Классическая проблема серверного кода: поток, ожидающий ответа от базы или соседнего сервиса, занят — он ничего не делает, но держит мегабайты стека. Тысячи одновременных запросов — тысячи занятых потоков. Kotlin отвечает корутинами, Java 21 — virtual threads. Идея одна, механика разная — разберём обе и сравним честно.
suspend: функция, которая умеет ждать бесплатно
suspend fun loadUser(id: String): User =
httpClient.get("/users/$id").body()
suspend fun loadProfile(id: String): Profile {
val user = loadUser(id) // «ожидание» без блокировки потока
val orders = loadOrders(user.id)
return Profile(user, orders)
}
Модификатор suspend означает: функция может приостановиться, отпустив поток, и продолжиться позже — возможно, на другом потоке. Код при этом читается как обычный последовательный — никаких колбэков и цепочек thenCompose, как с CompletableFuture. Приостановка происходит в точках вызова других suspend-функций; правило вежливости то же, что у потоков: внутри suspend-функции нельзя звать блокирующие API без обёртки.
Тысячи корутин живут на пуле из нескольких потоков: приостановилась одна — поток тут же исполняет другую.
Structured concurrency: у задач есть родитель
suspend fun loadDashboard(id: String): Dashboard = coroutineScope {
val user = async { loadUser(id) }
val stats = async { loadStats(id) }
Dashboard(user.await(), stats.await()) // параллельно, но в рамках scope
}
coroutineScope не завершится, пока не завершатся все запущенные внутри корутины. Упала одна — отменяются остальные, ошибка поднимается наверх. Это ответ на вечную проблему «потерянных» фоновых задач: у каждой корутины есть родитель, и утечь незамеченной она не может. Отмена — встроенная: отменился родитель (клиент закрыл соединение) — отменилось всё дерево.
Диспетчеры задают, где исполняться: Dispatchers.IO — для блокирующего ввода-вывода, Dispatchers.Default — для вычислений. Смена — одной функцией withContext(Dispatchers.IO) { ... }.
Virtual threads: тот же выигрыш без нового синтаксиса
Java 21 решает ту же проблему иначе: virtual threads — потоки, которыми управляет JVM, а не операционная система. Блокирующий вызов на виртуальном потоке не держит системный поток — JVM «отстёгивает» его сама. Код остаётся обычным блокирующим, suspend-модификаторы не нужны.
| Корутины | Virtual threads | |
|---|---|---|
| Как достигается дешёвое ожидание | компилятор переписывает suspend-функции | JVM паркует виртуальный поток при блокировке |
| Изменения в коде | suspend в сигнатурах, scope, диспетчеры | почти нет: старый блокирующий код |
| Structured concurrency | встроена и зрелая | появляется в JDK, пока моложе |
| Отмена задач | встроена в модель | прерывание потоков, как раньше |
| Где живёт | Kotlin (и Android) | любой JVM-язык с Java 21+ |
Честный вывод: если цель — только «не держать потоки на ожидании» в Spring-приложении, virtual threads дают это почти бесплатно. Корутины выигрывают там, где нужна модель целиком: structured concurrency, отмена, потоки данных (Flow), Android. В Kotlin-проекте на Spring Boot оба пути рабочие — и они не исключают друг друга.
Практические правила
suspendзаражает сигнатуры вверх по стеку — как checked exceptions: звать suspend можно только из suspend или из scope. Планируйте границу «синхронный мир ↔ корутины» осознанно.- Не смешивайте блокирующие вызовы (JDBC, старые клиенты) с
Dispatchers.Default— заворачивайте вDispatchers.IO. GlobalScope— почти всегда ошибка: корутина без родителя, та самая утечка, против которой придумана structured concurrency.- Основы конкурентности никуда не делись: гонки, видимость изменений, неизменяемость — фундамент тот же.
Коротко
- Корутины — дешёвые единицы конкурентности: suspend-функция приостанавливается, отпуская поток; тысячи корутин живут на маленьком пуле.
- Structured concurrency: у корутины всегда есть родитель, scope ждёт детей, ошибки и отмена распространяются по дереву — фоновые задачи не теряются.
- Virtual threads из Java 21 решают ту же проблему без изменения кода — JVM паркует виртуальный поток при блокирующем вызове.
- Выбор: только убрать «дорогие ожидания» — хватит virtual threads; нужна модель с отменой, scope и Flow — корутины.
- Блокирующие вызовы — в
Dispatchers.IO,GlobalScope— не использовать.
Что почитать дальше
- Kotlin и Spring Boot — последний шаг маршрута.
- Многопоточность со Spring на практике — пулы, @Async и virtual threads в Java-проекте.
- Многопоточность и конкурентность — фундамент: память, гонки, синхронизация.