Ещё недавно reactive был единственным способом обслуживать десятки тысяч соединений на JVM без армии потоков. Loom изменил расклад: блокирующий код снова масштабируется, а на бенчмарке из предыдущей статьи virtual threads обходят Reactor по throughput примерно втрое. Значит ли это, что reactive умер? Нет — но список причин его выбирать заметно сократился, и важно понимать, какие именно причины остались.
Это не пересказ бенчмарка — он уже разобран в статье «Конкурентность на JVM: virtual threads, корутины и reactive» этой же серии, с методикой, полной таблицей и разбором того, откуда берётся разрыв в числах. Здесь — другой угол: как принимать решение «reactive или virtual threads» для конкретного сервиса, не оглядываясь на моду ни в одну, ни в другую сторону.
В статье
- Как устроен reactive: Mono/Flux и backpressure
- Цена reactive: сложность, которую покупаете вместе с backpressure
- После Loom: что закрывают virtual threads
- Где backpressure всё ещё нужен
- Как выбирать: дерево решений
Как устроен reactive: Mono/Flux и backpressure
Project Reactor строит конкурентность не через потоки (виртуальные или платформенные), а через ленивые декларативные цепочки операторов над Mono (0 или 1 элемент) и Flux (0..N элементов). Ничего не выполняется до подписки — Flux.range(...).map(...).filter(...) только описывает, что должно произойти, когда появится подписчик:
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() — таймер-колёса на небольшом пуле планировщиков; flatMap с concurrency=N держит все N подписок «в полёте» одновременно.
Ключевая причина, по которой reactive вообще появился как отдельная модель, а не просто «ещё один способ писать асинхронный код», — backpressure на уровне протокола Reactive Streams. Подписчик сигнализирует издателю через request(n), сколько элементов он готов принять прямо сейчас, а оператор физически не эмитит больше — не буферизует безгранично, не роняет данные молча, а тормозит производителя на уровне контракта между Publisher и Subscriber. Это не то же самое, что ограничение размера пула потоков или очереди: там производитель либо блокируется на переполненной очереди, либо роняет задачи, либо копит их в памяти — сигнал идёт снизу вверх постфактум. В Reactive Streams потребитель заранее объявляет свою пропускную способность, и это часть спецификации, а не соглашение между командами.
WebFlux строит на этом полностью неблокирующий HTTP-стек: от Netty-обработчика запроса до ответа клиенту — ни одного блокирующего вызова, ни одного потока, простаивающего в ожидании I/O. При достаточно медленном или недобросовестном клиенте backpressure не даёт серверу захлебнуться собственными буферами.
Цена reactive: сложность, которую покупаете вместе с backpressure
За декларативность и backpressure reactive платит той же монетой, что и любой async-стек с «раскрашенными» функциями — методом, который возвращает Mono/Flux, нельзя вызвать как обычный, весь путь вызова выше по стеку тоже становится реактивным. Смешать блокирующий JDBC-вызов внутри flatMap на неправильном шедулере — частая ошибка, которая тихо сводит преимущества модели на нет: реактивный код формально остаётся реактивным, но блокирует один из немногих потоков Schedulers.parallel(), а не один из тысяч виртуальных.
Вторая цена — отладка. Стектрейс исключения, брошенного внутри оператора flatMap, чаще всего указывает на внутренности Reactor — на вызов subscribe() где-то в недрах Netty, а не на место в бизнес-логике, где реально случилась ошибка. Hooks.onOperatorDebug() частично лечит проблему, добавляя точки сборки стектрейса на этапе построения цепочки, но платит за это заметным overhead и обычно годится только для локальной отладки, не для прода.
Третья цена — порог входа и мышление. Reactive-код пишется и читается иначе, чем последовательный: вместо for и try/catch — операторы map/flatMap/retry/onErrorResume, вместо стектрейса вызовов — граф подписок. Опытная команда, годами писавшая блокирующий код, тратит реальное время на то, чтобы начать думать в терминах Publisher/Subscriber, а не просто выучить синтаксис Mono.
После Loom: что закрывают virtual threads
Virtual threads (JEP 444, JDK 21+) убирают главную историческую причину выбирать reactive — необходимость не блокировать поток ОС ради масштаба. Обычный блокирующий JDBC-драйвер, HttpClient.send(), Thread.sleep() — всё это теперь масштабируется на десятки тысяч конкурентных задач без единой строчки реактивного кода, потому что блокировка виртуального потока не блокирует carrier-поток: JDK автоматически размонтирует его на большинстве блокирующих операций.
На бенчмарке из статьи #5 (методика: N=10 000 I/O-bound задач, sleep/delay=100 мс, тот же сценарий «всё сразу», без искусственного ограничения concurrency) это не абстракция, а измеренная разница:
| Модель | Throughput (задач/сек) | Peak RSS |
|---|---|---|
| Virtual threads | ~49 500 | ~88–91 МБ |
| Reactor (flatMap, concurrency=N) | ~16 500 | ~98–101 МБ |
VT впереди примерно в 3 раза по throughput при формально одинаковом сценарии — и это не повод считать Reactor «медленным ради медленности»: разрыв объясняется накладными расходами flatMap/Schedulers.parallel() на масштабе 10 000 одновременных таймеров, а не тем, что реактивная модель неэффективна в принципе. Reactor платит эту цену за возможность, которой у VT попросту нет, — backpressure на уровне протокола (подробный разбор методики, полная таблица по пяти моделям и объяснение разрыва — в статье «Конкурентность на JVM»).
Здесь же стоит закрыть частый источник путаницы: Kotlin-корутины из той же таблицы (~19 600 задач/сек) — это НЕ «почти VT». По throughput корутины оказались в том же среднем ярусе, что и Reactor, а не на втором месте следом за VT — разрыв с VT объясняется измеренной стоимостью fan-out: сам цикл регистрации 10 000 дочерних корутин одного coroutineScope занял 56–75% wall-clock времени прогона. Интуитивное ожидание «лёгкая единица конкурентности ≈ virtual thread по скорости» подтвердилось только по памяти, не по throughput — и то же верно для Reactor: обе модели проигрывают VT в этом сценарии не потому, что VT «правильнее», а потому что platform-примитив JVM без обвязки почти всегда обгонит любую пользовательскую надстройку над ним по чистому throughput.
Практическое следствие: для нового сервиса, где backpressure не критичен, а код проще писать в блокирующем стиле, — VT выигрывает почти всегда, и по throughput, и по читаемости, и по отладке (обычный стектрейс вместо графа подписок).
Где backpressure всё ещё нужен
Список того, что reactive даёт, а VT — нет напрямую, короче, чем был пять лет назад, но не пуст:
- Backpressure на уровне протокола. Это единственное место в сравнении, где производитель реально тормозится потребителем через контракт
request(n), а не через внешний механизм. У VT нет прямого эквивалента — только семафоры, ограничение размера пула илиChannelс ограниченной ёмкостью (у KotlinFlow). Если downstream-потребитель регулярно медленнее производителя и это нужно обрабатывать не как исключение, а как норму — HTTP-стриминг ответа медленному клиенту, экспорт большого датасета, консьюмер с непредсказуемой скоростью обработки, — альтернатив Reactor/WebFlux на JVM практически нет. - Композиция операторов над потоком событий. Цепочка
retry→timeout→onErrorResume→buffer→windowнад потоком событий читается как единый декларативный конвейер. Переписать то же самое императивно поверх блокирующего кода на virtual threads возможно, но получившийся код обычно длиннее и менее очевиден — вы вручную реализуете то, что в Reactor уже есть готовым оператором. - Стриминг с явным временны́м измерением. Операторы вроде
Flux.interval,window(Duration),sample,debounceзавязаны на временную семантику потока, а не на дискретные запрос-ответ операции — для этого класса задач reactive-модель ближе к предметной области, чем последовательность блокирующих вызовов. - Существующий реактивный стек, который работает. Если сервис уже написан на WebFlux, r2dbc и реактивных клиентах, а бизнес-метрики в порядке — миграция на VT ради самого факта миграции не решает никакой реальной проблемы и добавляет только риск.
Ни один из этих пунктов не про «reactive быстрее» — на измеренном бенчмарке верно обратное. Они про возможности, которых блокирующая модель не даёт в принципе или даёт заметно менее прямым способом.
Как выбирать: дерево решений
flowchart TD
A{"Новый код\nили миграция?"} -->|"Существующий WebFlux,\nработает штатно"| KEEP["Оставить как есть.\nМиграция ради моды — не причина"]
A -->|"Новый сервис\nили явный рефакторинг"| B{"Нужен backpressure\nна уровне протокола?"}
B -->|"Да: медленный потребитель,\nстриминг, защита от перегрузки"| REACTOR["Reactor / WebFlux\n(или Flow в Kotlin)"]
B -->|"Нет"| C{"Много композиции\nоператоров над событиями\nво времени?"}
C -->|"Да"| REACTOR
C -->|"Нет: линейный\nблокирующий код"| D{"Блокирующие библиотеки\n(JDBC, старый HTTP-клиент)?"}
D -->|"Да"| VT["Virtual threads.\nБлокирующий код без изменений"]
D -->|"Нет, но throughput\nи читаемость важнее"| VT
style KEEP fill:#f9f3e3,stroke:#8b7355
style REACTOR fill:#c67a4a,stroke:#3a3631
style VT fill:#6d8a99,stroke:#3a3631
Практические ориентиры к дереву:
- Не переписывать работающий reactive «потому что Loom». Миграция WebFlux → VT — это переписывание реактивных драйверов на блокирующие, пересмотр обработки ошибок, замена операторов на try/catch — реальная стоимость ради выигрыша, который часто не критичен для уже стабильного сервиса.
- Не начинать новый reactive-стек «потому что модно». Если backpressure не нужен и команда не пишет на reactive каждый день, VT почти всегда даёт тот же результат проще, быстрее и с обычным стектрейсом при падении.
- Смешанный стек — нормальная позиция. Ядро сервиса на VT с блокирующими вызовами, а один компонент — стриминг медленному клиенту или Kafka-консьюмер с явным backpressure — на Reactor. Это не архитектурная неряшливость, а осознанное применение каждой модели там, где она даёт то, чего другая не даёт.
- Реактивные драйверы — отдельная переменная. Если БД-драйвер уже реактивный (r2dbc) и вокруг нет причин для backpressure, чистый блокирующий JDBC + VT обычно проще эксплуатировать — на JDBC больше опыта у команд, больше tooling, привычнее диагностика через thread dump.
Итог
Virtual threads не убили reactive, но забрали у него ту причину существования, которая была самой массовой — «нужно масштабировать I/O-bound нагрузку без потока на задачу». На бенчмарке из статьи #5 это видно прямо в числах: VT примерно втрое быстрее Reactor на одном и том же сценарии, при сопоставимой памяти. Для нового сервиса без backpressure-требований это почти всегда закрывает вопрос в пользу VT — блокирующий код, обычный стектрейс, меньше барьер входа.
То, что осталось за reactive, — не инерция и не привычка, а одна конкретная возможность, которой у VT нет: производитель, которого тормозит потребитель на уровне протокола, а не через внешний семафор. Там, где это действительно нужно — стриминг медленному клиенту, защита от перегрузки при непредсказуемой нагрузке, — альтернатив на JVM по-прежнему немного. Выбор между reactive и virtual threads сегодня — это не вопрос моды в ту или другую сторону, а вопрос одной проверки: нужен ли backpressure на уровне протокола именно в этом месте системы. Если да — Reactor. Если нет — почти наверняка virtual threads.
Тема конкурентности этим не исчерпывается: «Kotlin для backend» разбирает Kotlin Flow как модель стриминга, более лёгкую в письме, чем Reactor, но с похожими компромиссами; Kafka на JVM показывает, как consumer сам управляет темпом через poll() — контроль потока на уровне протокола брокера, без reactive-абстракций; а очереди задач и фоновые джобыготовится, с 17 сентября — соседний слой конкурентности, где вопрос не «reactive или блокирующий вызов», а «как распределить работу между воркерами», не сталкиваясь напрямую с backpressure Reactive Streams.
Документация и первоисточники
- Project Reactor — Reference Documentation
- Reactive Streams Specification
- Spring WebFlux
- JEP 444: Virtual Threads
- Kotlin Flow
- Стенд:
digital-cookbook/java/deep-dive/concurrency(тот же код, что и в статье #5 —ReactorBench.java,BlockingBench.java)
Комментарии