Virtual Threads w Javie 21

Trochę historii
Wiecie, że w Javie możliwe było programowanie współbieżne już w wersji 1.0? Oczywiście, przy użyciu klasy Thread. Możliwość ta pojawiła się w 1996 (obchodziłam w tym roku osiemnaste urodziny!), jednak dość szybko się okazało, że nie jest ono wcale proste… Większe i bardziej złożone aplikacje potrzebowały lepszych narzędzi do okiełznania wątków; w bibliotece standardowej Javiy brakowało konstrukcji niezbędnych do modelowania pewnych wzorców programowania współbieżnego, a sam kod był zwykle mało czytelny i bardzo podatny na błędy.
Tak więc osiem lat później - w 2004 roku - wydano wersję Java SE 5.0, w której znalazły się - przyćmione przez genericsy, enumy, varargi i adnotacje - nowe klasy w java.util.concurrent (concurrency utilities overview):
- Executor framework - który sprawił, że nie musimy już odpalać wątków ręcznie, dostajemy możliwość zakolejkowania tasków (Runnable) w schedulerze
- nowe, efektywne w programowaniu współbieżnym implementacje kolekcji Map, List i Queue (interfejsy Queue i BlockingQueue pojawiły się dopiero w tej wersji)
- narzędzia do synchronizacji wątków: muteksy, semafory, bariery, latche
- locki w java.util.concurrent.lock - “poprawiające” nieco synchronizację przy użyciu mechanizmu “synchronized {}” (dodano możliwosć zdefiniowania timeoutu przy ooczekiwaniu na lock, wielu zmiennych warunkowych per lock czy przerwania wątków czekających na złapanie locka).
Minęło kolejne dziesięć lat i w 2014 pojawiłą się Java 8. I znowu: lambdy przyćmiły nieco nowy mechanizm zarządzania współbieżnością: mechanizm fork-join, dzięki któremu zamiast jednej listy wątków oczekuących na wykonanie przez egzekutor, możliwe jest wykorzystanie wielu takich list, z których wątki mogą “podbierać” zadania (algorytm “work-stealing”). Pojawiło się też potężne rozszerzenie biblioteki standardowej o Completabe Future API (próbę stworzenia modelu programowania asymchronicznego w Javie, który jednak - wobec modelu async/await obecnego w wielu innych językach programowania - niewielu programistom przypadł do gustu.
A teraz mija kolejne 10 lat - i na scenę wkracza projekt Loom, który przynosi kolejny upgrade: wątki wirtualne (virtual threads), współbieżność strukturalną - a może raczej: ustrukturyzowaną? (structured concurrency) - oraz wartości kontekstowe (scoped values)
Przykłady i demo programów z wątkami wirualnymi: demo repo
Co to jest wątek?
Wątek w javie był do tej pory jedynie cienkim wraperem na wątek systemu operacyjnego (OS thread lub kernel thread). Taki wątek (zwany pthread od platform thread) jest dość drogim zasobem systemowym:
- wystartowanie wątku to czas ok 1 milisekundy
- potrzebuje alokacji pamięci (ok 2MB) na trzymanie stosu
- podlega schedulerowi w systemie operacyjnym, a przełączenie między wątkami jest ciężką operacją (patrz context switch).
Słabość modelu thread per request
Z tego też powodu “commodity hardware” jest dość mocno ograniczony jeśli chodzi o tworzenie wątków platformowych. Okazuje się, że w przypadku mocno obciążonych serwerów aplikacyjnych, które działają w modelu “odpalamy jeden wątek na obsługę jednego żądania” po osiągnieciu kilkuset tysięcy requestów po prostu brakuje pamięci na odpalenie kolejnego wątku i serwer umiera…Jeśli zaś mądrze wykorzystujemy pule wątków (na przykład ograniczając liczbę wątków) to i tak możemy mieć problem z wydajnością (np. przepustowością systemu), bo mimo wykorzystania wszystkich wątków w puli brakuje mocy przerobowych i kolejne połączenia/requesty nie mogą być obsłużone.
W świecie JVM powstało kilka prób radzenia sobie z tym problemem, na przykład: model programowania przy użyciu aktorów (Akka w Scali i Javie), Korutyny w Kotlinie, Vertx, a w samej Javie był jeszcze …
Niedoskonały CompletableFuture
Model programowana asynchronicznego w Javie (supplyAsync…, andThenApply… andThenApply…) pozostawia wciąż wiele do życzenia:
- jest bardzo mało czytelny
- trudno jest wnioskować na temat przebiegu sterowania
- nie wiadomo, w jakim wątku wykonuą się Runnable (debugowanie i logowanie jest bardzo trudne)
- trudno testować jednostkowo
Wątki wirtualne i wątki platformowe
Wątki platformowe
- mapują się 1 do 1 na wątki kernela, które są kolejkowane przez scheduler systemu operacyjnego
- posiadają duży stos i wymagają zasobów zarządzanych przez system operacyjny
- nadają się do wielu zadań, ale są drogim zasobem
- mają domyślnie nadane nazwy
- mogą być demonowe lub nie-demonowe; wątek w którym działa metoda main() to główny wątek nie-demonowy; JVM rozpoczyna sekwencję zamykania gdy wszystkie nie-demonowe wątki się zakończyły
Wątki wirtualne
- wątki wirtualne to wątki w trybie użytkownika (user-mode threads) kolejkowane przez JVM, a nie przez schedulera systemu operacyjnego
- mapują się w proporcji v:p (v > p) na wątki systemu operacyjnego, gdzie wiele v-wątków mapuje się na małą liczbę p-wątków
- p-wątki to tzw. carrier threads (“przewoźnicy” albo “kurierzy”) których nie tworzymy już bezpośrednio. To wątki w specjalnej puli fork-join zarządzanej przez JVM. Jeden wątek platformowy może wykonywać wiele różnych wątków wirtualnych na raz
- gdy v-wątek działający na pewnym p-wątku rozpoczyna wykonywanie blokującej operacji I/O, jego “struktura” (dane) może zostać przeniesiona na heap, a gdy kernel obsłuży operację I/O, JVM przywróci v-wątek z heapa z powrotem na - być może już inny - p-wątek.
- currentThread() zwróci informację o v-wątku (gdy odpalamy v-wątek, nie mamy dostępu do informacji o p-wątku)
- nie mają domyślnej nazwy; jeśli nie ustawimy jej, getName() zwróci pusty napis
- mają stały i niezmienialny priorytet
- są demonowe i nie blokują zamknięcia JVM-a
Tworzenie i uruchamianie wątków
Programista pisze kod “po staremu” - tworzy runnable i uruchamia je przy pomocy wątków , aby odpalić w nich długo trwające operacje. Robi to po to, aby nie blokować głównego wątku aplikacji. Może to zrobić korzystając z metod-fabryk wątków.
W Javie 21 dodano dwie metody, z których pierwsza tworzy bezpośrednio wątki platformowe, a druga - wątki wirtualne:
- Thead.ofPlatform() tworzy buildera/fabrykę do tworzenia wątków platformowych
|
|
- Thread.ofVirtual() - tworzy buildera do tworzenia wątków wirtualnych lub do utworzenia fabryki wątków wirtalnych
|
|
Prosty testowy programik
Na podstawie: JosePaumard
Poniższy program MaxThreads.java uruchamia podaną z wiersza poleceń liczbę wątków wirtualnych bądź platformowych, każe każdemu z nich spać przez dwie sekundy, czeka, aż wątki się zakończą i wypisuje czas od ich uruchomienia do zakończenia.
W taki sposób uruchomię dziesięć wątków platformowych:
java --source 21 --enable-preview MaxThreads.java plat 10
A tak uruchomię 15 wątków wirtualnych:
java --source 21 --enable-preview MaxThreads.java virt 15
|
|
Wątki platformowe - uruchomienie
Spróbuję uruchomić ten program w pętli, za każdym razem dziesięciokrotnie zwiększając liczbę wątków. Zaczynam od wątków platformowych, iteruję od dziesięciu do miliona.
JVM na mojej maszynie nie udźwignął diesięciu tysięcy wątków:
|
|
Wątki wirtualne - uruchomienie
A jak sobie radzą wirtualne? Dają radę :)
|
|
W środku
Dotychczas wątek mógł być przerwany (InterruptedException
), mógł się zakończyć normalnie lub rzucić wyjątkiem. Teraz wątek moze się “zaparkować”, “zawiesić” - jak zwał, tak zwał - i po pewnym czasie, zwykle po wykonaiu przez kernel operacji I/O - zostanie on przywrócony do wykonania już na innym wątku platformowym. Jak to się dzieje?
Jeśli spojrzycie w kod klasy Thread, w sczególności na implementację metody sleep
, zobaczycie klasę Continuation, która jest wewnętrznym “wraperem” wokół wątku platformowego. Jej metoda yialdContinuation
jest odpowiedzialna za “oddanie sterowania” do wątku platformowego. Jeśli włączymy dostęp do wewnętrznego modułu (patrz wątek na liście openjdk): java --add-opens java.base/jdk.internal.vm=ALL-UNNAMED <your-main-class>
wówczas możemy użyć ContinuationScope do tego, aby - będąc w tasku wewnątrz “Continuation” - zawiesić bieżący task, a reszta kodu nie będzie wykonana. Jeśli uruchimimy Continuation ponownie, zostanie ona wykonana do końca. Nie jest to publiczne API (i nie wiadomo, czy zostanie upublicznione), ale ciekawie jest zobaczyć bebechy.
Na przykład ten kod pokazuje, w jaki sposób można sterować wykonaniem wątków wirtualnych przy pomocy Continuation: G3_Continuation_Yield.
Structured concurency
Jeśli mamy już wątki wirtualne, możemy spróbować programowania asynchronicznego przy użyciu strukturalnej współbieżności - będziemy pisać kod imperatywny bez sięgania po CompletableFuture. StructuredTaskScope pozwala na utworzenie lekkigo “zakresu” (scope) wewnątrz metody, pozwalającego na
- utworzenie wielu v-wątków (
fork()
) - zdefiniowanie, kiedy scope ma zostać zamknięty (on success, on failure)
- uwidocznienie zależności między wątkami w thread dump-ach
- można skasować (cancel) wątek nadrzędny, a wszystkie podrzędne wątki również się skasują
- nie trzeba jawnie deklarować ExecutorSerwisu (globalnego z punktu widzenia lokalnej metody), którego nie wiadomo kiedy zamknąć
Na przykład:
|
|
Scoped value
Dotychczasowy ThreadLocal został - wewnątrz StructuredTaskScope - zatąpiony mechanizmem ScopedValue.
Podobnie jak ThreadLocal służy do “dzielenia się” wartoscią z wątkami-dziećmi bez konieczności przekazywania jej w parametrze. Jednak działanie ScopedValue jest takie, że jej wartość jes “związana” i widoczna jedynie w czasie trwania dynamcznego zakresu (“scope-u”) i nie jest dostępna poza nim. Może zostać ona związana z inną wartością (i wątki-dzieci będą widziały nową wartość).
Przykład użycia
|
|
O Scoped Values przeczytasz więcej we wpisie Jeśli nie ThreadLocal, to ScopedValue.
Przygotowania w ekosystemie
Co ciekawe, nawet Spring Boot przygotował się na to, aby uruchamiać na swoim wewnętrzym Tomkacie obsługę żądań na wątkach wirtualnych. Wystarczy zaimplementować i wystawić jako @Bean klasę TomcatProtocolHandlerCustomizer która definiuje callable służące do ustawienia odpowiedniego executora użuwanego w synchroniczncych żądaniach:
retur handler -> handler.setExecutor(Executors.newVirtualThreadPoolPerTaskExecutor())
. Po szczegóły i demo odsyłam do świetnej prezentacji JosePaumard: Virtual Threads and Structured Concurrency in Java 21 With Loom, z której zaczerpnęłam inspirację (i kod) do napisania tego wpisu.
Zbliża się wieczór, czas zająć się dziećmi i domem.
Happy coding!