Асинхронное программирование на JVM — тема, которая вызывает больше всего споров. Две основные парадигмы: корутины Kotlin (structured concurrency) и Project Reactor (reactive streams). Обе решают одну задачу — эффективное использование потоков при I/O-операциях. Но подход, DX и экосистема совершенно разные.
Я использовал обе модели на продакшене: Reactor — в Spring WebFlux-проекте с 200+ эндпоинтами, корутины — в Ktor-микросервисах с 50K rps. Вот мои выводы.
Project Reactor: реактивные потоки
Project Reactor — реализация Reactive Streams спецификации от Pivotal (ныне VMware/Broadcom). Он работает с двумя основными типами: Mono<T> (0 или 1 элемент) и Flux<T> (0..N элементов). Вся логика — через цепочку операторов: map, flatMap, filter, switchIfEmpty.
fun getProductWithReviews(productId: Long): Mono<ProductDetailDto> {
return productRepository.findById(productId)
.switchIfEmpty(Mono.error(ProductNotFoundException(productId)))
.flatMap { product ->
reviewRepository.findByProductId(product.id)
.collectList()
.map { reviews ->
ProductDetailDto(
id = product.id,
name = product.name,
price = product.price,
reviews = reviews.map { it.toDto() },
averageRating = reviews.map { it.rating }.average()
)
}
}
.timeout(Duration.ofSeconds(3))
.onErrorResume(ProductNotFoundException::class.java) {
Mono.empty()
}
}
Reactor навязывает реактивную модель на всех уровнях. Если вы вызываете блокирующий код (JDBC, File I/O) внутри реактивной цепочки — вы блокируете event loop, и вся парадигма ломается. Это главная ловушка. Любой blocking call нужно оборачивать в Schedulers.boundedElastic() или использовать реактивные драйверы (R2DBC вместо JDBC, WebClient вместо RestTemplate).
Плюсы Reactor: мощнейший набор операторов для сложных потоковых трансформаций, backpressure из коробки (контроль скорости между producer и consumer), интеграция с Spring WebFlux, Netty и R2DBC. Для pipeline-обработки данных (streaming ETL, real-time analytics) Reactor — выдающийся инструмент.
Минусы: крутой порог входа. Отладка реактивных цепочек — это боль. Stack trace показывает операторы, а не контекст бизнес-логики. flatMap внутри flatMap быстро превращается в непонятную лапшу. Новые разработчики тратят недели на привыкание к реактивному мышлению.
Проблема отладки
Самая частая боль — stack trace при ошибке в цепочке. Reactor показывает что-то вроде:
java.lang.NullPointerException
at com.example.service.ProductService$$Lambda$1234.apply(Unknown Source)
at reactor.core.publisher.FluxFlatMap$FlatMapMain.onNext(FluxFlatMap.java:381)
at reactor.core.publisher.FluxFilterFuseable$FilterFuseableSubscriber.onNext(FluxFilterFuseable.java:113)
at reactor.core.publisher.MonoPeek$MonoPeekTerminal.onNext(MonoPeek.java:231)
at reactor.core.publisher.FluxSwitchIfEmpty$SwitchIfEmptySubscriber.onNext(FluxSwitchIfEmpty.java:72)
... 42 more
Где именно произошла ошибка? Какой flatMap? С каким продуктом? Приходится добавлять .doOnNext(), .checkpoint("step-name") и логировать на каждом шаге. Это добавляет бойлерплейта и замедляет разработку.
Kotlin Coroutines: structured concurrency
Корутины Kotlin — принципиально другой подход. Вместо цепочек операторов вы пишете последовательный код, который выглядит как синхронный. Компилятор через CPS-трансформацию (Continuation-Passing Style) превращает suspend-функции в state machine. Вы не замечаете асинхронности — она скрыта.
suspend fun getProductWithReviews(productId: Long): ProductDetailDto {
val product = productRepository.findById(productId)
?: throw ProductNotFoundException(productId)
val reviews = reviewRepository.findByProductId(product.id)
return ProductDetailDto(
id = product.id,
name = product.name,
price = product.price,
reviews = reviews.map { it.toDto() },
averageRating = reviews.map { it.rating }.average()
)
}
Тот же функционал, но код читается как обычная функция. Нет flatMap, нет map внутри map. Stack trace показывает реальную бизнес-логику, а не операторы реактивной цепочки. Отладка в IDE работает — можно ставить breakpoints и inspect-ить переменные.
Structured concurrency — ключевое преимущество корутин. Корутина не может «потеряться»: если вы запустили async внутри coroutineScope, scope дождётся завершения всех дочерних корутин (или отменит их при ошибке). В Reactor потерянная подписка — частая причина утечек памяти.
// Параллельный запрос с structured concurrency
suspend fun getOrderWithDetails(orderId: Long): OrderDetailDto = coroutineScope {
val orderDeferred = async { orderRepository.findById(orderId) }
val paymentsDeferred = async { paymentRepository.findByOrderId(orderId) }
val shippingDeferred = async { shippingRepository.findByOrderId(orderId) }
val order = orderDeferred.await() ?: throw OrderNotFoundException(orderId)
OrderDetailDto(
order = order,
payments = paymentsDeferred.await(),
shipping = shippingDeferred.await()
)
}
Три параллельных запроса к БД, а код выглядит как последовательный. Это и есть магия корутин.
Backpressure
Единственная область, где Reactor объективно сильнее — backpressure. Reactive Streams спецификация определяет backpressure на уровне протокола: consumer может сказать producer «замедлись». В корутинах backpressure реализуется через Flow:
// Producer
fun tickerStream(intervalMs: Long): Flow<Tick> = flow {
while (true) {
emit(Tick(Instant.now()))
delay(intervalMs)
}
}.flowOn(Dispatchers.Default)
// Consumer — backpressure через conflate или buffer
tickerStream(1000)
.conflate() // Пропустить промежуточные значения
.collect { tick -> // Consumer определяет скорость
processTick(tick)
}
Flow — холодный поток, и backpressure работает через suspend-функцию collect. Но это менее declarative, чем onBackpressureBuffer или onBackpressureDrop в Reactor. Для сложных stream-processing pipeline Reactor может быть удобнее.
Производительность
Я запустил бенчмарк: 10 000 параллельных запросов, каждый делает 3 HTTP-вызова к внешнему API (200ms latency каждый). JDK 17, 8 vCPU:
Метрика | Coroutines (Ktor) | Reactor (WebFlux)
--------------------------|-------------------|-------------------
Throughput (rps) | 15,200 | 14,800
P99 latency | 620ms | 650ms
P99.9 latency | 780ms | 830ms
Heap usage (под нагрузкой)| 450MB | 520MB
GC pauses (avg) | 12ms | 18ms
CPU utilization | 75% | 78%
Разница незначительна. Обе модели эффективно используют потоки. Корутины чуть лучше по памяти из-за меньшего количества аллокаций (state machine дешевле, чем chain of publishers). Но на практике разница незаметна для большинства приложений.
Интеграция и экосистема
Reactor неразрывно связан с Spring WebFlux. Если ваш проект на Spring Boot — Reactor уже встроен. Spring Data R2DBC, Spring Cloud Gateway, Spring Security — всё работает через Reactor types (Mono/Flux). Смешивать корутины и Reactor в Spring можно (mono.toFuture(), flow.asFlux()), но это добавляет конверсионного бойлерплейта.
Корутины нативно встроены в Kotlin. Любая библиотека может предоставлять suspend-функции: Ktor, Exposed, Kmongo, khttp. Для Retrofit и OkHttp есть корутин-адаптеры. Стандартная библиотека Kotlin (kotlinx.coroutines) даёт Dispatchers, Channels, Flow — полный набор для асинхронного программирования.
Мой вердикт
Если вы начинаете новый проект на Kotlin — coroutines. Код чище, отладка проще, порог входа ниже, structured concurrency защищает от утечек. Корутины — это «нормальный» путь для Kotlin-разработчика, и экосистема всё больше движется в эту сторону.
Если вы в экосистеме Spring и уже используете Spring WebFlux — оставайтесь на Reactor. Миграция на корутины внутри Spring WebFlux не даёт значимых преимуществ, а конверсия между Mono/Flux и корутинами добавляет сложности.
Единственный сценарий, где я бы выбрал Reactor в новом проекте — heavy stream-processing с complex backpressure requirements (real-time data pipeline, event sourcing с миллионами событий в секунду). Но для 95% REST API микросервисов корутины — лучший выбор.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье