- BrainTools - https://www.braintools.ru -

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

Привет, Хабр

Многие если не все встречались с потоками, пулами потоков и проблемами многопоточности и конкурентности. В 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, а в память [1] и планировщик.

Тут и появляются пулы.


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 задач логика [2] понятная: если у нас 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 [3].

Virtual threads стали стабильными в Java 21.

Java: virtual thread на одну задачу
Thread.startVirtualThread(() -> {
    var user = userRepository.loadUser(userId);
    var orders = orderClient.loadOrders(userId);
    render(user, orders);
});

На вид это обычный Thread. И это не случайность [4]. 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 [5]. Дальше 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;

  • родитель ждёт их завершения;

  • при ошибке [13] можно отменить остальные;

  • при выходе из блока не остаётся фоновых хвостов;

  • stack trace и диагностика становятся ближе к реальной структуре программы.

Рядом с Loom стоит ещё одна важная штука: JEP 506: Scoped Values [14]. Это способ передавать контекст вниз по вызовам и дочерним задачам. Что-то из мира 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-канале [15].


Ссылки

Автор: rurikovich

Источник [16]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/30161

URLs in this post:

[1] память: http://www.braintools.ru/article/4140

[2] логика: http://www.braintools.ru/article/7640

[3] JEP 444: Virtual Threads: https://openjdk.org/jeps/444

[4] случайность: http://www.braintools.ru/article/6560

[5] JEP 428: Structured Concurrency: https://openjdk.org/jeps/428

[6] JEP 453: https://openjdk.org/jeps/453

[7] JEP 462: https://openjdk.org/jeps/462

[8] JEP 480: https://openjdk.org/jeps/480

[9] JEP 499: https://openjdk.org/jeps/499

[10] JEP 505: https://openjdk.org/jeps/505

[11] JEP 525: https://openjdk.org/jeps/525

[12] JEP 533: https://openjdk.org/jeps/533

[13] ошибке: http://www.braintools.ru/article/4192

[14] JEP 506: Scoped Values: https://openjdk.org/jeps/506

[15] Telegram-канале: https://t.me/tech_lead_rst

[16] Источник: https://habr.com/ru/articles/1033894/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1033894

www.BrainTools.ru

Rambler's Top100