Odpowiedź na pytanie.
Historia pytania.
ReentrantReadWriteLock, wprowadzony w Javie 5, zapewnił znaczną poprawę współbieżności w porównaniu z pojedynczymi mutexami, umożliwiając jednoczesne odczyty przez wielu użytkowników. Jednak jego projekt wyraźnie zabrania podnoszenia blokady — nabywania blokady zapisu podczas trwania blokady odczytu — ponieważ implementacja śledzi liczbę posiadanych odczytów dla każdego wątku. Kiedy wątek posiadający blokadę odczytu próbuje zdobyć blokadę zapisu, zakleszcza się: blokada zapisu wymaga wyłącznej własności, co nie może być przyznane, gdy jakiekolwiek blokady odczytu (w tym własna wątku) pozostają aktywne. StampedLock, wprowadzony w Javie 8 jako alternatywa nie-rekurencyjna, rozwiązał to ograniczenie przez optymistyczne znaczniki odczytu, które nie wymagają posiadania blokady podczas fazy odczytu, w połączeniu z atomowymi mechanizmami walidacji i konwersji.
Problem.
Fundamentalne niebezpieczeństwo wynika z asymetrii w semantyce nabywania blokady. W ReentrantReadWriteLock, podnoszenie wymaga zwolnienia blokady odczytu przed nabyciem blokady zapisu, co tworzy podatne okno, w którym inne wątki mogą zdobyć blokadę zapisu lub zmienić stan między zwolnieniem a ponownym nabyciem. To zmusza programistów do implementacji skomplikowanych wzorców podwójnego sprawdzania blokady lub pętli ponawiających, zwiększając złożoność kodu i latencję. Co więcej, jeśli programista omyłkowo próbuje bezpośredniego podniesienia (writeLock().lock() podczas trwania readLock()), wątek wchodzi w nieodwracalny stan zakleszczenia, czekając na zwolnienie zezwolenia na odczyt.
Rozwiązanie.
StampedLock eliminuje to niebezpieczeństwo poprzez tryOptimisticRead(), które zwraca długi znacznik bez zdobywania jakiejkolwiek blokady lub zwiększania liczby odczytujących. Wątek wykonuje swoje operacje odczytu, a następnie wywołuje validate(stamp); jeśli znacznik pozostaje ważny (nie wystąpił żaden piszący w międzyczasie), odczyt był spójny bez blokowania. Jeśli wątek wykryje potrzebę zapisu, próbuje tryConvertToWriteLock(stamp), które atomowo waliduje znacznik i zdobywa blokadę zapisu tylko wtedy, gdy stan nie zmienił się od początku optymistycznego odczytu. Takie podejście zapobiega zakleszczeniu, ponieważ wątek nigdy nie trzyma sprzecznej blokady odczytu podczas przejścia, a unika okna wyścigu strategii zwolnienia i ponownego nabycia przez uzależnienie podnoszenia od spójności stanu.
Przykład kodu.
import java.util.concurrent.locks.StampedLock; public class AtomicUpgradeCache { private final StampedLock lock = new StampedLock(); private int value = 0; public void conditionalUpdate(int threshold, int newValue) { long stamp = lock.tryOptimisticRead(); int current = value; // Walidacja przed działaniem if (!lock.validate(stamp)) { stamp = lock.readLock(); try { current = value; } finally { lock.unlockRead(stamp); } } if (current < threshold) { // Próba atomowego podniesienia stamp = lock.tryConvertToWriteLock(stamp); if (stamp == 0L) { // Konwersja się nie powiodła, zdobyj świeżą blokadę zapisu stamp = lock.writeLock(); } try { // Ponowne sprawdzenie warunku pod wyłączną blokadą if (value < threshold) { value = newValue; } } finally { lock.unlock(stamp); } } } }
Sytuacja z życia
Opis problemu.
Platforma handlu wysokiej częstotliwości utrzymywała pamięciowy podręcznik zleceń reprezentujący aktualną głębokość rynku, wymagający około 50 000 odczytów na sekundę z setek wątków, ale jedynie sporadycznych aktualizacji, gdy na rynku następowały zmiany cen. Początkowa implementacja korzystała z bloków synchronized, co powodowało katastrofalne skoki latencji podczas zmienności rynku, kiedy wątki rywalizowały o monitor, a latencja odczytu czasami przekraczała 500 milisekund. Zespół inżynierski musiał całkowicie wyeliminować rywalizację po stronie odczytu, zapewniając jednocześnie, że aktualizacje cen mogły atomowo weryfikować warunki rynkowe i modyfikować rejestr bez zakleszczenia podczas aktualizacji z obserwacji do mutacji.
Różne rozważane rozwiązania.
Rozwiązanie 1: ReentrantReadWriteLock z zwolnieniem i ponownym nabyciem.
To podejście polegało na zdobywaniu blokady odczytu w celu zbadania warunków rynkowych, zwolnieniu jej, a następnie natychmiastowym próbie zdobycia blokady zapisu, jeśli aktualizacja była konieczna. Chociaż unikało to zakleszczenia, wprowadzało istotny wyścig stanów: między zwolnieniem blokady odczytu a zdobyciem blokady zapisu, rywalizujące wątki mogły obserwować ten sam stary stan i zainicjować zbędne zapytania do bazy danych lub wymieniać wywołania API, co prowadziło do zjawiska stada ognia i marnowania zasobów obliczeniowych. Dodatkowo, ciągłe przełączanie kontekstu między trybami odczytu i zapisu dodawało wymierne opóźnienie podczas okresów handlu o dużej objętości.
Rozwiązanie 2: Niezmienniki z odniesieniami do zmiennych.
To rozwiązanie całkowicie zrezygnowało z blokad na rzecz utrzymania podręcznika zleceń jako niezmiennej struktury danych z odniesieniami do zmiennej. Czytelnicy po prostu dereferencjonowali zmienną, aby uzyskać spójny zrzut, podczas gdy pisarze tworzyli całkowicie nowe kopie podręcznika i przeprowadzali atomowe operacje porównania i ustawienia na odniesieniu. To całkowicie wyeliminowało rywalizację przy odczycie i zapewniło doskonałą wydajność odczytu. Jednak generowało ogromny nacisk na alokację — każda drobna aktualizacja cen wymagała skopiowania całej struktury podręcznika, co wywoływało częste pauzy w zbieraniu śmieci w młodym pokoleniu, które naruszały 10-milisekundowe SLA latencji aplikacji podczas zmiennych warunków rynkowych.
Rozwiązanie 3: StampedLock z optymistycznymi odczytami i warunkową konwersją.
Wybrane rozwiązanie wykorzystało StampedLock do zapewnienia optymistycznego dostępu do odczytu dla gorącej ścieżki: wątki optymistycznie odczytywały stan podręcznika zleceń, używając tryOptimisticRead(), walidowały znaczek i kontynuowały tylko wtedy, gdy nie wystąpił jednoczesny zapis. Dla rzadkich operacji zapisu, system próbował przekonwertować optymistyczny znacznik bezpośrednio na blokadę zapisu przy użyciu tryConvertToWriteLock(), tym samym atomowo walidując, że zaobserwowany stan pozostał aktualny i nabywając wyłączny dostęp tylko wtedy, gdy to było możliwe. W przypadku niepowodzenia konwersji, system przechodził do jawnego przejęcia blokady zapisu z tradycyjną logiką ponawiania. To podejście zapewniło niemal zerowe opóźnienie dla odczytów (podobnie do surowego dostępu zmiennej) przy jednoczesnym zapobieganiu ryzyku zakleszczenia, które charakteryzowało podnoszenie w ReentrantReadWriteLock.
Które rozwiązanie zostało wybrane (i dlaczego).
Zespół wybrał Rozwiązanie 3, ponieważ unikalnie równoważyło ekstremalne wymagania dotyczące przepustowości odczytu (optymistyczne odczyty skalują się liniowo z liczba wątków) z wymaganiami na bezpieczeństwo atomowe dla warunkowych aktualizacji. W przeciwieństwie do Rozwiązania 1, wyeliminowało okno wyścigu między zwolnieniem odczytu a przejęciem zapisu poprzez mechanizm walidacji znacznika. W przeciwieństwie do Rozwiązania 2, unikało nacisku na alokację pamięci poprzez umożliwienie modyfikacji na miejscu pod ochroną przekonwertowanej blokady zapisu, zamiast wymagać całkowitych kopii strukturalnych dla każdej drobnej korekty ceny. Możliwość walidacji i konwersji atomowej zapewniła, że aktualizacje cen następowały tylko wtedy, gdy stan rynku odpowiadał dokładnie kryteriom decyzyjnym, zapobiegając naruszeniom spójności, które dręczyły wcześniejsze prototypy.
Rezultat.
Po wdrożeniu aplikacja utrzymywała 50 000 równoczesnych odczytów na sekundę z latencjami p99.9 poniżej 15 mikrosekund, co stanowiło 30-krotną poprawę w porównaniu do poprzedniego podejścia opartego na synchronizacji. Podczas symulowanej zmienności rynku z 1 000 równoczesnymi aktualizacjami cen na sekundę, system utrzymywał zerowe incydenty zakleszczenia, a pauzy w zbieraniu śmieci pozostawały poniżej 2 milisekund. Implementacja StampedLock skutecznie obsługiwała sześć miesięcy produkcyjnego handlu bez żadnego incydentu związanego z współbieżnością lub wyścigu danych, weryfikując architektoniczną decyzję o wykorzystaniu optymistycznego blokowania dla scenariuszy odczytu wysokiej częstotliwości.
Co często umyka kandydatom
Dlaczego StampedLock nie wspiera rekurencji, i jaki katastrofalny tryb awarii występuje, jeśli wątek próbuje rekurencyjnie nabyć tę samą blokadę?
StampedLock jest wyraźnie zaprojektowany jako blokada nie-rekurencyjna w celu minimalizacji śledzenia stanu wewnętrznego i maksymalizacji przepustowości. W przeciwieństwie do ReentrantReadWriteLock, który utrzymuje mapę wątków właścicieli i liczb posiadanych, StampedLock śledzi tylko to, czy jakikolwiek wątek ma dostęp, a nie który konkretny wątek go posiada. W konsekwencji, jeśli wątek posiadający blokadę odczytu próbuje zdobyć inną blokadę odczytu (lub blokadę zapisu) na tej samej instancji StampedLock, natychmiast się zakleszcza: wywołanie nabycia blokuje czekając na zwolnienie wszystkich istniejących blokad, ale zablokowany wątek sam posiada jedną z tych blokad, co tworzy nierozwiązywalną zależność cykliczną. Programiści muszą refaktoryzować kod, aby przekazać bieżący znacznik jako parametr metody zamiast próbować zagnieżdżonych nabyć blokad, co często wymaga znacznych zmian architektonicznych w wewnętrznych API, które wcześniej polegały na lokalnym stanie blokady.
Jak różnią się semantyka widoczności pamięci w trybie optymistycznego odczytu StampedLock od jego pesymistycznej blokady odczytu, i dlaczego validate() samodzielnie nie jest wystarczające do zapewnienia spójności bez odpowiednich relacji happens-before?
Optymistyczny odczyt za pomocą tryOptimisticRead() nie zapewnia gwarancji happens-before sam w sobie; jedynie rejestruje wersję znaczka bez wydawania barier pamięci ani zapobiegania przeorganizowaniu instrukcji. Dane obserwowane podczas fazy optymistycznej mogą odzwierciedlać przestarzałe linie pamięci podręcznej CPU lub częściowo skonstruowane obiekty, ponieważ model pamięci JVM traktuje optymistyczne odczyty jako zwykłe dostęp do zmiennych bez semantyki synchronizacji. Dopiero gdy validate(stamp) zwraca prawdę, ustanawia, że żaden zapis nie był nabyty od czasu rozpoczęcia optymistycznego odczytu, tworząc tym samym odpowiednią krawędź happens-before w stosunku do najbardziej niedawnego zwolnienia blokady zapisu. Jednak kandydaci często pomijają, że validate() zapewnia jedynie stan blokady, a nie wewnętrzną spójność struktury danych: jeśli chronione dane zawierają nie-wolne odniesienia do zmiennych obiektów, optymistyczny odczyt może zaobserwować odniesienie do obiektu, którego pola są nadal inicjowane przez inny wątek (niebezpieczne publikowanie). Z tego powodu, optymistyczne odczyty wymagają, aby chroniony stan składał się wyłącznie z odniesień wolnych lub obiektów niezmiennych, aby zapewnić bezpieczne publikowanie niezależnie od semantyki pamięci blokady.
Jaka jest fundamentalna niezgodność między StampedLock a Wirtualnymi Wątkami (Project Loom), i dlaczego to wymaga unikania StampedLock w nowoczesnych aplikacjach o wysokiej współbieżności wykorzystujących wirtualne wątki?
Implementacje StampedLock polegają na operacjach LockSupport.park, które przypinają podstawowy Wątek Platformy (wątek nośny), kiedy wirtualny wątek blokuje, trzymając blokadę. Kiedy wirtualny wątek próbuje zdobyć obciążoną StampedLock (czy to odczytową, czy zapisu), JVM nie może odmontować wirtualnego wątku z jego nośnika, ponieważ wnętrze blokady używa natywnych prymitywów synchronizacyjnych, które nie zostały jeszcze dostosowane do wydania wątków wirtualnych. To przypinanie zrywa podstawową obietnicę skalowalności wątków wirtualnych, które multiplikują tysiące wirtualnych wątków na niewielu wątkach platformy. Jeżeli wiele wirtualnych wątków jednocześnie blokuje się na obciążeniu StampedLock, monopolizują cały pulę wątków piramidalnych, zamrażając aplikację, nawet jeśli miliony wirtualnych wątków pozostają teoretycznie dostępne. W przeciwieństwie do tego, ReentrantLock i Semaphore zostały przystosowane, aby uniknąć przypinania, wykorzystując nieblokujące algorytmy lub specjalistyczne mechanizmy wydania, kiedy są wywoływane z wirtualnych wątków. W konsekwencji, nowoczesne aplikacje wykorzystujące wykonawców VirtualThread muszą zastąpić StampedLock blokadą ReentrantLock lub strukturami danych wspólnych, aby zapobiec wyczerpywaniu zasobów wątków nośnych.