Dwa requesty trafiają na ten sam endpoint “wypłać 100€” z różnicą pół milisekundy. Konto ma 150€. Oba requesty czytają ten sam stan konta, oba widzą wystarczające środki, oba zostają zatwierdzone, oba odejmują 100€. Konto kończy na -50€, a żadna ścieżka w kodzie nie powinna do tego doprowadzić. To jest race condition: wynik zależy od tego, która operacja skończy się pierwsza, a to, co było “pierwsze”, nigdy nie zależało od kodu.
Co powoduje race condition?
Potrzeba dwóch składników naraz: współdzielonego, mutowalnego stanu i więcej niż jednej ścieżki wykonania, która go dotyka bez koordynacji. Ścieżkami mogą być thready, taski async, procesy albo dwa serwery sięgające po ten sam wiersz w bazie danych. Usuń jeden z tych składników i bug zniknie. Jeden thread nie może rywalizować sam ze sobą, a niemutowalnego stanu nie da się uszkodzić, czytając go dwa razy.
Klasyczny wzorzec to check-then-act: czytasz wartość, podejmujesz na jej podstawie decyzję, potem działasz zgodnie z tą decyzją. Jeśli w przerwie między twoim odczytem i zapisem inna ścieżka wykona tę samą sekwencję czytaj-decyduj-działaj, obie podejmują decyzję słuszną w chwili, która przestaje istnieć, zanim którakolwiek z nich zapisze wynik.
Współdzielony licznik pokazuje ten mechanizm w najczystszej postaci, bez żadnych dodatkowych komplikacji. counter += 1 wygląda na jedną operację. W rzeczywistości to trzy: odczytaj aktualną wartość, dodaj jeden, zapisz wynik. Jeśli dwa thready wykonują tę sekwencję na liczniku startującym od 0, i oba odczytują, zanim którykolwiek zapisze, oba obliczają 1 i oba zapisują 1. Jedno zwiększenie przepada. Bez wyjątku, bez linii w logu.
| |
Puść to kilka razy. Niedobór nie jest zawsze taki sam — to druga cecha charakterystyczna race condition: nie powtarza się przewidywalnie, bo zależy od schedulingu, który różni się między uruchomieniami i między maszynami.
Jak naprawić race condition za pomocą locka?
Zamień sekwencję czytaj-modyfikuj-zapisz w operację atomową: gdy jeden thread ją zacznie, żaden inny nie może się wtrącić. Lock robi to, pozwalając tylko jednemu threadowi trzymać go naraz. Każdy inny thread, który wywoła acquire(), blokuje się, aż ten, który go trzyma, wywoła release().
| |
with lock: przejmuje lock na wejściu i zwalnia na wyjściu, również wtedy, gdy wewnątrz bloku zostanie zgłoszony wyjątek. Nigdy nie zostajesz z lockiem, którego nikt nie zwalnia.
Błąd, który podtrzymuje bug przy życiu nawet po dodaniu locka: blokowanie zapisu, ale nie sprawdzenia. Ta funkcja dawkowania wygląda bezpiecznie i nie jest:
| |
Dwa thready mogą przejść sprawdzenie, zanim którykolwiek z nich przejmie lock. Oba dodają swoją kwotę, a suma przekracza limit. Lock musi obejmować sprawdzenie i działanie jako jedną sekcję krytyczną, nie tylko linię, która wygląda niebezpiecznie:
| |
Pod GIL-em Pythona ta pierwsza, zbyt wąsko zablokowana wersja często przypadkowo działa. Dlatego przechodzi przez code review: przechodzi każdy test na twoim laptopie, a potem zawodzi pod obciążeniem produkcyjnym albo w dniu, gdy przechodzisz na build free-threaded.
Czy async JavaScript może mieć race conditions?
Tak. JavaScript wykonuje twój kod na jednym threadzie w przeglądarce i w głównym wykonaniu Node, ale ten sam rodzaj błędu wraca, gdy dwa await mogą skończyć się w kolejności, której nie zaplanowałeś. Pole wyszukiwania to typowy przypadek. Użytkownik wpisuje “kot”, leci request, wpisuje dalej “kotek”, leci drugi request. Jeśli pierwszy request jest wolniejszy, jego odpowiedź przychodzi po drugiej i zastępuje nowsze wyniki starymi.
| |
Każde wywołanie oznacza się swoim ID, zanim napotka pierwszy await. Gdy odpowiedź wraca, renderuje się tylko wtedy, gdy wciąż jest najnowszym requestem. AbortController to drugie popularne narzędzie: od razu przerywa stary request, zamiast pozwolić mu się zakończyć i wyrzucić wynik, co dodatkowo oszczędza round trip w sieci.
Subtelniejsza wersja nie ma żadnego wywołania sieciowego, tylko współdzieloną zmienną rozciągającą się na await:
| |
Obie funkcje czytają totalLength jako 0, zanim którykolwiek z dwóch await się rozwiąże. Obie liczą swoją nową sumę od tego samego nieaktualnego punktu startowego, a drugi zapis nadpisuje pierwszy. Odczyt i zapis są rozdzielone punktem zawieszenia, a w tym czasie może wykonać się dowolny inny kod. Jeśli ten scheduling nie jest jeszcze oczywisty, /pl/posts/javascript/async-await-i-event-loop-javascript/ wyjaśnia, co robi event loop przy każdym await. Rozwiązanie: rozwiąż wartość najpierw i wpleć ją do współdzielonego stanu w jednym synchronicznym kroku (const len = await getPageLength(n); totalLength += len;), albo wykonuj te dodawania sekwencyjnie, a nie za pomocą Promise.all.
Jak zapobiegać race conditions w bazie danych?
Przenieś licznik do wiersza i przerwa między check i act się rozszerza, bo teraz obejmuje round trip do bazy danych. Dwa serwery aplikacji, każdy wykonujący “odczytaj saldo, zweryfikuj, zapisz nowe saldo” na tym samym wierszu, mogą oba odczytać 150€ i oba zatwierdzić wypłatę 100€. To jest przykład z początku, na poziomie SQL.
Dwa rozwiązania, z różnymi kosztami.
Blokowanie pesymistyczne przejmuje lock na wierszu z góry. W PostgreSQL to SELECT ... FOR UPDATE:
| |
Druga transakcja próbująca SELECT ... FOR UPDATE na tym samym wierszu blokuje się, aż pierwsza zrobi commit albo rollback. Poprawne, ale każdy równoległy request do tego wiersza teraz czeka w kolejce za lockiem. To kosztuje czas pod obciążeniem i może doprowadzić do deadlocka, jeśli dwie transakcje blokują wiersze w odwrotnej kolejności. Trzymaj transakcję krótką: tutaj wiersz znajdujesz po kluczu głównym, czyli przez wyszukanie po indeksie. Odfiltruj po kolumnie bez indeksu, i wyszukanie zamienia się w skan, który wydłuża każdą transakcję, podczas gdy inne czekają (mechanika jest w /pl/posts/databases/jak-dzialaja-indeksy-w-bazie-danych/).
Blokowanie optymistyczne pomija lock i sprawdza konflikt w momencie zapisu, zwykle za pomocą kolumny wersji:
| |
Jeśli inna transakcja już zaktualizowała wiersz, version nie jest już 7, żaden wiersz nie zostaje zaktualizowany, a twoja aplikacja to zauważa i powtarza operację ze świeżą wartością. To skaluje się lepiej, gdy konflikty są rzadkie. Działa tylko, jeśli sprawdzenie wersji i zapis dzieją się w tej samej instrukcji. Odczytaj wersję, potem wykonaj osobny UPDATE na jej podstawie, i otworzysz z powrotem przerwę, którą próbowałeś zamknąć.
Jaka jest różnica między race condition a deadlockiem?
Obie trafiają do szuflady “bugi współbieżności” i zawodzą w odwrotny sposób:
| Race condition | Deadlock | |
|---|---|---|
| Co się dzieje | Wykonanie się kończy, z błędnym lub niekonsekwentnym wynikiem | Wykonanie się zatrzymuje; nic się nie kończy |
| Przyczyna | Niezsynchronizowany dostęp do współdzielonego stanu | Dwa lub więcej locków czekających na siebie w cyklu |
| Jak to zauważyć | Liczby, które się nie zgadzają, dane “czasami” błędne | Program albo request się zawiesza |
| Typowa naprawa | Locki, operacje atomowe, albo usunięcie współdzielonego mutowalnego stanu | Konsekwentna kolejność locków, timeouty |
Naprawa jednego może wprowadzić drugie. Owinięcie buga check-then-act lockiem usuwa race. Jeśli ten kod przejmuje też drugi lock, w innej kolejności niż inna ścieżka kodu, zamieniłeś ciche uszkodzenie danych na zawieszenie. Przejmuj locki wszędzie w tej samej kolejności i trzymaj zablokowaną sekcję tak krótko, jak pozwala na to poprawność.
Jak wykryć race conditions przed produkcją?
Race rzadko pokazują się w teście na jednym threadzie, który działa sam na twoim laptopie. Są częstą przyczyną flaky testów — temat, który szerzej opisuje /pl/posts/testing/testy-jednostkowe-vs-integracyjne-vs-end-to-end/. Test, który zawodzi raz na pięćdziesiąt uruchomień pod obciążeniem CI i przechodzi każdy raz, gdy puszczasz go lokalnie, zasługuje na podejrzenie, zanim nazwiesz go po prostu “flaky”.
Go ma do tego wbudowany detektor. Skompiluj albo testuj z -race, a runtime instrumentuje dostępy do pamięci, zgłaszając każdy przypadek, w którym dwie goroutines dotykają tej samej zmiennej współbieżnie i przynajmniej jedna z nich zapisuje.
| |
Wykrywa tylko race, które faktycznie wykonują się w trakcie przebiegu, więc im bliżej twoje testy są rzeczywistego obciążenia współbieżnego, tym więcej znajduje. Mnoży też zużycie CPU i pamięci kilkukrotnie, więc jego miejsce jest w CI, nie w binarce produkcyjnej. Python i Java nie mają nic wbudowanego na tym poziomie, co jest kolejnym powodem, by polegać na lockach i atomowych operacjach bazodanowych, a nie liczyć na to, że same testy dadą ci pewność.
Cache skrywają ten sam bug. Dwa requesty, które sprawdzają ten sam klucz, widzą nieaktualną wartość i potem ją ustawiają — to znowu check-then-act, przerwa, z którą musi liczyć się wzorzec cache-aside opisany w /pl/posts/backend/jak-dziala-cache-redis/.
Zanim sięgniesz po lock, zapytaj jednak, czy współdzielony mutowalny stan musi istnieć w ogóle. Saldo aktualizowane tylko przez jedną atomową instrukcję, taką jak UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100, nie ma żadnej przerwy między check i act, bo baza danych robi obie rzeczy w jednym kroku. Najtańsza naprawa race condition to usunięcie drugiej ścieżki, która rywalizowała z pierwszą, więc zacznij swoją następną review od wylistowania każdego miejsca, które zapisuje do tego stanu.