Конкурентность на JVM: virtual threads, корутины и reactive

Три модели конкурентности на JVM: virtual threads (Loom), корутины Kotlin и reactive (Project Reactor) — как они устроены, чем платят и когда какая уместна

Долгие годы «масштабируемый ввод-вывод на JVM» означал reactive: callback’и, Mono/Flux и цену в виде нечитаемых стектрейсов. Project Loom изменил уравнение — блокирующий код снова масштабируется. А в Kotlin параллельно живут корутины. Три модели, и выбор между ними — не про моду.

Эта статья — сравнение трёх подходов к конкурентности на JVM по существу: механизм, цена, границы применимости. В конце — не абстрактные рассуждения, а реальный прогон одного и того же сценария (10 000 «одновременных» I/O-bound задач) через все три модели плюс platform-пул в двух конфигурациях, с честными числами throughput, latency и памяти — включая места, где интуиция подводит.

Конкурентность на JVM: virtual threads, корутины и reactive

В статье

Virtual threads (Loom)

Project Loom (JEP 444, финализирован в JDK 21) вводит виртуальные потоки — юниты выполнения, управляемые самой JVM, а не операционной системой. Ключевой механизм — continuations: внутреннее API, которое позволяет виртуальному потоку «размонтироваться» с несущего (carrier) потока в точке блокировки и вернуть carrier в пул для другой задачи, а при готовности — примонтироваться заново, возможно на другой carrier-поток. Именно поэтому миллион виртуальных потоков помещается в память, где раньше поместилась бы лишь пара тысяч платформенных: виртуальный поток — это в первую очередь объект в куче (стек растёт по мере надобности), а не выделенный OS-стек фиксированного размера (обычно 1 МБ) плюс запись в таблице потоков ядра.

Практическое следствие — блокирующий стиль снова корректен и читаем. Thread.sleep(), блокирующий JDBC-драйвер, HttpClient.send() — всё это работает без единой строчки reactive-кода и масштабируется на десятки тысяч конкурентных задач, потому что блокировка виртуального потока не блокирует carrier: JDK автоматически размонтирует его на большинстве стандартных блокирующих операций.

В стенде к этой статье разница между virtual threads и platform-пулом — буквально одна строчка: меняется реализация ExecutorService, весь остальной код задачи не трогается.

Result result = switch (mode) {
    case "vt" -> BlockingBench.run(mode, Executors.newVirtualThreadPerTaskExecutor(), n, sleepMs);
    case "platform" -> BlockingBench.run(mode, fixedPool(200), n, sleepMs);
    case "platform-large" -> BlockingBench.run(mode, fixedPool(n), n, sleepMs);
    case "reactor" -> ReactorBench.run(n, sleepMs);
    default -> throw new IllegalArgumentException("unknown mode '" + mode + "'");
};
for (int i = 0; i < n; i++) {
    int idx = i;
    long submitTime = System.nanoTime();
    executor.submit(() -> {
        try {
            Thread.sleep(sleepMs); // блокирующий вызов — обычный код, не reactive
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            latenciesNanos[idx] = System.nanoTime() - submitTime;
            latch.countDown();
        }
    });
}
latch.await();

У модели есть реальные ловушки, и все они — про то, что виртуальный поток не размонтировался, когда должен был, и завис на carrier-потоке (pinning). Классический случай — synchronized-блок: вход в него при блокирующем вызове внутри держал carrier-поток занятым весь блок, и при достаточно большом числе виртуальных потоков это превращало Loom в дорогой platform-пул. Начиная с JDK 24 (JEP 491) большинство synchronized-блоков больше не пиннят виртуальный поток к carrier — но нативные вызовы (JNI) и часть блокирующих методов по-прежнему пиннят, и это стоит проверять флагом -Djdk.tracePinnedThreads=full при подозрении на проблему. Второй нюанс — ThreadLocal: он по-прежнему работает на виртуальных потоках, но при счёте на миллионы одновременных задач лишний ThreadLocal — это лишняя копия на каждую, и накладные расходы становятся заметны раньше, чем на platform-пуле из сотен потоков; для передачи контекста в новом коде стоит присмотреться к ScopedValue (JEP 506, финализирован в JDK 25) — он спроектирован специально под структурную конкурентность и виртуальные потоки.

Корутины Kotlin

Kotlin решает ту же задачу масштабируемого I/O иначе: не через управляемые JVM потоки, а через компиляцию suspend-функций в машину состояний на уровне байткода. Только suspend — не заклинание «сделай неблокирующим»: сам модификатор лишь разрешает функции приостанавливаться и заставляет компилятор развернуть последовательный код в цепочку продолжений. Поток освобождается исключительно в реальной точке приостановкиdelay, неблокирующее I/O, ожидание другой корутины: там корутина отдаёт поток диспетчеру, и тот занимает им другую корутину. А Thread.sleep() или блокирующий JDBC-вызов внутри suspend-функции блокируют поток ровно так же, как в обычном коде, — компилятор их не «расколдует». Для такого кода есть withContext(Dispatchers.IO): отдельный пул, где поток блокировать не жалко.

Structured concurrency — не факультативная практика, а часть API: coroutineScope { ... } не возвращает управление, пока не завершатся (успешно или с ошибкой) все дочерние корутины, запущенные внутри блока. Отмена и таймауты — тоже первоклассные операции, а не try/finally поверх futures:

suspend fun loadArticleCard(slug: String): Article = coroutineScope {
    val titleDeferred = async { fetchTitle(slug) }
    val viewsDeferred = async { fetchViews(slug) }
    Article(slug = slug, title = titleDeferred.await(), views = viewsDeferred.await())
}

val timedOut = withTimeoutOrNull(2) {
    launch { delay(50) } // не успеет — родительский withTimeoutOrNull отменит раньше
    delay(50)
    "успел"
}

Подробнее про Kotlin-специфичные идиомы — null-safety, data-классы, sealed-иерархии, extension-функции — в статье «Kotlin для backend» этой же серии; здесь фокус только на конкурентности.

Корутины vs virtual threads: где пересекаются, где нет. Интуитивно напрашивается ожидание «корутины ≈ virtual threads» — обе модели дают дешёвую, масштабируемую единицу конкурентности без выделенного OS-потока на задачу. На стенде это ожидание подтвердилось только наполовину. По памяти — да: корутины держат ~107–129 МБ peak RSS, тот же порядок, что и VT (~89 МБ). А вот по throughput — нет: корутины показали ~19 600 задач/сек (18 945–22 476 по 6 прогонам) — это в 2.3–3 раза медленнее VT (~49 500) и оказались в том же среднем ярусе, что и Reactor (~16 500): по точечной оценке чуть выше, но обе модели далеко позади VT — не тот «почти второе место сразу за VT», которого можно было бы ожидать от корутин.

Причина разрыва — не «долгий хвост» отдельных задач, как можно было бы предположить: во всех 6 прогонах max практически равен p99 (разница меньше 2 мс), гипотеза «немногие задачи завершаются заметно позже большинства» не подтвердилась. Причина измерена напрямую — это цена самого цикла постановки задач:

runBlocking {
    coroutineScope {
        repeat(n) { i ->
            val submitTime = System.nanoTime()
            launch(Dispatchers.Default) {
                delay(sleepMs)
                latenciesNanos[i] = System.nanoTime() - submitTime
            }
        }
        // диагностика: сколько времени сам цикл repeat(n){launch{...}} отнимает
        // от wall-clock ДО того, как блок начнёт ждать завершения детей
        submitLoopNanos = System.nanoTime() - wallStart
    }
}

submit_loop_ms — время, которое занимает сам цикл repeat(n) { launch(Dispatchers.Default) { ... } }, регистрация 10 000 дочерних корутин одного coroutineScope с одного вызывающего потока, — составило 298–447 мс, то есть 56–75% всего wall-clock времени прогона (среднее по 4 диагностическим прогонам — около 67%). Пример одного прогона: throughput 18 945 задач/сек, p50 161.7 мс, p99 235.0 мс, max 236.2 мс, wall 527.8 мс, из которых submit_loop — 297.9 мс.

Вероятный механизм этой цены — растущая иерархия Job/список дочерних корутин одного coroutineScope, регистрируемых с одного потока, плюс конкуренция самого вызывающего потока с уже запущенными дочерними корутинами за ограниченный пул Dispatchers.Default (по числу ядер CPU). Это гипотеза, а не установленный факт: она не изолирована отдельным экспериментом и приведена как наиболее правдоподобное объяснение измеренных чисел, а не как окончательный вывод. У virtual threads аналогичного узкого места нет — Thread.ofVirtual().start() в цикле является примитивом JVM без сопоставимой структуры данных для регистрации иерархии.

Дополнительная честная оговорка: разброс throughput между прогонами Kotlin-части — около 20% (шире, чем ~5–10% у Java-части) — короткоживущий процесс с холодным JIT, зафиксировано как есть, не сглажено.

Reactive (Project Reactor)

Project Reactor реализует третий подход: реактивные потоки (Mono/Flux) с ленивой подпиской и операторными цепочками, где конкурентность выражается не потоками (виртуальными или платформенными) и не корутинами, а декларативной композицией операторов поверх небольшого пула планировщиков.

Flux.range(0, n)
        .flatMap(i -> {
            long submitTime = System.nanoTime();
            return Mono.delay(Duration.ofMillis(sleepMs))
                    .doOnNext(tick -> latenciesNanos[i] = System.nanoTime() - submitTime);
        }, n)
        .blockLast();

Mono.delay() в этом стенде использует Schedulers.parallel() — таймер-колёса (timer wheel) на небольшом пуле потоков, а не поток на задачу; flatMap с concurrency=N держит все N подписок «в полёте» одновременно — тот же режим нагрузки, что и у virtual threads, без искусственного ограничения на число одновременных задач.

Backpressure — единственная в этом сравнении возможность по-настоящему управлять скоростью производителя относительно потребителя на уровне протокола, а не просто ограничением размера пула или очереди: подписчик сигнализирует, сколько элементов готов принять, и оператор физически не эмитит больше. Ни у virtual threads, ни у корутин нет прямого эквивалента — обе модели полагаются на внешние механизмы (семафоры, ограничение размера пула, Channel с ограниченной ёмкостью у Kotlin).

Именно поэтому после Loom reactive нужен реже, но не исчез: там, где backpressure не критичен и код проще в блокирующем стиле, virtual threads выигрывают по читаемости почти всегда. Подробный разбор, когда WebFlux всё ещё оправдан, а когда это уже переусложнение, — в статье «Reactive на JVM: Project Reactor, WebFlux и когда он больше не нужен», финальной в этой серии.

Цена, которую платит Reactor за декларативность и backpressure, — сложность отладки: стектрейс исключения внутри flatMap часто указывает на внутренности Reactor, а не на место в бизнес-логике, где реально произошла ошибка (Hooks.onOperatorDebug() частично лечит это ценой overhead). Плюс сам порог входа — код в реактивном стиле читается и пишется иначе, чем последовательный, и требует другого мышления даже от опытной команды.

На этом же стенде throughput Reactor (~16 500 задач/сек) заметно — примерно в 3 раза — ниже, чем у VT (~49 500), при формально одинаковом сценарии («все N задач сразу», без искусственного ограничения concurrency). Это не противоречит тезису статьи (Reactor эффективен по памяти при заметно более сложном коде), но разрыв стоит проговорить прямо, а не подавать как «почти вровень с VT». Вероятное объяснение — накладные расходы flatMap/Schedulers.parallel(): таймер-колесо Mono.delay() рассчитано на разумное число одновременных таймеров, а не на 10 000 сразу, и диспетчеризация через ограниченный пул Schedulers.parallel() (по числу ядер) добавляет расходы, которых нет у примитива JVM Thread.ofVirtual().

Бенчмарк: throughput, latency и память на одном сценарии

Методика одинакова для всех пяти строк таблицы: N = 10 000 «одновременных» I/O-bound задач, задержка 100 мс (Thread.sleep/delay/Mono.delay — в зависимости от модели), каждый режим — отдельный процесс JVM (peak RSS не смешивается между режимами), latency = время от постановки задачи в очередь/на подписку до завершения — честно включает время ожидания в очереди, а не только сам sleep. Стенд — eclipse-temurin:25-jdk в Docker Desktop/Windows, -Xms64m -Xmx1g, контейнер -m 4g, одинаковые флаги для Java- и Kotlin-частей (Kotlin 2.2.0, kotlinx-coroutines-core 1.11.0). Все числа — характерный прогон на конкретном хосте; важен порядок величин и относительное сравнение, а не абсолютные цифры.

Модель Throughput (задач/сек) p50 latency p99 latency Peak RSS
Virtual threads 49 484 / 53 032 101.6–102 мс 105.8–110 мс 88–91 МБ
Kotlin coroutines 18 945–22 476 (6 прогонов) 126.1–200.4 мс 144.5–289.7 мс 107–129 МБ
Reactor (flatMap, concurrency=N) 16 494 / 16 958 100.3 мс 154–158 мс 98–101 МБ
Platform threads, пул 200 1 940 / 1 952 2 499.0 мс 4 941–4 950 мс 96–99 МБ
Platform threads, пул 10 000 3 296 / 3 165 100.4 мс 102.8 мс 366–427 МБ
Throughput пяти моделей конкурентности на JVM, задач/сек (характерный прогон)N=10 000 I/O-bound задач, sleep/delay=100 мс, отдельный процесс на режим55 00027 500~49 500~19 600~16 500~1 940~3 300VTcoroutinesReactorplatform×200platform×10kRSS ~89 МБRSS 107–129 МБRSS ~98 МБRSS 96–99 МБRSS 370–430 МБЧисла host-зависимы (Docker Desktop/Windows). Разброс: Java ~5–10% между прогонами, Kotlin ~20%

Две строки platform threads в таблице и на графике — намеренно, не избыточность. Пул из 200 платформенных потоков (platform×200) упирается в throughput: на N=10 000 задач с sleep=100 мс и пулом в 50 раз меньше N образуется очередь партиями примерно по 200 штук, и p99 latency улетает за 4.9 секунды — задача может просидеть в очереди дольше, чем длится сам I/O. Пул размером N (platform×10k, 10 000 платформенных потоков) снимает очередь пула-200 — p99 latency падает с ~4.9 с до ~102 мс, throughput растёт с ~1 940 до ~3 300 — ценой памяти: peak RSS вырос примерно в 4 раза, с ~90 МБ до 366–427 МБ. Но по throughput это всё равно кратно ниже среднего яруса (корутины/Reactor, ~16 500–19 600) — снятие очереди не поднимает platform-пул до их уровня. Важная честная деталь: тезис «10 000 platform-потоков — это крах» не подтвердился буквально — процесс не упал с OutOfMemoryError или unable to create native thread, память выросла, но не взорвалась. На этой машине (контейнер с лимитом 4 ГБ, дефолтный -Xss) 10 000 потоков не добивают до предела; на более скромном контейнере или при десятках/сотнях тысяч потоков картина будет жёстче. Обе строки вместе показывают реальный компромисс platform-пула — throughput или память, выбирайте, — по отдельности каждая раскрывает только половину картины.

Сведём итог по всем пяти моделям в одну строку: virtual threads лидируют по throughput с большим отрывом при низкой памяти без единой строчки reactive-кода; Kotlin-корутины и Reactor образуют один средний ярус, близкий друг к другу по throughput (по точечной оценке корутины чуть впереди Reactor, но точный порядок здесь менее важен, чем сам факт «ярус Reactor, а не ярус VT»), и оба заметно позади VT — у корутин цена в измеренной стоимости fan-out регистрации, у Reactor — в накладных расходах таймер-колеса на масштабе 10 000 задач; platform-пул — крайние случаи компромисса throughput/память, честная иллюстрация того, зачем вообще понадобились и Loom, и реактивные фреймворки.

Как выбирать

Разница между I/O-bound и CPU-bound нагрузкой определяет выбор раньше всего остального. Все три модели в этой статье решают одну и ту же задачу — масштабирование конкурентного I/O-bound кода без потока на задачу. Ни одна из них не ускоряет CPU-bound работу: если узкое место — вычисления, а не ожидание, помогут только больше ядер или меньше работы, а не смена модели конкурентности.

Практические ориентиры:

  • Нужен ли backpressure на уровне протокола — если да (стриминг с медленным потребителем, защита от перегрузки при неравномерной нагрузке), альтернатив Reactor/WebFlux (или Flow в Kotlin с ограниченным буфером) на JVM практически нет.
  • Блокирующий код и библиотеки, которые не переписать — virtual threads снимают вопрос: JDBC-драйверы, блокирующие HTTP-клиенты, файловый I/O работают без изменений и масштабируются на десятки тысяч конкурентных задач.
  • Kotlin как основной язык проекта — корутины интегрированы с языком (structured concurrency, Flow, отмена) глубже, чем любой JVM-фреймворк может быть интегрирован извне; но на этом стенде они проигрывают VT по throughput при том же сценарии, и это стоит держать в голове при выборе, если throughput — узкое место, а не просто «корутины ведь тоже лёгкие».
  • Не смешивать модели в одном слое без причины — блокирующий вызов внутри reactive-цепочки на неправильном шедулере или блокировка виртуального потока внутри synchronized, вызывающего нативный код, — оба случая сводят преимущества модели на нет одним и тем же способом: тихо возвращают код в старый режим «поток простаивает, ожидая I/O», просто спрятанный за абстракцией.

Модели конкурентности — общая проблема не только JVM: обзор паттернов конкурентности в разных языкахготовится, с 18 сентября сравнивает virtual threads, корутины, async/await и модель Go на уровне идей, а серия про конкурентность в Go разбирает тот же вопрос — дешёвая единица конкурентности вместо потока на задачу — на горутинах и каналах вместо continuations и state machines.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии