Wybór języka

Nie patrz na wątki wirtualne, bo wtedy...!

Pozwólcie mi rozpocząć ten wpis od suchara.

Uproszczony model atomu

Jedzie sobie elektron na motorze. Nagle zatrzymuje go policjant i mówi:

“Musiałem Pana zatrzymać, bo przekroczył Pan znacznie prędkość, jadąc dokładnie 178 i pół kilometra na godzinę.”

“No WIELKIE DZIĘKI, panie policjancie. To teraz kompletnie nie wiem, gdzie jestem!”

Niektóre suchary są niesamowicie słabe, więc nie martw się, jeśli ten Cię nie rozbawił. Ten sprowadza się to do zasady nieoznaczoności. W niezwykle uproszczonym ujęciu: niektóre rzeczy we wszechświecie nie mogą być obserwowane we wszystkich swoich aspektach. Jeśli masz jabłko, możesz zmierzyć jego masę, objętość, rozmiar (i jeśli spadnie Ci na głowę) prędkość, wszystko jednocześnie. Bez wpływu na samo jabłko. Jednak jeśli interesują Cię elektrony, im dokładniej znasz ich prędkość, tym mniej wiesz o ich położeniu, i odwrotnie, stąd chmura elektronowa. (Do licha, czy rzeczy atomowe przestaną mnie ścigać? 😅)

Krok wstecz

Zanim przejdziemy do wątków wirtualnych (bo to właśnie cię tu przyciągnęło), cofnijmy się o krok i omówmy to, co już wiemy na temat Javy.

Być może już wiesz, że Stream.parallel() jest odradzane w wielu sytuacjach, a CompletableFuture to zazwyczaj rozwiązanie preferowane, jeśli potrzebujesz wycisnąć sok z wszystkich dostępnych rdzeni CPU. Dzieje się tak dlatego, że nie ma sposobu, aby dostarczyć własną pulę wątków do metody parallel(), co oznacza, że jesteś ograniczony do common pool (puli wspólnej).

Przykład takiego zachowania może wyglądać następująco. Po pierwsze, mamy metodę, która obsługuje zadanie (a samo zadanie jest obliczeniowo intensywne):

public static void handleTask(int id) {
    startedTasks.incrementAndGet();
    hardWork(5_000);
    logger.info(() -> "FINISHED %3d %s".formatted(id, Thread.currentThread()));
    finishedTasks.incrementAndGet();
}

Rzeczywista praca obliczeniowo intensywna odbywa się tylko w funkcji hardWork, oczywiście. I, jak można się domyślić, zajmie to mniej więcej pięć sekund. Reszta istnieje po to, aby umożliwić nam łatwe przeprowadzenie eksperymentu (za pomocą czystej Javy i bez dodatkowych narzędzi).

I jest zadeklarowana w ten sposób:

static {
    System.setProperty("java.util.logging.SimpleFormatter.format", "[%1$tF %1$tT %1$tZ] [%4$-7s] %5$s %n");
}

static AtomicInteger startedTasks = new AtomicInteger(0);
static AtomicInteger finishedTasks = new AtomicInteger(0);

Zakładając, że mamy:

  • sześć sekund na ukończenie jak największej liczby zadań
  • OpenJDK Runtime Environment (build 21-ea+34-2500)
  • 16 rdzeni CPU

I używamy Stream.parallel() (z pewnych przyczyn). Pierwsze podejście wygląda tak:

Thread.ofPlatform().daemon(true).name("stream-1").start(() -> {
    System.out.println("Started " + Thread.currentThread() + " to do some work");
    IntStream.range(0, 100).parallel().forEach(UncertaintyPrincipleOfVirtualThreads::handleTask);
});
Thread.sleep(Duration.ofSeconds(6));
logger.info("Tasks: started [%d], finished [%d]".formatted(startedTasks.get(), finishedTasks.get()));

Jakich wyników się spodziewasz? Dodaję trochę odstępu tutaj, abyśmy mogli się zastanowić, zanim przewiniemy w dół.



















Wynik zarejestrowany na końcu wygląda tak:

Started Thread[#30,stream-1,5,main] to do some work
[tasks finish here]
Tasks: started [32], finished [16]

I ma to sens, prawda? Jeśli każde zadanie zajmuje około 5 sekund, możemy używać naszych rdzeni przez maksymalnie 6 sekund, a pula wspólna osiąga swój maksymalny rozmiar w tym scenariuszu, jesteśmy w stanie zakończyć 16 zadań i rozpocząć (bez ukończenia) kolejne 16. (Jeśli uruchomisz to na swoim komputerze, wyniki mogą się różnić. Nie wiem, ile masz rdzeni CPU).

Cóż, jeśli jednej krowie zajmuje około 280 dni, aby urodzić jedno cielę, to na pewno dwie krowy urodzą dwie cielęta w około 280 dni, prawda? ;-)
Zobaczmy, co ten kod może wygenerować:

Thread.ofPlatform().daemon(true).name("stream-1").start(() -> {
    System.out.println("Started " + Thread.currentThread() + " to do some work");
    IntStream.range(0, 100).parallel().forEach(UncertaintyPrincipleOfVirtualThreads::handleTask);
});

Thread.ofPlatform().daemon(true).name("stream-2").start(() -> {
    System.out.println("Started " + Thread.currentThread() + " to do some OTHER work");
    IntStream.range(100, 200).parallel().forEach(UncertaintyPrincipleOfVirtualThreads::handleTask);
});

I oczom naszym ukazuje się:

Started Thread[#30,stream-1,5,main] to do some work
Started Thread[#31,stream-2,5,main] to do some OTHER work
[tasks finish here]
Tasks: started [34], finished [17]

Wait, WAT? Podwoiliśmy liczbę strumieni równoległych i zamiast podwojenia wyników, czyli 2 × 16 = 32, dostajemy jedno, tylko JEDNO zakończone zadanie więcej?

Czas zobaczyć, jak faktycznie zakończyły się zadania w obu przypadkach. Dla pojedynczego strumienia może to wyglądać tak:

FINISHED  65 Thread[#30,stream-1,5,main] 
FINISHED  48 Thread[#37,ForkJoinPool.commonPool-worker-7,5,main] 
FINISHED  32 Thread[#31,ForkJoinPool.commonPool-worker-1,5,main] 
FINISHED  42 Thread[#40,ForkJoinPool.commonPool-worker-10,5,main]
FINISHED  40 Thread[#35,ForkJoinPool.commonPool-worker-5,5,main] 
FINISHED  90 Thread[#34,ForkJoinPool.commonPool-worker-4,5,main] 
FINISHED  28 Thread[#38,ForkJoinPool.commonPool-worker-8,5,main] 
FINISHED  38 Thread[#36,ForkJoinPool.commonPool-worker-6,5,main] 
FINISHED  15 Thread[#32,ForkJoinPool.commonPool-worker-2,5,main] 
FINISHED  22 Thread[#41,ForkJoinPool.commonPool-worker-11,5,main]
FINISHED  43 Thread[#42,ForkJoinPool.commonPool-worker-12,5,main]
FINISHED   7 Thread[#39,ForkJoinPool.commonPool-worker-9,5,main] 
FINISHED  47 Thread[#44,ForkJoinPool.commonPool-worker-14,5,main]
FINISHED  57 Thread[#45,ForkJoinPool.commonPool-worker-15,5,main]
FINISHED  44 Thread[#33,ForkJoinPool.commonPool-worker-3,5,main] 
FINISHED  82 Thread[#43,ForkJoinPool.commonPool-worker-13,5,main]

A gdy używamy dwóch strumieni równoległych, wynik jest następujący:

FINISHED  97 Thread[#43,ForkJoinPool.commonPool-worker-12,5,main]
FINISHED  65 Thread[#30,stream-1,5,main] 
FINISHED 115 Thread[#35,ForkJoinPool.commonPool-worker-4,5,main] 
FINISHED  82 Thread[#37,ForkJoinPool.commonPool-worker-6,5,main] 
FINISHED 190 Thread[#39,ForkJoinPool.commonPool-worker-8,5,main] 
FINISHED  78 Thread[#44,ForkJoinPool.commonPool-worker-13,5,main]
FINISHED 144 Thread[#42,ForkJoinPool.commonPool-worker-11,5,main]
FINISHED 107 Thread[#36,ForkJoinPool.commonPool-worker-5,5,main] 
FINISHED  15 Thread[#38,ForkJoinPool.commonPool-worker-7,5,main] 
FINISHED 132 Thread[#33,ForkJoinPool.commonPool-worker-2,5,main] 
FINISHED  32 Thread[#32,ForkJoinPool.commonPool-worker-1,5,main] 
FINISHED  85 Thread[#46,ForkJoinPool.commonPool-worker-15,5,main]
FINISHED  90 Thread[#34,ForkJoinPool.commonPool-worker-3,5,main] 
FINISHED  57 Thread[#45,ForkJoinPool.commonPool-worker-14,5,main]
FINISHED 122 Thread[#40,ForkJoinPool.commonPool-worker-9,5,main] 
FINISHED 165 Thread[#31,stream-2,5,main] 
FINISHED  44 Thread[#41,ForkJoinPool.commonPool-worker-10,5,main]

Mimo że uruchomiliśmy dwa równoległe strumienie, wciąż korzystamy z tej samej wspólnej puli wątków roboczych. Jeden dodatkowy wynik pochodzi z wątku stream-2. Możemy zauważyć (ponieważ niektóre zakończone zadania mają wartość >= 100), że drugi strumień faktycznie “kradnie” wątki z puli przed tym, jak pierwszy strumień zdąży je zaprząc do pracy.

Jeśli pójdziemy odrobinę na dziko i oddzielimy oba strumienie za pomocą Thread.sleep(10) (coś, czego nigdy nie robimy w kodzie produkcyjnym, prawda? PRAWDA?!?), najprawdopodobniej zobaczymy tylko jedno zakończone zadanie o wartości >= 100. Całkowita liczba zadań nie zmieni się, ponieważ pierwszy strumień zajmuje wszystkie wątki z puli wspólnej, zanim drugi zdąży obsłużyć tylko jedno przy użyciu wątku stream-2.

Tak więc strumienie równoległe nie mogą rosnąć liniowo, bez względu na to, ile ich użyjesz.

Chwileczkę, ale w Javie 21 pojawiła się nowa funkcjonalność, nawet twój szef o niej słyszał i mówi:

wątki wirtualne to the rescue, po prostu ich użyj!

Niestety, nie miałeś okazji uczestniczyć w tej prezentacji tego gościa od fartucha, więc wszyscy nadal liczą na to, że wątki wirtualne wycisną więcej soku z twojego CPU!

A zespół dodaje pewne feature flags i chowa za nimi nową implementację, która wygląda mniej więcej tak:

for (int i = 0; i < 100; i++) {
    int taskId = i;
    Thread.ofVirtual().start(() -> {
        handleTask(taskId);
    });
}

I teraz, ponieważ wątki wirtualne są tak lekkie, liczymy na to, że uzyskamy więcej wyników niż 16! Ile wyników otrzymamy tym razem?



















No nie. No po prostu ….. nie. Niemożliwe. Przepaliliśmy dwa sprinty na przepisanie tego dziadostwa tylko po to, żeby uzyskać:

Tasks: started [32], finished [16]

Co tu się stanęło się? Dobra, spędziliśmy dwa tygodnie na kodowaniu, ponieważ o wiele łatwiej było kodować niż czytać instrukcję przez godzinę. A instrukcja mówi, że pod maską wątki wirtualne korzystają z puli ForkJoin! I mówi również, że domyślnie jest ona równa liczbie dostępnych procesorów!

Nie wszystko jest stracone, można to również dostosować, ale przed dostosowaniem postanawiamy przyjrzeć się bliżej rzeczywistej implementacji obsługi zadania, aby zmierzyć postęp, i może zaoszczędzić trochę czasu. Przecież jak zrobimy, że będzie działało szybciej niż 5 sekund, spowoduje, że wszystko będzie działało szybciej, bez względu na to, czy używamy wątków wirtualnych, czy nie. Więc wracamy do dobrych starych log("ONE") i log("TWO"), aby śledzić postęp. Dzielimy zadanie na mniejsze kroki i dodajemy nieco instrukcji trace fine. Teraz nasze zadania są obsługiwane w ten sposób:

public static void handleTask(int id) {
    startedTasks.incrementAndGet();
    report(id, "1");
    hardWork(1_000);
    report(id, "2");
    hardWork(1_000);
    report(id, "3");
    hardWork(1_000);
    report(id, "4");
    hardWork(1_000);
    report(id, "5");
    hardWork(1_000);
    logger.info(() -> "FINISHED %3d %s".formatted(id, Thread.currentThread()));
    finishedTasks.incrementAndGet();
}

private static void report(int taskId, String stage) {
    logger.fine(() -> "STEP %s %3d %s".formatted(stage, taskId, Thread.currentThread()));
}

Rzecz jasna nie możemy zapomnieć o zwiększeniu dokładności logowania, więc w bloku statycznym dajemy:

logger.setLevel(java.util.logging.Level.FINE);

Ekscytacja rośnie, walimy w przycisk “uruchom”, i…

FINISHED  26 VirtualThread[#58]/runnable@ForkJoinPool-1-worker-8
FINISHED  21 VirtualThread[#52]/runnable@ForkJoinPool-1-worker-4
FINISHED  30 VirtualThread[#62]/runnable@ForkJoinPool-1-worker-2
Tasks: started [100], finished [3] 

Zaraz, co? WT actual F? GDZIE JEST MOJA WYDAJNOŚĆ??!!!1jeden

Tylko po to, aby upewnić się, że nie przypadkiem zepsuliśmy niczego, przełączamy feature flag z powrotem i sprawdzamy wyniki z dwoma równoległymi strumieniami. I jest tak, jak było wcześniej: Tasks: started [34], finished [17].

Cóż, wydaje się, że wątki wirtualne zachowały się dokładnie jak elektron. Chcieliśmy się im dokładniej przyjrzeć, a zamiast tego… zniknęły? Co za nonsens? Czyżby

Wprowadzono Mechanikę Kwantową do Javy?

Na początek, dlaczego w ogóle nie widzimy instrukcji FINE? Cóż, to nie jest tak trudne do ogarnięcia ;-)

Możliwość zobaczenie logów ze STEP jest pomocne, chociaż nie jest kluczowa. Po pierwszym STEP 1 zaczynamy zauważać coś w rodzaju:

STEP 1 27 VirtualThread[#61]/runnable@ForkJoinPool-1-worker-9 
STEP 2 10 VirtualThread[#43]/runnable@ForkJoinPool-1-worker-6 
STEP 2 15 VirtualThread[#48]/runnable@ForkJoinPool-1-worker-11 
STEP 1 30 VirtualThread[#64]/runnable@ForkJoinPool-1-worker-11 
STEP 2 16 VirtualThread[#49]/runnable@ForkJoinPool-1-worker-9 
STEP 2 19 VirtualThread[#53]/runnable@ForkJoinPool-1-worker-3 
STEP 1 28 VirtualThread[#62]/runnable@ForkJoinPool-1-worker-6 

A jeśli będziemy przewijać dalej, naszym oczom ukaże się:

STEP 1 47 VirtualThread[#81]/runnable@ForkJoinPool-1-worker-11
STEP 4 20 VirtualThread[#54]/runnable@ForkJoinPool-1-worker-15
STEP 2 21 VirtualThread[#55]/runnable@ForkJoinPool-1-worker-9 
STEP 5  8 VirtualThread[#41]/runnable@ForkJoinPool-1-worker-12 
STEP 5  5 VirtualThread[#38]/runnable@ForkJoinPool-1-worker-14 
STEP 1 29 VirtualThread[#63]/runnable@ForkJoinPool-1-worker-14

To może wydawać się dziwne… w podejściu ze strumieniami wszystko jest “zgodne z oczekiwaniami”, a kroki są przetwarzane w pewnym sensie partiami… Najpierw widzimy 17 zadań w STEP 1, potem wszystkie przechodzą do STEP 2, i tak dalej, a po zakończeniu w FINISH, pracowniki wybierają następne zadania w STEP 1. Oznacza to, że dopóki zadanie jest wykonywane, pracowniki z puli ForkJoin wykonują swoje zadania od początku do ukończenia.

Jednak w przypadku wątków wirtualnych sytuacja jest wyraźnie inna… W “najsampierwszym” wyniku zobaczyliśmy to:

1STEP 1 27 VirtualThread[#61]/runnable@ForkJoinPool-1-worker-9 
2STEP 2 10 VirtualThread[#43]/runnable@ForkJoinPool-1-worker-6 
3STEP 2 15 VirtualThread[#48]/runnable@ForkJoinPool-1-worker-11 
4STEP 1 30 VirtualThread[#64]/runnable@ForkJoinPool-1-worker-11 
5STEP 2 16 VirtualThread[#49]/runnable@ForkJoinPool-1-worker-9 
6STEP 2 19 VirtualThread[#53]/runnable@ForkJoinPool-1-worker-3 
7STEP 1 28 VirtualThread[#62]/runnable@ForkJoinPool-1-worker-6 

Zadanie #27 zostało przeniesione do STEP 1 przez Virtual Thread[#61], używając worker-9. Następnie, zamiast kontynuować to konkretne zadanie #27, worker-9 został przypisany do Virtual Thread[#49], obsługując STEP 2 zadania #16. Według wyników to samo stało się z worker-6 i worker-11. I przypuszczam, że można śmiało powiedzieć, że statystycznie to zdarzyło się z każdym zadaniem i wątkiem wirtualnym. Trzy zadania, które zostały zakończone, były szczęśliwymi zwycięzcami, które zostały przetworzone przez pracowniki przez wszystkie pięć kroków, niekoniecznie tymi samymi pracownikami od STEP 1 do STEP 5.

To, co tu widzimy w akcji, to

Prawdziwy Cel Wątków Wirtualnych

Wątki wirtualne, gdy napotykają operację wejścia-wyjścia, odłączają się od wątku nośnego (“prawdziwego”), który je obsługuje. Po angielsku nazywa się ta operacja “unmounting”, co można też przetłumaczyć jako “zsiadanie” (z konia) lub “wysiadanie” (z czołgu). W ten sposób wątek nośny może wziąć inny wątek wirtualny na tapet i zajmować się nim, dopóki ten inny wątek wirtualny nie wywoła operacji wejścia-wyjścia.

To może przypominać w pewnym sensie taksówki lub przejazdy współdzielone. Gdy już wsiądziecie do samochodu (wcześniej jakoś go zarezerwowaliście), kierowca Was nie wyrzuci, dopóki nie dojedziecie do celu. Kierowcy nie obchodzi, czy to Wasza kolejna podróż w tym dniu, czy pierwsza.
Kierowca Ubera nie będzie Was preferować tylko dlatego, że wcześniej tej nocy ktoś z Ubera zawiózł Was do restauracji, a teraz chcecie zmienić miejscówkę. Statystycznie wszyscy są tu równi. Zatem rozwiązaniem jest nie pozwolić taksówce odjechać, tylko trzeba zatrzymać kierowcę (“proszę tu poczekać”) i zapłacić. Albo po prostu nie wychodzić z samochodu ;-)

W naszym przykładzie oznacza to: nie wywołujcie niepotrzebnych operacji wejścia-wyjścia / logowania, ponieważ to dzieli Waszą podróż na mniejsze etapy. Całkowity przebieg dla wszystkich osób / zadań będzie mniej więcej taki sam na koniec dnia, ale jeśli wolisz, aby niektóre osoby / zadania ukończyły całą podróż, musisz to wziąć pod uwagę.

Trzeba po prostu pamiętać, do czego są przeznaczone wątki wirtualne i w czym są dobre. Jeśli masz pewne operacje wejścia-wyjścia (nie tylko logowanie, ale także, a może nawet przede wszystkim, zapytania do bazy danych, operacje na plikach, wywołania sieciowe), pozwalają one wykorzystać moc obliczeniową CPU do innych zadań, zamiast niepotrzebnie czekać. I to bez nauki nowej składni, nowego API itd.
Podobnie jak w przypadku taksówek: jeśli idziesz do restauracji na kolację z przyjaciółmi, rzadko kiedy mówisz kierowcy “Proszę tutaj zaparkować i nie wyłączać silnika, zapłacę za to”. Zamiast tego zwalniasz taksówkę i zamawiasz kolejną, gdy faktycznie musisz wrócić do domu, a w międzyczasie inni ludzie mogą z tej taksówki skorzystać, a jest ona kosztownym zasobem.

Jednak powodzenia w zamówieniu taksówki po ogromnym koncercie ;-)

To prawda, że wątki wirtualne to wciąż wątki i możesz to łatwo sprawdzić na własną rękę:

Thread.ofVirtual().start(() -> {
    if(Thread.currentThread() instanceof Thread) {
        System.out.println("Virtual Thread is a Thread");
    }
});

Dlatego też wątki wirtualne możemy używać wszędzie tam, gdzie do tej pory używaliśmy “dobrych starych” wątków. Tylko miej, proszę, na uwadze, że wywołania operacji wejścia-wyjścia będą dzielić Wasze podróże na mniejsze odcinki. I że istnieje ta pula “która może być dostosowana”, ale (w chwili pisania) nie jest dozwolone używanie własnej puli dla wątków wirtualnych. W tym celu musimy trzymać się Completable Futures.

Hej, ale mogę zmusić mój wątek wirtualny, żeby nie wychodził z taksówki!

Oczywiście, bo moglibyśmy całe zadanie zrobić jako synchronized. I być może w naszym przypadku to może działać bez wielkich strat w wydajności (ponieważ po prostu logujemy na konsolę lokalną).

Jednak mechanizm ten może się zmienić w przyszłych wersjach Javy. A jeśli operacja report zajmie dłużej (powiedzmy, jeśli występuje zdalne logowanie, które jest blokujące), trzymanie wątku nośnego jest bez sensu. Zazwyczaj jest to Naprawdę Zły Pomysł™, które opisałem tutaj i tutaj.

Sugerowałbym monitorowanie postępu zadań bez polegania na operacjach wejścia-wyjścia wywoływanych wewnątrz wątków wirtualnych. I, jak pokazano, log("HERE") liczy się jako taka operacja.

Różne pule

Jest jeszcze jedna rzecz, aby zakończyć ten wpis. W przypadku, gdy ktoś nie zwrócił uwagi ani na instrukcję, ani na wynik…

To widać, gdy korzystamy ze strumieni:

FINISHED  48 Thread[#37,ForkJoinPool.commonPool-worker-7,5,main]

a to pokażą nam wątki wirtualne (obecnie):

FINISHED  26 VirtualThread[#58]/runnable@ForkJoinPool-1-worker-8

Tak, to są dwie różne pule Fork Join. Dlatego możemy pozbyć się feature flags i uruchomić zadania zarówno w strumieniach, jak i wątkach wirtualnych (bez logowania na poziomie FINE).

Wówczas możemy zobaczyć którykolwiek z tych wyników:

Tasks: started [60], finished [18]
Tasks: started [60], finished [19]
Tasks: started [50], finished [7]
Tasks: started [66], finished [30]
Dlaczegóż? Cóż, to temat na oddzielny wpis. TL;DR: są powody, dla których stosuje się podejście “jeden wątek na rdzeń”


Rany, wyszło dłużej, niż zamierzałem ;-) Jeśli masz ochotę do dyskusji, zapraszam do mediów społecznościowych. Sznurki w stopce poniżej.

Wybór języka