Jak testować race conditions?

3

Zdaje się, że niektórzy trzymają się zasady, ze każdy bug musi mieć test który tego buga odtwarza i pilnuje, by odtąd bug nigdy nie powrócił. (@jarekr000000 o ile pamiętam o tym pisał kiedyś; ale już mi się nie chce teraz szukać)

W zasadzie założenie wydaje mi się sensowne, tylko czasem trudne w realizacji.

No dobrze, załóżmy, że mamy kod, który jest błędny, ale te błędy wyskakują tylko raz na jakiś czas, bo jest błąd we współbiezności - w jakimś szczególnym przypadku jakaś tablica jest odczytywana przez jeden wątek, modyfikowana przez drugi, zabrakło mutexu, albo ktoś chciał być mądry i celowo nie dał mutexu, bo sądził że memory barriers wystarczą, jednak nie wystarczyły i tego rodzaju rak.

Nie wiem, jak napisać test automatyczny, który to odtworzy. Chyba jedyny sposób to taki, by odtworzyć konkretny przeplot: znaleźć instrukcję A, znaleźć instrukcję B, uruchomić dwa wątki, zatrzymać jeden na instrukcji A, zatrzymać drugi na instrukcji B, puścić oba, powtarzać 100 razy aż wreszcie będziemy mieli jednoczesny zapis z dwóch wątków do tej samej zmiennej i błędy z tego wynikłe. Wygląda na w opór uciążliwe do napisania, ale pewnie możliwe.

Tyle że teraz naprawiamy buga. Mało tego, naprawiamy go porządnie: nie (tylko) dajemy mutex tam, gdzie go zabrakło, ale także przebudowujemy architekturę tak, by bug nie mógł się powtórzyć. Usuwamy sekcję krytyczną: usuwamy zmienną, która może być jednocześnie zapisywana z dwóch wątków (albo zapisywana z jednego i czytana z drugiego, whatever). Stosujemy wytyczne programowania funkcyjnego i za ich pomocą uwspółbieżniamy ponownie kod. Nie może być nieprawidłowej mutacji, skoro nie ma mutacji w ogóle.

Co teraz z naszym testem? Został on napisany tak, że zatrzymywał wątek na instrukcji A, puszczał drugi wątek na instrukcję B i tak dalej, by wymusić jeden konkretny przeplot. Ale nie ma już instruckji A ani instrukcji B. Czyli test w zasadzie należy wyrzucić?

Czy jest sens pisać test, któy jest w opór uciążliwy do napisania, jeśli natychmiast po naprawie buga musimy skasować test?

2

@YetAnohterone Nie wiem czy do końca zrozumiałem, ale jeśli:

(...) nie (tylko) dajemy mutex tam, gdzie go zabrakło, ale także przebudowujemy architekturę tak, by bug nie mógł się powtórzyć. Usuwamy sekcję krytyczną: usuwamy zmienną, która może być jednocześnie zapisywana z dwóch wątków (albo zapisywana z jednego i czytana z drugiego, whatever). Stosujemy wytyczne programowania funkcyjnego i za ich pomocą uwspółbieżniamy ponownie kod. Nie może być nieprawidłowej mutacji, skoro nie ma mutacji w ogóle.

Skoro zrefaktorowałeś kod, to i testy trzeba. Jeśli nie ma już instrukcji A i B, to test jest do wywalenia IMO.

BTW. Niektóre języki mają wbudowane narzędzia do sprawdzania race inne nie, ale są dla nich zazwyczaj dostępne jakieś zewnętrzne toole. Ja w Go mam hooka, który odpala mi właśnie flage race i wiem, że mój kod w miarę działa 😛

0
YetAnohterone napisał(a):

Zdaje się, że niektórzy trzymają się zasady, ze każdy bug musi mieć test który tego buga odtwarza i pilnuje, by odtąd bug nigdy nie powrócił. (@jarekr000000 o ile pamiętam o tym pisał kiedyś; ale już mi się nie chce teraz szukać)

W zasadzie założenie wydaje mi się sensowne, tylko czasem trudne w realizacji.

Często tak jest. @jarekr000000 ma rację. Taki bug powinien mieć test.

YetAnohterone napisał(a):

No dobrze, załóżmy, że mamy kod, który jest błędny, ale te błędy wyskakują tylko raz na jakiś czas, bo jest błąd we współbiezności - w jakimś szczególnym przypadku jakaś tablica jest odczytywana przez jeden wątek, modyfikowana przez drugi, zabrakło mutexu, albo ktoś chciał być mądry i celowo nie dał mutexu, bo sądził że memory barriers wystarczą, jednak nie wystarczyły i tego rodzaju rak.

Nie wiem, jak napisać test automatyczny, który to odtworzy. Chyba jedyny sposób to taki, by odtworzyć konkretny przeplot: znaleźć instrukcję A, znaleźć instrukcję B, uruchomić dwa wątki, zatrzymać jeden na instrukcji A, zatrzymać drugi na instrukcji B, puścić oba, powtarzać 100 razy aż wreszcie będziemy mieli jednoczesny zapis z dwóch wątków do tej samej zmiennej i błędy z tego wynikłe. Wygląda na w opór uciążliwe do napisania, ale pewnie możliwe.

Zapnij semafory w miejscu gdzie jest race condition, odpal kilka wątków w teście, poczekaj aż wszystkie przyjadą do semaforu, i potem puść wszystkie semafory na raz.

YetAnohterone napisał(a):

Czy jest sens pisać test, któy jest w opór uciążliwy do napisania, jeśli natychmiast po naprawie buga musimy skasować test?

Tak. Nauczysz się to robić, test suite będzie pełniejszy, staniesz się lepszym programistą przez to, kiedy wystąpi taki bug ponownie będziesz miał już gotowe rozwiązanie.

YetAnohterone napisał(a):

Tyle że teraz naprawiamy buga. Mało tego, naprawiamy go porządnie: nie (tylko) dajemy mutex tam, gdzie go zabrakło, ale także przebudowujemy architekturę tak, by bug nie mógł się powtórzyć. Usuwamy sekcję krytyczną: usuwamy zmienną, która może być jednocześnie zapisywana z dwóch wątków (albo zapisywana z jednego i czytana z drugiego, whatever). Stosujemy wytyczne programowania funkcyjnego i za ich pomocą uwspółbieżniamy ponownie kod. Nie może być nieprawidłowej mutacji, skoro nie ma mutacji w ogóle.

Co teraz z naszym testem? Został on napisany tak, że zatrzymywał wątek na instrukcji A, puszczał drugi wątek na instrukcję B i tak dalej, by wymusić jeden konkretny przeplot. Ale nie ma już instruckji A ani instrukcji B. Czyli test w zasadzie należy wyrzucić?

To jest w zasadzie judgment call.

  • Czym się nie powinieneś kierować:

    • test jest trudny do napisania
    • test jest w miarę ciężki do wymyślenia/utrzymania
  • Czym się powinieneś kierować:

    • Czy test ma szansę mi pomóc w przyszłości?

Jeśli odpowiedź na ostatnie pytanie brzmi "nie", to wywal go.

Dregorio napisał(a):

Skoro zrefaktorowałeś kod, to i testy trzeba.

A skąd.

0

@Riddle Masz rację, wywaliłem/zmieniłem funkcjonalność, więc zostawiam test.
Piszesz test -> odpalasz -> test nie przechodzi -> piszesz funkcję -> odpalasz test -> test przechodzi -> zmieniasz funkcję -> test przechodzi -> wywalasz funkcję -> test nie przechodzi. - i mówimy o tej sytuacji tylko "w większej" skali.

1
Dregorio napisał(a):

@Riddle Masz rację, wywaliłem/zmieniłem funkcjonalność, więc zostawiam test.
Piszesz test -> odpalasz -> test nie przechodzi -> piszesz funkcję -> odpalasz test -> test przechodzi -> zmieniasz funkcję -> test przechodzi -> wywalasz funkcję -> test nie przechodzi. - i mówimy o tej sytuacji tylko "w większej" skali.

No tak, tylko jak rozumiem Twój test ma sprawdzać race conditions. Czyli jeśli dobrze rozumiem, to ten test ma zapewnić że wynik jest ten sam, niezależnie od kolejności w jakiej wątki zaczną śmigać. No to na moje ten test powinien zostać i działać tak jak działa.

Bo tak — bez race conditiona wynik ma być ten sam, i z podejściem funkcyjnym ma być ten sam. Nie widzę powodu żeby ten test zaczął failować. Chyba że funkcjonalność zostaje usunięta. Wtedy test nie ma sensu.

2

Napisz test, który odpala te akcje w osobnych wątkach. Test instrumentuj czymś do wykrywania race conditions np. -race w go, -fsanitize=thread w C++. Sanitizery wykrywają race conditiony nawet, gdy ich fizycznie nie ma, bo instrumentacja patrzy czy wszystko jest poprawnie wywołane. Z doświadczenia wiem, że tego typu narzędzia praktycznie zawsze wykrywają problemy od razu

Oczywiście concurrency w bardziej możliwych przypadkach może nie być możliwe do przetestowania, bo możliwości przebiegu programu jest więcej niż atomowów we wszechświecie

YetAnohterone napisał(a):

Co teraz z naszym testem? Został on napisany tak, że zatrzymywał wątek na instrukcji A, puszczał drugi wątek na instrukcję B i tak dalej, by wymusić jeden konkretny przeplot. Ale nie ma już instruckji A ani instrukcji B. Czyli test w zasadzie należy wyrzucić?

Czy jest sens pisać test, któy jest w opór uciążliwy do napisania, jeśli natychmiast po naprawie buga musimy skasować test?

Podejdź do testów z perspektywy interfejsu. Jeśli funkcje mogą być wywołane w osobnych wątkach to napisz test, który to symuluje. Nie skupiaj się na implementacji

0

@Riddle tak, zgadzam się. Ja piję do sytuacji, że nasze testy zależą od instrukcji A i B. Skoro ich nie ma, test nie ma racji bytu IMO

1

Jak pomniał slsy dla C++/C i innych wspieranych przez clang/gcc jest thread sanitizer.
Potrafi wyłapać większość race conditions i potencjale dead locks.
Piszesz normalny Unit test, który korzysta z wielu wątków w jednym teście, a narzędzie potrafi wykryć race condition nawet gdy on nie nastąpi.
Instrumentatrium generuje nadmiarową informację, jakie locki były aktywne podczas zapisu odczytu i na tej podstawie jest w stanie wykryć wiele błędów.
Błąd bezie zawierał nie tylko adres, ale też call stack wątków, które wykonały dostęp do tej pamięci bez włąściwych locków, albo złą kolejność locków (dead lock).

0

tylko dla c++/c?
bo jednoczesny zapis do tej samej zmiennej może stwarzać problemy także w językach takich jak c#
ok nie będzie to UB jak w C(++), ale mogą w zmiennej być smieci

0
YetAnohterone napisał(a):

tylko dla c++/c?

To może powiedz jaka technologia cię interesuje dokładnie? Golang ma też coś takiego -race. Java o ile dobrze wiem też

bo jednoczesny zapis do tej samej zmiennej może stwarzać problemy także w językach takich jak c#
ok nie będzie to UB jak w C(++), ale mogą w zmiennej być smieci

Zgaduje, że będzie. Wszystko zależy jaki C# ma model pamięci

1
YetAnohterone napisał(a):

tylko dla c++/c?

Może https://github.com/microsoft/coyote? Zbieżność nazw z pewnym projektem forum napisanym w c# przypadkowa.

0

daj po prostu Disabled na testa jak race condition naprawiony i bedzie przechodzic, same istnienie takiego test bedzie tez "przestroga" czy tez ostrzezeniem dla innych i jakby problem znowu wystapil to wystarczy odkomentowac

2

https://www.baeldung.com/java-testing-multithreaded

Bywa, że faktycznie zrobienie testu na race jest dramatycznie trudne. Czasem robi się test przez tydzień, żeby potem zrobić jedną linijką poprawkę.
Ma to sens:

  • w starych kobyłach na produkcji, gdzie stabilność ma duże znaczenie.
  • jeśli nie jesteśmy 100% pewni jaka jest przyczyna błędu z produkcji i fajnie jest potwierdzić, że np. taki sam exception leci w teście.

Jeśli projekt jest w fazie, gdzie taka inwestycja wydaje się bez sensu, lub miałbym wprowadzać sztuczne zmiany w kodzie, żeby umożliwić test ... to odpuściłbym sobie (aczkolwiek nie pamiętam takiej sytuacji).

1

W moich przypadkach po prześledzeniu logów z produkcji w miarę łatwo można było zorientować się jaka metoda api została wywołana wielokrotnie i powodowała problemy.
U mnie łatwiej było napisać test niż znaleźć źródłowy problem. Podczas pisania testów skupiałem się na opisaniu w teście poprawnego zachowania. Np aby współbieżne wywołanie jakiegoś api nie powodowało zawieszania/błędów/wyjątków tylko za każdym razem poprawne dane zostały zwrócone.
Testy na race condition zostawały i były aktywne cały czas po naprawieniu błędu.
Ostatnio moje doświadczenia w tym temacie przekuwam w bibliotekę wspomagającą testowanie race condition'ów i deadlock'ów w Javie (https://github.com/stawirej/threads-collider)

1 użytkowników online, w tym zalogowanych: 0, gości: 1