Wirtualne wątki w Javie
Jakiś czas temu Ron Pressler opublikował artykuł zatytułowany „State of Loom”, który był lansowany przez wszystkich znaczących ludzi ze społeczności JVM — i to z samych dobrych powodów. Chociaż sama lektura była bardzo interesująca (osobiście bardzo podobała mi się metafora „taksówki”), to jednak byłem sceptycznie nastawiony do przedstawionego w nim rozwiązania. Ron rzucił mi więc wyzwanie — mam wypróbować je samemu.
Oto, czego będziemy potrzebować:
- Java15 Early-Access Build, który zawiera Project Loom
- Naszego ulubionego narzędzia wiersza poleceń, ponieważ IntelliJ nic obecnie nie wie na temat Java15
Na początek postanowiłem zaimplementować podstawowe wyzwanie, które pojawia się w dokumentacji o współprogramach Kotlina: rozpoczęcie miliona wątków, które zwiększą licznik atomowy. Chociaż oryginalny kod został napisany w Kotlinie, to łatwo go przepisać przy użyciu składni Javy:
public class Main {
public static void main(String[] args) {
var c = new AtomicLong();
for (var i = 0; i < 1_000_000; i++) {
new Thread(() -> {
c.incrementAndGet();
}).start();
}
System.out.println(c.get());
}
}
Skompiluj i uruchom:
javac Main.java && java Main
... Never completes
Widać, że przy użyciu zwykłych wątków, to nadal nie działa. Przepiszmy to teraz, używając „wirtualnych wątków” zapewnionych nam przez Project Loom:
for (var i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
c.incrementAndGet();
});
}
Wynik jest poprawny:
1000000
Ale wiem, że stać nas na więcej.
Ron w swoim artykule słusznie wskazuje na to, że Kotlin musiał wprowadzić funkcję delay()
, która zawiesiłaby coroutine. Dzieje się tak, ponieważ użycie Thread.sleep()
uśpiłoby również jeden z wątków, który planuje współprogramy, zmniejszając tym samym współbieżność. Jak wirtualne wątki sobie z tym radzą?
for (var i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
c.incrementAndGet();
try {
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
Wynik:
Something around 400K on my machine
To właśnie wtedy rzeczy stają się interesujące — Thread.sleep()
jest świadome bycia wywoływanym w wirtualnym wątku. Zawiesza ono więc kontynuację, ale nie wykonanie. To już samo w sobie jest niesamowite. Mimo wszystko przyjrzymy się dokładniej następującemu ćwiczeniu.
var threads = new ArrayList<Thread>();
var cores = 10;
for (var i = 0; i < cores; i++) {
var t = Thread.startVirtualThread(() -> {
var bestUUID = "";
for (var j = 0; j < 1_000_000; j++) {
var currentUUID = UUID.randomUUID().toString();
if (currentUUID.compareTo(bestUUID) > 0) {
bestUUID = currentUUID;
}
}
System.out.println("Best slow UUID is " + bestUUID);
});
threads.add(t);
}
for (var i = 0; i < cores; i++) {
var t = Thread.startVirtualThread(() -> {
var bestUUID = UUID.randomUUID().toString();
System.out.println("Best fast UUID is " + bestUUID);
});
threads.add(t);
}
for (Thread t : threads) {
t.join();
}
Tak więc uruchamiamy 10 wolnych i 10 szybkich zadań. Intuicyjnie można by powiedzieć, że szybkie zadania zakończą się wcześniej, bo są przecież milion razy szybsze. Nie chodzi tu jednak o intuicyjność:
Best slow UUID is fffffde4-8c70-4ce6-97af-6a1779c206e1
Best slow UUID is ffffe33b-f884-4206-8e00-75bd78f6d3bd
Best slow UUID is fffffeb8-e972-4d2e-a1f8-6ff8aa640b70
Best fast UUID is e13a226a-d335-4d4d-81f5-55ddde69e554
Best fast UUID is ec99ed73-23b8-4ab7-b2ff-7942442a13a9
Best fast UUID is c0cbc46d-4a50-433c-95e7-84876a338212
Best fast UUID is c7672507-351f-4968-8cd2-2f74c754485c
Best fast UUID is d5ae642c-51ce-4b47-95db-abb6965d21c2
Best fast UUID is f2f942e3-f475-42b9-8f38-93d89f978578
Best fast UUID is 469691ee-da9c-4886-b26e-dd009c8753b8
Best fast UUID is 0ceb9554-a7e1-4e37-b477-064c1362c76e
Best fast UUID is 1924119e-1b30-4be9-8093-d5302b0eec5f
Best fast UUID is 94fe1afc-60aa-43ce-a294-f70f3011a424
Best slow UUID is fffffc24-28c5-49ac-8e30-091f1f9b2caf
Best slow UUID is fffff303-8ec1-4767-8643-44051b8276ca
Best slow UUID is ffffefcb-614f-48e0-827d-5e7d4dea1467
Best slow UUID is fffffed1-4348-456c-bc1d-b83e37d953df
Best slow UUID is fffff6d6-6250-4dfd-8d8d-10425640cc5a
Best slow UUID is ffffef57-c3c3-46f5-8ac0-6fad83f9d4d6
Best slow UUID is fffff79f-63a6-4cfa-9381-ee8959a8323d
Intuicja działa tylko w przypadku, gdy liczba cores jest mniejsza lub równa liczbie rdzeni na maszynie. Trochę się tego spodziewałem, ponieważ Project Loom do planowania wirtualnych wątków używa ForkJoinPool
, co nie jest znowu takie złe.
Pomimo że, jak stwierdzono w dokumentacji, wątki wirtualne podlegają wywłaszczaniu, to ich planowanie nie opiera się na wywłaszczaniu, podobnie jak w przypadku współprogramów. Nie widać tu przeplatania wykonania wątków znanego z modelu pracy wątków na poziomie systemu. Jednak należy zauważyć, że twórcy projektu rozważają wprowadzenie wywłaszczania, co mogło być interesującą opcją.
Jednym z oczywistych rozwiązań byłoby tu użycie Thead.yield()
, co działałoby całkiem ok. Problemem jest jednak to, że takie wywołanie nie zwraca rezultatu. Dodatkowo nie mamy do dyspozycji słowa kluczowego suspend
, znanego z Kotlina, które by pomogło w tym przypadku.
Poza tym każde wywołanie standardowych operacji IO w Javie również spowoduje przekazanie wykonania do innego wątku. Na przykład, użycie System.out.print (“ “)
spowodowało przełączenie kontekstu. W każdej rzeczywistej aplikacji IO jest to tak powszechne, że i tak nie powinno to stanowić prawdziwego problemu.
Podsumowanie
Muszę przyznać, że Project Loom jest naprawdę niesamowity. Z pewnością przyda się wielu programistom, skutecznie zapewniając wszędzie lekką współbieżność bez potrzeby korzystania z danej biblioteki lub środowiska.
Jednak największą korzyścią dla twórców bibliotek jest fakt, że gdy Java15 osiągnie wystarczająco szerokie zastosowanie, obawy o współbieżność będzie można odłożyć na bok i w końcu zająć się rozwojem nowych funkcji.
Chyba właśnie takie są nasze oczekiwania wobec całej platformy, prawda? W ostatnim czasie było sporo pracy nad odśmiecaniem, optymalizacją kodu, a teraz czas na kolejny krok, czyli współbieżność.
Oryginał tekstu w języku angielskim możesz przeczytać tutaj.