Stosowanie operacji atomowych jest pięknym i prostym rozwiązaniem wielu problemów pojawiających się w środowiskach wielowątkowych, gdzie wiele procesów próbuje modyfikować te same dane. Niestety, nie zawsze ich użycie rozwiązuje problem.
Nie zawsze operacja atomowa wystarczy
Wyobraźmy sobie, że chcemy uzależnić jakąś operację w sklepie od stanu magazynowego. Jeśli mamy dokładnie 5 produktów w magazynie, być może chcielibyśmy wysłać administratorowi magazynu SMS z informacją, że produkty się kończą. Spróbujmy to zrobić:
Jeśli dwa procesy pobiorą liczbę produktów z magazynu w podobnym czasie, to oba mogą otrzymać liczbę 5 i wysłać SMS. Pierwszy proces nie zdąży zaktualizować magazynu, zanim drugi proces nie odczyta jego stanu. Administrator dostanie dwa SMS-y zamiast jednego i to pomimo tego, że samo zmniejszenie stanu magazynowego jest atomowe. Dobrze, że to był tylko SMS, a nie przelew na pół miliona.
Niezależnie od tego, w jaki sposób realizowana jest operacja wysłania SMS, nie możemy w łatwy sposób połączyć jej z modyfikacją stanu magazynowego w bazie danych.
Nie zawsze pracujemy z bazą danych
WooCommerce umożliwia także zmianę stanu magazynowego za pomocą API. Załóżmy, że w ramach obsługi żądania klienta, chcemy pobrać stan magazynowy pewnego produktu, zmniejszyć go o jeden i zapisać. Jeśli może się zdarzyć sytuacja, że dwóch klientów wyśle request, który odwoła się do API i najpierw pobiorą stan magazynowy, a następnie zapiszą zmodyfikowany, to analogiczne jak w przypadku działaniu na bazie danych, jeden z procesów może nadpisać dane nieprawidłową wartością. Ponieważ posługujemy się API, nie możemy skonwertować obu czynności do jednego zapytania.
Każda z tych sytuacji uniemożliwia proste rozwiązanie problemu za pomocą operacji atomowej.
Sekcja krytyczna
Istota problemu, na który natrafiamy, polega na tym, że więcej niż jeden proces może równolegle wykonywać ten sam kod. Rozwiązanie problemu nasuwa się samo: trzeba zabezpieczyć tę część kodu, która nie może być wykonywana równolegle. Kolejne procesy powinny zaczekać z przetwarzaniem takiego kodu tak długo, aż poprzedni proces tego nie zakończy. Taką specjalną część kodu, która może być wykonywana jednocześnie co najwyżej przez jeden proces, nazywamy sekcją krytyczną. Jak jednak zapewnić, że inne procesy zaczekają z działaniem na kolegę, który pierwszy wszedł do sekcji?
Muteksy
Najłatwiejszym sposobem zapewniającym, że kolejne procesy nie rozpoczną przetwarzania kodu sekcji krytycznej, jest użycie współdzielonej między procesami flagi. Współdzielonej, to znaczy takiej, że każdy proces ma dostęp do jej stanu: może odczytać lub zmodyfikować jej stan. Pierwszy proces, który wchodzi do sekcji, ustawia za pomocą operacji atomowej flagę i powiadamia w ten sposób inne procesy, że w tym momencie kod sekcji jest przetwarzany. Po opuszczeniu sekcji flaga jest czyszczona, więc do sekcji może wejść kolejny proces.
Taka flaga jest przykładem prostego algorytmu zapewniającego wzajemne wykluczanie (ang. MUTual EXclusion), czyli mutex — jeśli jeden proces znajduje się w sekcji, wyklucza to wejście do sekcji kolejnych procesów.
Własna implementacja muteksu nie jest spektakularnie trudna, ale łatwo można popełnić błąd. Z tego powodu najłatwiej będzie użyć narzędzi, które stworzyli inni. Kilka lat temu w WP Desk potrzebowaliśmy muteksów, między innymi ze względu potrzebę generowania odpornej na wątki numeracji faktur. Ponieważ nie udało nam się znaleźć implementacji, która dobrze sprawdzałaby się w WordPress, stworzyliśmy swoją, dostępną pod adresem https://gitlab.com/wpdesk/wp-mutex
Jak użyć muteksu? Jest to bardzo proste:
Procesy, które napotkają polecenie acquireLock, zaczekają maksymalnie 5 sekund na pozwolenie wejścia do sekcji krytycznej. Jeśli po 5 sekundach dostęp nadal będzie niemożliwy, wyjątek zostanie wyrzucony.
Co się stanie, jeśli zapomnimy wyczyścić flagę?
Głodne i smutne procesy
Jeśli proces który ustawił flagę zbyt długo będzie przebywał w sekcji krytycznej, to procesy oczekujące na wejście zaczną rzucać wyjątki. Zostaną zagłodzone (ang. process starvation) w oczekiwaniu na zasoby.
Jeszcze gorsza sytuacja może pojawić się, jeśli proces natrafi na błąd podczas przetwarzania kodu w sekcji. Może wyjść z sekcji i nie wyczyścić flagi głodząc wszystkie inne procesy. Z tego powodu bardzo dobrym pomysłem opakowanie całej sekcji w try/finally. Dzięki temu nawet w przypadku gdy wystąpi jakiś błąd podczas wysyłania SMS, proces nadal będzie pamiętał, żeby wyczyścić flagę i zwolnić mutex.
Zakleszczenia
Czasami będziemy potrzebowali więcej niż jednej sekcji krytycznej. Może wtedy dojść do sytuacji, że proces będący w sekcji B, będzie czekał na pozwolenie wejścia do sekcji A. Pozwolenie, które nigdy nie zostanie udzielone, ponieważ proces, który przetwarza sekcję A, w tym samym czasie oczekuje na wejście do sekcji B. Procesy będą wzajemnie na siebie czekały i dojdzie to zakleszczenia (ang. deadlock). W praktyce, w samym kodzie PHP do zakleszczeń dochodzi bardzo rzadko, ponieważ zadania biznesowe stojące przed programistami WordPress, a co za tym kod, który piszemy, są dość łatwe. Jednak do tematu zakleszczeń wrócimy w przyszłości, ponieważ w środowiskach bazodanowych, a dla WordPress zwłaszcza w kontekście MySQL InnoDB, deadlock może wpędzić w nerwicę nawet największego stoika.
Prawie zawsze procesy PHP uruchamiane są wielowątkowo, dlatego umiejętność synchronizacji procesów to wiedza, którą powinien posiadać każdy programista. Czy wyczerpałem temat wielowątkowości? Nie. To dopiero początek, ale podane informacje są wystarczające, żeby poradzić sobie z większością problemów, które czyhają na nieuważnego programistę WordPress :)