Вступ до Project Loom. Частина 1: Virtual Threads
Мене звуть Денис Макогон, я — Principal Java Developer та Java Advocate у команді Java Developer Relationships в компанії Oracle. Сьогодні я хочу поділитися першою статтею з циклу про OpenJDK Project Loom, а також другою статтею з циклу статтей, що передують релізу OpenJDK 19 цього вересня.
Вступ
До релізу JDK 19 залишилося не так вже й багато часу, лише 3 місяці. А це означає, що зараз саме час розібрати окремий функціонал, який буде доступний розробникам. Цього разу я хочу приділити увагу новому функціоналу, що є частиною великого проєкту Project Loom. Йдеться про JEP 425: Virtual Threads (Preview).
Platform threads
З давніх пір наявність фреймворку для розробки багатопотокових додатків є невід’ємною вимогою до високорівневих мов програмування. У цьому контексті Java не є винятком.
Модель реалізації потоків у Java базується на відповідності потоків JVM до системних, тобто наказуючи JVM створити потік типу:
Thread.ofPlatform().name("platform-thread").start(() -> { try { Thread.sleep(Duration.ofSeconds(5)); } catch (InterruptedException e) { throw new RuntimeException(e); } });
ви насправді інструктуєте JVM створити для вас системний потік (POSIX thread, або WinNT thread).
Так було дуже довгий час. Мережеві додатки, які реалізують модель типу thread-per-request стикалися з тим, що єдиним шляхом збільшити пропускну здатність було горизонтальне масштабування (створенням ще більшої кількості екземплярів JVM). Справа в тому, що масштабованість регулюється законом Літтла, який визначає, що для заданої тривалості обробки, кількість запитів, які програма обробляє одночасно, повинна зростати пропорційно швидкості надходження запитів на обробку. Тобто є верхня межа, досягнувши якої, вже не можливо приймати ще більше запитів на обробку.
Наприклад, припустимо, що мережевий додаток із середньою затримкою 50мс досягає пропускної здатності 200 запитів на секунду, обробляючи 10 запитів одночасно. Щоб цей додаток масштабувався до пропускної здатності 2000 запитів на секунду, їй потрібно буде обробляти 100 запитів одночасно. Якщо кожен запит обробляється в потоці протягом усього часу запиту, то щоб додаток справлявся із навантаженням, кількість потоків має збільшуватися зі зростанням пропускної здатності.
На жаль, кількість доступних системних потоків, виділених для окремого процесу, є обмеженою, фактичне значення для процессів прописано у конфігурації операційної системи. Враховуючи обмеженість кількості потоків для процесу, підхід thread-per-request із використанням системних потоків є неефективною моделлю. Якщо кожен запит займатиме весь потік протягом усього часу, то кількість потоків часто стає обмежувальним фактором задовго до того, як інші ресурси вичерпаються. Наприклад, кількість потоків, доступних JVM за замовчуванням, становить:
$ sysctl -a | grep num_task
kern.num_tasks: 4096 kern.num_taskthreads: 4096
Це означає, що JVM зможе вичерпати доступні 4096
потоків задовго до того, як в неї закінчиться пам’ять. Але ж в реальності кількість потоків, доступних для використання безпосередньо клієнтським кодом зсередини JVM менша, тобто потоки закінчаться ще швидше, бо JVM резервує за собою певну кількість потоків під Garbage Collector, ForkJoinPool::commonPool
і так далі.
Хоч це значення і немале, але реалізація потоків у JDK обмежує пропускну здатність додатків до рівня значно нижчого, ніж може підтримувати апаратне забезпечення сучасних серверів. Це відбувається навіть тоді, коли потоки об’єднані в пул (типу Executors::cachedThreadPool
), оскільки об’єднання в пул допомагає уникнути великих витрат на створення та запуск нового потоку, що робить роботу багатопотокових додатків ефективнішою, але не збільшує загальну кількість потоків.
Virtual threads
Проблема моделі thread-per-request
Багато розробників бажають використовувати апаратні можливості серверного устаткування повною мірою. Це означає відмовитися від стилю thread-per-request на користь спільного використання потоків.
Замість того, щоб обробляти запит в одному потоці від початку до кінця, підсистема розподілення потоків по запитах поверне потік до пулу у той час, коли він очікує завершення операції типу I/O, для того, щоб потік міг обслуговувати інші запити. Підхід такого типу дозволяє виконувати велику кількість одночасних операцій без споживання великої кількості потоків.
Зміна підходу
Щоб застосунки могли масштабуватися ефективно, використовуючи всі можливості серверів, при цьому зберігати підхід типу thread-per-request, є вимога реалізувати потоки у такий спосіб, щоб їх реально стало більше, незважаючи на обмежену кількість системних потоків. Так виходить, що операційні системи не можуть реалізувати потоки більш ефективно. Проте JVM може реалізовані потоки таким чином, щоб розірвати пряме співвідношення Java-потоків від системних.
Подібно до того, як операційні системи створюють ілюзію великої кількості пам’яті, створюючи великий віртуальний адресний простір з обмеженою кількістю фізичної оперативної, середовище виконання Java може створити ілюзію великої кількості потоків, створюючи велику кількість віртуальних потоків з обмеженою кількістю потоків, виділених під окремий процес JVM.
Впровадження віртуальних потоків
Отже, так з’явився новий тип Java-потоків — Virtual Thread. На відміну від класичних потоків, віртуальні вже не мають прямого співвідношення до системних потоків. А також їхня кількість більше не залежить від кількості доступних системних потоків, а лише від наявної пам’яті, якою оперує JVM.
Дуже важливим є те, що віртуальний потік є екземпляр java.lang.Thread
, тобто це звичайнісінький потік, до якого всі звикли, але він не прив’язаний до певного системного потоку. Ще важливо зазначити те, що потік у тому розумінні та реалізації, який існував до впровадження віртуальних потоків, нікуди не подівся. Навпаки, впровадження віртуальних потоків дає змогу вибирати як ви хочете реалізувати багатопоточність у ваших додатках, бо реальне призначення двох реалізацій потоків відрізняється одне від одного таким чином, що віртуальні потоки створені для I/O-задач, а от звичайні потоки більше підходять для важких задач, де загалом використовується лише CPU.
Оскільки створення віртуальних потоків є дешевшим за створення звичайного і їхня кількість обмежена лише пам’яттю, їх ніколи не слід об’єднувати у пули (кешуючі, фіксовані) бо це не дає ніякої переваги, на відміну від потоків системних, де кешуючі та фіксовані пули мають найбільну перевагу виходячи з того, що створення классичних потоків є дуже дорогим і краще всього робити це на старті JVM.
Треба запам’ятати, що кожен новий віртуальний потік має бути створений під конкретну задачу. Такий підхід призведе до того, що більшість віртуальних потоків будуть існувати відносно невеликий проміжок часу (у рамках та контексті додатку) і матимуть не глибокий стек викликів при умові виконання лише обмеженого контексту завдань, типу виконання HTTP-запиту або запиту до БД через JDBC. А от звичайні потоки, навпаки, важкі та дорогі, бо вимагають створення об’єктів за JVM. Тому часто їх треба об’єднувати у пули задля того, щоб уникнути навантаження на створення самих потоків і лише витрачати час та ресурси на призначення задачі на виконання у потоці.
Базова технологія
У якості базової технології роботи з віртуальними потоками виступає ForkJoinPool
типу work-stealing, у режимі First-In-First-Out. При старті JVM, процес ініціалізації ForkJoinPool
для віртуальних потоків дуже схожий на процес створення ForkJoinPool::commonPool
, фактор паралельності якого теж залежить від кількості наявних CPU, проте фактичне значення можна налаштувати за допомогою системної властивості jdk.virtualThreadScheduler.parallelism
.
Варто зауважити, що ForkJoinPool
для віртуальних потоків відрізняється від ForkJoinPool::commonPool
типом і режимом із потоками, а саме — LIFO.
Класичний потік, який належить до ForkJoinPool
і на який призначений віртуальний потік, називається «carrier thread». Цікаво те, що віртуальний поток взагалі не залежить від свого потоку-носія і може виконуватися на довільному системному треді, який належить до ForkJoinPool
. Це означає, що віртуальний потік взагалі нічого не знає про свого носія, бо стеки потоків розділені між собою.
Практичне застосування віртуальних потоків
Разом із впровадженням віртуальних потоків, стандартний набір Threads API був розширений відповідними методами для роботи з новим типом потоків. Наприклад, для створення віртуального потоку тепер існує ось така конструкція:
Thread.startVirtual( () -> System.out.println(Thread.currentThread()) );
Варто відзначити, що це не єдиний спосіб створити потік, бо ще є новий ExecutorService
:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); }); }
З впровадженням нового типу потоків з’явилася необхідність якось відрізняти потоки за назвами, тобто впровадити відповідну термінологію:
- platform thread — той тип потоків, який є обгорткою надсистемних потоків;
- virtual thread — новий тип потоків.
Відповідно до цих назв були введені нові типи білдерів для створення фабрик потоків:
public static Builder.OfPlatform ofPlatform();
та
public static Builder.OfVirtual ofVirtual();
Тобто щоб створити, наприклад, новий ExecutorService
певного типу, можна використовувати відповідну конструкцію:
Executors.newThreadPerTaskExecutor( Thread.ofVirtual().factory() );
або
Executors.newThreadPerTaskExecutor( Thread.ofPlatform().factory() );
Окрім стандартних методів типу Executors::newVirtualThreadPerTaskExecutor
, є можливість використовувати фабрику, відмінну від тієї, яка використовується у стандартній бібліотеці. Тобто можна зробити свою кастомну реалізацію ExecutorService
для віртуальних або платформених потоків замість:
@PreviewFeature(feature = PreviewFeature.Feature.VIRTUAL_THREADS) public static ExecutorService newVirtualThreadPerTaskExecutor() { ThreadFactory factory = Thread.ofVirtual().factory(); return newThreadPerTaskExecutor(factory); }
використати:
Executors.newThreadPerTaskExecutor( Thread.ofVirtual() .name("virtual-", 0) .allowSetThreadLocals(false) .factory() );
Таким чином можна створювати ExecutorService
, засновану на іменованій фабриці віртуальних або фізичних тредів із дозволом на встановлення Thread Locals.
Дуже цікавим є той факт, що код, який реалізує блокуючі I/O операції за замовчанням, стане ефективнішим, якщо його запустити у віртуальному треді:
import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Collections; import java.util.concurrent.*; import java.util.stream.Collectors; public class LoomClient { public static void main(String...args) throws Exception { var tasksNo = 500_000; var request = HttpRequest.newBuilder() .version(HttpClient.Version.HTTP_1_1) .GET(); var client = HttpClient.newHttpClient(); Thread.startVirtual(() -> { var currThread = Thread.currentThread(); System.out.println(currThread.threadId()); var resp = client.send( request.uri(new URI(String.format( "http://localhost:8080/get?virtualThread=%s&counter=%s", currThread.isVirtual(), currThread.getName()))) .build(), HttpResponse.BodyHandlers.ofString() ); return resp.statusCode(); }); } }
Це означає, що певні API-методи типу:
public abstract <T> CompletableFuture<HttpResponse<T>> sendAsync( HttpRequest request, BodyHandler<T> responseBodyHandler);
взагалі стануть непотрібними, бо їхня реалізація поки що базується на звичайних потоках.
Цікавий експеримент
Найголовніше те, що віртуальний потік є стовідсотковим Java-об’єктом, тобто максимальна кількість таких потоків дуже залежить від наявної пам’яті. Тобто на відміну від системних потоків, кількість віртуальних є величиною несталою, бо у різні проміжки часу кількість потоків може змінюватися. Але можна уявити, що додаток майже нічого не робить і порахувати, скільки ж віртуальних потоків можна створити. Кількість можна визначити ось таким чином:
import java.time.Duration; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class CalculateThreadMax { private static void runTest(Thread.Builder builder) { var counter = new AtomicInteger(0); try{ while (true) { builder.start(() -> { counter.incrementAndGet(); var threadID = Thread.currentThread().threadId(); if (threadID == 1_000_001L) { System.out.println("No. of threads is already beyond 1M!"); } if (threadID == 2_000_001L) { System.out.println("No. of threads is already beyond 2M and 1! That's enough!"); System.exit(0); } try { Thread.sleep(Duration.ofSeconds(threadID)); } catch (Exception e) { System.out.println("MAX threads: " + (counter.get() - 1)); System.exit(0); } }); } } catch (OutOfMemoryError ex) { System.out.println("MAX threads: " + (counter.get() - 1)); System.exit(0); } } public static void main(String...args) { runTest(Thread.ofVirtual()); // runTest(Thread.ofPlatform()); } }
На моєму старенькому MacBook Pro 2018 16Gb RAM за короткий проміжок часу я зміг створити приблизно 2.2
мільйони віртуальних потоків менше ніж за кілька десятків секунд. У порівнянні з реальним значенням доступних системних потоків що становить приблизно 4073
потоків, то 2.2 мільйони виглядає як дуже непристойно велике значення.
От тут і криється одна із технічних проблем, яка досі не вирішена. Враховуючи те, скільки можна створювати віртуальних потоків, неминуче постає питання — а як справи із Thread Locals? До Thread Locals (сховища типу key-value, власником якого є потік) не було питань, коли реальна кількість потоків була у межах 10К за замовчанням. Але коли справа йде про мільйони потоків і кожен з них має своє таке сховище, то виникає питання, наскільки таке рішення підходить для віртуальних потоків. Відповіді на це питання ще нема, проте активна робота ведеться.
Висновки
Впровадження віртуальних потоків є революційним для усієї екосистеми Java, поза жодними сумнівами. Основною задачею, яка стояла перед розробниками, була сумісність між звичайними потоками, які вже на цей час існували два десятиліття. Безпосередня сумісність полягає ще у тому, что реалізація віртуальних потоків та відповідного ExecutorService
надає змогу вже прямо зараз інтегрувати новий тип потоків до мережевих додатків, які надають змогу перевизначити механізм розподілення запитів по потоках.
Іншим важливим аспектом є маштабування віртуальних потоків, і як ви бачите, та можете впевнитися, є можливість перевершити кількість потоків у десятки тисяч разів у порівнянні до системних потоків. Але відкритим залишається питання щодо ефективності використання Thread Locals, враховуючи кількість потенційно запущених потоків одночасно, або у короткий проміжок часу.
Усі нові впроваждення, що стосуються Project Loom, можно вже використовувати, починаючу із JDK 19+26 Early Access Build:
sdk install java 19.ea.26-open
P.S.
Пам’ятайте про те, що Virtual Threads це нове Preview API. Для того, щоб працювати з цим API, треба:
- Скомпілюйте програму за допомогою
javac --release 19 --enable-preview Main.java
та запустіть її за допомогоюjava --enable-preview Main
; або, - Використовуючи програму запуску вихідного коду, запустіть програму з
java --source 19 --enable-preview Main.java
; або, - Використовуючи
jshell
, почніть його зjshell --enable-preview
.
------
49 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів