Привет, Хабр
Многие если не все встречались с потоками, пулами потоков и проблемами многопоточности и конкурентности. В JVM языках под капотом одна и таже платформа, но Java, Kotlin, Scala и Clojure по-разному работают с потоками, задачами и ожиданием I/O.

В Java кроме классических потоков появились virtual threads. В Kotlin давно есть корутины. В Scala есть ZIO со своим runtime и fiber’ами. В Clojure есть futures, promises, agents и core.async. Снаружи это всё может выглядеть как разные способы выполнить работу параллельно, но внутри модели сильно отличаются.
Мы сравним эти подходы на простых для backend-разработки вещах: как запускается задача, где используются реальные потоки, а где логические, что происходит при ожидании I/O и кто отвечает за отмену. Заодно будет видно, почему в Java можно спокойно писать блокирующий код, а в Kotlin, Scala и Clojure чаще появляются async, await, flatMap.
В первой части начну с Java-базы: обычный Thread, thread pools, virtual threads из Project Loom и Structured Concurrency. В следующих будут корутины, zio-runtime с fibers и Clojure.
1. Классический Java Thread
Начнём с базы. В Java Thread исторически был довольно прямым отражением потока операционной системы. Не всегда один-в-один в деталях реализации JVM и ОС, но для прикладного программиста модель была примерно такая:
java.lang.Thread -> native/platform thread -> OS scheduler
У такого потока есть стек, системные структуры, участие планировщика ОС, переключение контекста. Это не бесплатная штука. Создавать поток под каждую мелкую задачу можно, но недолго.
Java: создание обычного потока
Thread thread = new Thread(() -> {
System.out.println("Working in " + Thread.currentThread());
});
thread.start();
thread.join();
В маленькой программе всё выглядит нормально. В сервере под нагрузкой начинается веселье: тысячи соединений, ожидание БД, внешние HTTP-вызовы, очереди, таймауты. Если на каждый запрос заводить платформенный поток, можно быстро упереться не в CPU, а в память и планировщик.
Тут и появляются пулы.
2. Thread pools: компромисс эпохи дорогих потоков
Идея thread pool простая: потоки дорогие, значит, будем их переиспользовать.
Вместо того чтобы создать 10 000 потоков под 10 000 задач, создаём, например, 16 потоков для условного backend-инстанса на 16 vCPU (virtual CPU) и очередь задач перед ними.
Java: классический ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(16);
Future<User> userFuture = executor.submit(() -> userRepository.loadUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> orderClient.loadOrders(userId));
User user = userFuture.get();
List<Order> orders = ordersFuture.get();
executor.shutdown();
Сама идея отличная. Потоки не создаются на каждый чих, перед ними появляется очередь, можно ограничить параллелизм и не пустить в базу больше запросов, чем она переварит. Ну и классика: отдельный pool для CPU-bound, отдельный для blocking I/O, плюс правило на случай, если очередь переполнится.
Но пул потоков быстро превращается в инженерную задачу, а не просто в одну строчку кода.
Сколько потоков надо? 8? 16? 200? Размер очереди ограничивать или пусть будет unbounded? Что делать, если в ForkJoinPool.commonPool() случайно попала блокирующая операция? Почему один сервис завис, хотя CPU свободен? Почему CompletableFuture не выполняется? Почему в thread dump все ждут JDBC?
Знакомо?
CPU-bound и I/O-bound
Для CPU-bound задач логика понятная: если у нас 8 ядер, то 800 потоков не сделают вычисления быстрее. Они сделают больше переключений контекста.
Для I/O-bound задач всё хитрее. Поток может почти всё время ждать: БД, сеть, файловую систему, очередь. Поэтому потоков нужно больше, чем ядер. Но насколько больше? Ответ обычно звучит как “it depends”, и это неприятно, потому что зависит правда от всего.
Пулы потоков не исчезли и после Loom. Просто у них меняется роль. Раньше пул часто был способом экономить потоки. Сейчас он всё чаще нужен как ограничитель: не пустить в БД больше 50 запросов, не положить чужой API, не превратить сервис в генератор таймаутов.
3. Зелёные потоки и virtual threads
Зелёные потоки – это потоки, которыми управляет не операционная система, а runtime языка или виртуальной машины. Проще: много лёгких логических потоков могут жить поверх меньшего числа настоящих OS threads.
В Java зелёные потоки были ещё в ранних версиях платформы, потом Java ушла в native threads. А теперь идея вернулась, но уже в современном виде: Project Loom и virtual threads.
Почему Java ушла от ранних green threads
Короткая версия: ОС научились хорошо планировать native threads, а серверной Java нужно было нормально использовать несколько процессоров и ядер.
Старые green threads были удобны для переносимости, но плохо дружили с настоящим параллелизмом, blocking system calls, native-библиотеками, профайлерами и отладчиками. Для растущих серверных приложений это стало слишком большим ограничением.
Первоисточник по Loom: JEP 444: Virtual Threads.
Virtual threads стали стабильными в Java 21.
Java: virtual thread на одну задачу
Thread.startVirtualThread(() -> {
var user = userRepository.loadUser(userId);
var orders = orderClient.loadOrders(userId);
render(user, orders);
});
На вид это обычный Thread. И это не случайность. Loom специально сохраняет привычную Java-модель: обычные методы, обычные stack traces, обычные блокирующие вызовы. Только поток теперь лёгкий.
На пальцах схема такая:
много virtual threads
|
v
меньше carrier/platform threads
|
v
потоки ОС
Когда virtual thread делает blocking I/O, JVM может “отмонтировать” его от carrier thread. Carrier освобождается и выполняет другую работу. Когда I/O завершится, virtual thread продолжит выполнение.
Для программиста это выглядит почти магически: код блокирующий, но платформа старается не держать настоящий поток заблокированным там, где это можно сделать иначе.
Java: executor, который создаёт virtual thread на задачу
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> user = executor.submit(() -> userRepository.loadUser(userId));
Future<List<Order>> orders = executor.submit(() -> orderClient.loadOrders(userId));
return render(user.get(), orders.get());
}
Тут важный сдвиг в голове. Мы больше не обязаны переиспользовать virtual threads. Их нормально создавать под задачу. newVirtualThreadPerTaskExecutor() звучит почти как ересь для человека, воспитанного на newFixedThreadPool, но в этом и смысл.
Где virtual threads хороши
На практике они особенно хорошо ложатся на классический backend:
-
request-per-thread модель;
-
JDBC;
-
blocking HTTP clients;
-
RPC;
-
очереди;
-
много одновременного ожидания.
Это не ускоритель CPU. Если у вас 8 ядер и вы считаете хэши, виртуальный миллион потоков не превратит машину в суперкомпьютер.
Зато если у вас 10 000 запросов в основном ждут сеть и БД, virtual threads возвращают право писать нормальный последовательный код без пирамиды callback’ов и без обязательного reactive-стека.
4. Structured Concurrency
Virtual threads отвечают на вопрос: “как дешево запустить много задач?”
Но есть другой вопрос: “как потом не потерять эти задачи?”
Обычный ExecutorService легко рождает сиротские Future: одна задача упала, вторая продолжает работать, родительский запрос уже отменён, а где-то в фоне ещё летит HTTP-вызов. По thread dump потом можно гадать, кто кого породил.
Structured Concurrency пытается вернуть структуру туда, где раньше была куча ручных submit/get/cancel.
Стартовая JEP-ссылка: JEP 428: Structured Concurrency. Дальше API несколько раз проходил через preview-итерации.
Virtual threads уже стабильны. А вот structured concurrency в Java ещё проходит preview-стадии, поэтому мелкие детали API пока лучше считать временными. Они уже менялись и ещё могут поменяться.
Java: идея StructuredTaskScope
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> userRepository.loadUser(userId));
var orders = scope.fork(() -> orderClient.loadOrders(userId));
scope.join();
scope.throwIfFailed();
return render(user.get(), orders.get());
}
Я смотрю на это не как на новый красивый синтаксис, а как на попытку вернуть задачам нормальный жизненный цикл:
-
дочерние задачи живут внутри scope;
-
родитель ждёт их завершения;
-
при ошибке можно отменить остальные;
-
при выходе из блока не остаётся фоновых хвостов;
-
stack trace и диагностика становятся ближе к реальной структуре программы.
Рядом с Loom стоит ещё одна важная штука: JEP 506: Scoped Values. Это способ передавать контекст вниз по вызовам и дочерним задачам. Что-то из мира ThreadLocal, но лучше подходит к миллионам virtual threads и structured concurrency.
Вывод
Классические thread pools появились как ответ на дорогие platform threads. Они помогают переиспользовать потоки, ограничивать параллелизм и контролировать очередь задач, но вместе с этим добавляют отдельный слой настройки: размер пула, размер очереди, разные пулы для CPU-bound и blocking I/O.
Virtual threads меняют эту картину. Их не нужно переиспользовать как обычные потоки, поэтому Java снова позволяет писать последовательный blocking-код, но запускать много независимых задач дешевле. При этом ограничения никуда не исчезают: базу, внешний API или очередь всё равно нужно защищать лимитами.
Structured Concurrency закрывает другой вопрос: не только как запустить задачи, но и как управлять их жизненным циклом. Чтобы дочерние задачи не жили отдельно от родительского запроса, нормально отменялись и не превращались в фоновые хвосты.
Во второй части перейдём к JVM-языкам, которые решают похожие задачи уже иначе: Kotlin coroutines, ZIO runtime и Clojure.
Если вам близки темы разработки, рефакторинга, архитектуры и стартапов, буду рад видеть вас в моём Telegram-канале.
Ссылки
Автор: rurikovich


