Сравнение моделей конкурентности JVM языков: Треды, Пулы и Structured Concurrency. Blocking IO.. Blocking IO. ExecutorService.. Blocking IO. ExecutorService. Java.. Blocking IO. ExecutorService. Java. JVM.. Blocking IO. ExecutorService. Java. JVM. Kotlin.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread. Thread Pool.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread. Thread Pool. Virtual Threads.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread. Thread Pool. Virtual Threads. Анализ и проектирование систем.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread. Thread Pool. Virtual Threads. Анализ и проектирование систем. высоконагруженные системы.. Blocking IO. ExecutorService. Java. JVM. Kotlin. Project Loom. scala. Structured Concurrency. Thread. Thread Pool. Virtual Threads. Анализ и проектирование систем. высоконагруженные системы. многопоточность.

Привет, Хабр

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

Сравнение моделей конкурентности JVM языков: Треды, Пулы и Structured Concurrency - 1

В 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-итерации.

JEP по Structured Concurrency

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

Источник