Привет, Хабр!

Корутины в Kotlin выглядят обманчиво просто. Пишешь suspend, вызываешь как обычную функцию, запускаешь через launch — и вот у тебя асинхронный код без колбэков и без цепочек Future. На демках всё вроде как хорошо.

Проблемы начинаются на бэкенде под нагрузкой, где корутин много, они вложены друг в друга, живут в рамках запроса и ходят в несколько внешних сервисов сразу. Тут распространяется отмена, летят исключения, разные отмены при падении. И оказывается, что исключение из одного вызова уронило весь запрос, фоновая задача пережила остановку сервиса, а try/catch вокруг launch вообще ничего не поймал.

Разберём семь мест, где это ломается. Всё про серверный код — Ktor, Spring, обработчики запросов, фоновые задачи.

GlobalScope, который переживёт всё

Начнём с того, что стоит запретить себе в первый же день.

fun processOrder(order: Order) {
    GlobalScope.launch {
        notifyWarehouse(order)
        chargePayment(order)
    }
}=
  • GlobalScope не привязан ни к чему. Корутина, запущенная в нём, живёт сама по себе: её нельзя отменить вместе с запросом, она не завершится при остановке приложения, и если внутри вылетит исключение, оно просто исчезнет или уронит что‑нибудь неожиданное.

  • Для бэкенда это прямой путь к утечкам. Запрос обработан, ответ отправлен, а запущенная в GlobalScope корутина всё ещё висит, держит соединения и данные. При остановке сервиса такие корутины обрываются на середине — платёж мог пройти, а уведомление уже нет.

Правильно — привязать работу к области с понятным временем жизни. В обработчике запроса область уже есть, её дают фреймворк или структурные билдеры:

suspend fun processOrder(order: Order) = coroutineScope {
    launch { notifyWarehouse(order) }
    launch { chargePayment(order) }
}
  • coroutineScope не вернёт управление, пока обе корутины не закончатся, и отменит их, если отменят сам запрос. Для фоновых задач, которые должны пережить конкретный запрос, заводят отдельную область на уровне приложения с SupervisorJob — и привязывают её к событию остановки сервиса, чтобы задачи корректно доработали при выключении.

try/catch вокруг launch, который ничего не ловит

Ошибка, которая ломает интуицию сильнее всего.

try {
    scope.launch {
        riskyOperation()
    }
} catch (e: Exception) {
    log.error("поймали", e)      // сюда не попадём
}

launch запускает корутину и немедленно возвращает управление. Код внутри выполнится позже, когда try/catch уже завершится. Исключение вылетит не в том месте, где стоит перехват, а внутри корутины — и catch его не увидит.

Обрабатывать надо внутри корутины:

scope.launch {
    try {
        riskyOperation()
    } catch (e: Exception) {
        log.error("поймали", e)
    }
}

Либо, если это верхнеуровневая корутина, вешать CoroutineExceptionHandler на область.

Этот обработчик работает только для launch и только на верхнем уровне. Для вложенных корутин он не вызывается, исключение оттуда уходит наверх по иерархии, к родителю.

Одно падение уронило все параллельные вызовы

Базовый сценарий: собрать страницу из нескольких источников разом.

suspend fun buildDashboard(userId: Long) = coroutineScope {
    val profile = async { userService.fetch(userId) }
    val orders = async { orderService.fetch(userId) }
    val recommendations = async { recoService.fetch(userId) }

    Dashboard(profile.await(), orders.await(), recommendations.await())
}

Всё хорошо, пока все три сервиса отвечают. Но стоит recoService упасть, и падает весь запрос целиком, включая профиль и заказы, которые уже успешно загрузились. Хотя рекомендации на дашборде вообще не критичны, без них можно показать страницу.

Так работает структурная конкурентность: внутри coroutineScope падение одного ребёнка отменяет всех остальных и пробрасывает исключение наверх. Это правильное поведение по умолчанию, оно не даёт потеряться ошибкам. Но иногда оно не то, что нужно.

Когда падение одной части не должно валить остальные, есть supervisorScope. В нём дети изолированы: упавший ребёнок не тянет за собой соседей.

suspend fun buildDashboard(userId: Long) = supervisorScope {
    val profile = async { userService.fetch(userId) }
    val orders = async { orderService.fetch(userId) }
    val recommendations = async { recoService.fetch(userId) }

    Dashboard(
        profile.await(),
        orders.await(),
        recommendations = runCatching { recommendations.await() }.getOrNull()
    )
}

Здесь падение рекомендаций не тронет профиль и заказы, а runCatching вокруг await превратит их провал в null — дашборд соберётся без них. Обратите внимание: await на упавшем async перебрасывает исключение, поэтому обернуть надо именно его, а не сам блок async.

Разница между coroutineScope и supervisorScope — одно из главных решений на бэкенде: критичные вызовы, без которых ответа нет, оставляют в обычной области; некритичные, которыми можно пожертвовать, изолируют.

async, который проглотил ошибку до await

Продолжение той же темы, но с отдельной ловушкой.

val deferred = scope.async {
    fetchData()          // тут упало
}
// ... и мы не вызвали await()

async устроен так, что исключение внутри него не всплывает сразу — оно сохраняется и пробрасывается только когда вы вызовете await(). Если await() не вызвать вообще, ошибка так и останется внутри Deferred, и вы о ней не узнаете.

Особенно коварно это в связке с supervisorScope, где исключение не уходит наверх автоматически. Получается async, который упал, но молчит, пока кто‑нибудь не дёрнет await(), а если никто не дёрнет, провал просто теряется.

Отсюда правило: результат async всегда должен быть кем‑то забран через await(). Если результат не нужен, а нужен только побочный эффект, это не async, а launch — они для разного. async — когда нужно вернуть значение, launch — когда нужно просто что‑то сделать.

Блокирующий вызов на диспетчере, которого мало

На бэкенде это бьёт по пропускной способности напрямую.

suspend fun loadUser(id: Long): User {
    val row = jdbcTemplate.queryForObject(sql, id)   // блокирующий JDBC
    return row.toUser()
}

Проблема в том, где эта функция выполняется. Если её вызвали на Dispatchers.Default, она заняла один из потоков пула, рассчитанного под задачи, которые не блокируются. JDBC‑запрос блокирует поток целиком, пока база не ответит. Несколько таких вызовов разом и пул исчерпан, а весь остальной код, который тоже сидит на Default, стоит и ждёт.

Dispatchers.Default заточен под вычисления, и потоков в нём примерно по числу ядер. Для блокирующего ввода‑вывода есть отдельный Dispatchers.IO с большим пулом, рассчитанным как раз на то, что потоки будут простаивать в ожидании:

suspend fun loadUser(id: Long): User = withContext(Dispatchers.IO) {
    val row = jdbcTemplate.queryForObject(sql, id)
    row.toUser()
}

withContext переключает выполнение на нужный диспетчер и возвращает результат обратно. Общее правило простое: вычисления идут на Default, блокирующий ввод‑вывод — на IO. Если у вас нормальный асинхронный драйвер (R2DBC вместо JDBC, реактивный HTTP‑клиент), переключение не нужно вовсе, он не блокирует поток.

А вот для любого legacy‑кода с блокирующими вызовами withContext(Dispatchers.IO) обязателен, иначе один медленный запрос к базе положит обработку всех остальных.

withContext, обёрнутый в try/catch не с той стороны

Продолжение темы про диспетчеры, но с ошибкой в обработке.

suspend fun loadUser(id: Long): User? {
    return try {
        withContext(Dispatchers.IO) {
            jdbcTemplate.queryForObject(sql, id).toUser()
        }
    } catch (e: Exception) {
        null                         // база упала — возвращаем null
    }
}

Выглядит аккуратно, но catch (e: Exception) здесь снова проглотит CancellationException, если запрос отменят во время выполнения. Отмена придёт внутрь withContext, вылетит наружу как исключение, catch его поймает и вернёт null — а вызывающий код решит, что всё в порядке, пользователь просто не найден.

На бэкенде это приводит к тому, что отменённый по таймауту запрос не отменяется, а тихо возвращает пустой результат. Проблема та же, что и раньше: CancellationException надо пропускать.

} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    null
}

Это настолько частая ошибка, что стоит выработать рефлекс: любой catch (e: Exception) внутри suspend‑функции должен сначала пропустить отмену. Единственное исключение — самый верхний уровень, где вы сознательно ловите вообще всё перед логированием.

runBlocking внутри suspend‑функции

Ошибка, которую делают, когда не хотят разбираться, как позвать suspend из обычного кода.

fun getUser(id: Long): User = runBlocking {
    userRepository.fetch(id)         // suspend-функция
}

runBlocking запускает корутину и блокирует текущий поток, пока она не завершится. Это мост из обычного кода в мир корутин, и место ему в main, в тестах, изредка в точке входа. В обычном серверном коде, а тем более внутри уже асинхронного стека, он губителен: занимает поток целиком на всё время выполнения, сводя на нет весь смысл корутин.

Особенно опасно вызвать runBlocking из корутины, которая крутится на ограниченном диспетчере. Он заблокирует поток из пула, а если таких вызовов наберётся столько же, сколько потоков, — получите взаимную блокировку: все потоки заняты ожиданием, а разбирать очередь корутин некому.

Если функция должна вызывать suspend‑код, она сама должна быть suspend:

suspend fun getUser(id: Long): User = userRepository.fetch(id)

suspend проще всего протаскивать вверх по стеку до самого края, где фреймворк уже умеет вызывать корутины сам — современные Ktor и Spring WebFlux принимают suspend‑обработчики напрямую, и runBlocking там не нужен вовсе.

Отмена, которую съели вместе с исключением

Место, на котором ломается корректная остановка:

scope.launch {
    try {
        fetchData()
    } catch (e: Exception) {
        log.error("что-то упало", e)      // а тут может быть отмена
    }
}

Когда корутину отменяют, внутри неё бросается CancellationException. Это не ошибка, а служебный сигнал: «пора закругляться». Но CancellationException — обычное исключение, и catch (e: Exception) его радостно ловит. В результате корутина, которую попросили остановиться, вместо этого логирует «ошибку» и продолжает работать как ни в чём не бывало.

Такая корутина не отменяется.

Область ждёт её завершения, а она не завершается. Сервис не останавливается, запрос не отменяется по таймауту, и понять, почему, по логам сложно.

CancellationException нужно пробрасывать дальше:

scope.launch {
    try {
        fetchData()
    } catch (e: CancellationException) {
        throw e                          // отмену пропускаем наверх
    } catch (e: Exception) {
        log.error("что-то упало", e)
    }
}

Порядок catch важен: сначала ловим и пробрасываем отмену, потом всё остальное. Есть и более аккуратный вариант — runCatching в свежих версиях корутин отмену уже не глотает, но с обычным try/catch про порядок надо помнить.

Отмена, которую не проверяют

Обратная сторона: корутину можно попросить остановиться, а она не остановится, если сама не даёт такой возможности.

scope.launch {
    var sum = 0L
    for (i in 0 until 10_000_000_000) {
        sum += heavyCompute(i)           // ни одной точки приостановки
    }
}

Отмена в корутинах кооперативная. Она не прерывает выполнение силой — она поднимает CancellationException в ближайшей точке приостановки, то есть на очередном suspend‑вызове. Если внутри цикла нет ни одного suspend, отменять негде: cancel() поставит флаг, но проверить его некому, и цикл досчитает до конца.

Для тяжёлых вычислений отмену проверяют явно — либо через ensureActive(), либо через yield(), который заодно отдаёт поток другим корутинам:

scope.launch {
    var sum = 0L
    for (i in 0 until 10_000_000_000) {
        ensureActive()                   // бросит CancellationException, если отменили
        sum += heavyCompute(i)
    }
}

Стандартные suspend‑функции из библиотеки (delay, сетевые вызовы, работа с каналами) проверяют отмену сами, поэтому в обычном асинхронном коде об этом думать не надо. Проблема только там, где вы крутите долгий синхронный цикл без единого suspend внутри.

Таймаут, который не срабатывает

На бэкенде таймаут на внешний вызов обязателен, и в корутинах его легко поставить — и так же легко сделать бесполезным.

suspend fun fetchWithTimeout(id: Long): Data? {
    return withTimeoutOrNull(500) {
        blockingHttpCall(id)             // блокирующий вызов
    }
}

withTimeoutOrNull даёт задаче 500 миллисекунд, а по истечении отменяет её и возвращает null. Но отмена в корутинах кооперативная, сработает только на точке приостановки. Если внутри блокирующий вызов без единого suspend, отменять негде: таймаут выйдет, а вызов будет спокойно висеть дальше, пока не ответит сам.

Работает withTimeout только с настоящим асинхронным кодом, где есть точки приостановки:

suspend fun fetchWithTimeout(id: Long): Data? {
    return withTimeoutOrNull(500) {
        httpClient.get("/data/$id")      // suspend-вызов, отменяется корректно
    }
}

Если приходится оборачивать таймаутом блокирующий вызов, его сначала уводят на Dispatchers.IO, но и это не сделает сам вызов отменяемым, он просто перестанет держать основной пул. По‑настоящему прервать блокирующую операцию корутины не могут, ровно как и потоки под капотом.

Есть ещё разница между withTimeout и withTimeoutOrNull: первый при истечении бросает TimeoutCancellationException, второй возвращает null. Первый удобнее, когда таймаут — это ошибка, которую надо обработать выше; второй — когда отсутствие ответа штатно и обрабатывается тут же.

suspend‑функция, которая запускает и забывает

Последнее — про функции, которые молча нарушают структурную конкурентность.

suspend fun handleRequest(req: Request) {
    val scope = CoroutineScope(Dispatchers.IO)
    scope.launch {
        auditLog(req)                    // живёт своей жизнью
    }
    respond(req)
}

Тут внутри suspend‑функции создаётся новая, ни к чему не привязанная область и в ней запускается корутина. По сути это тот же GlobalScope, только замаскированный: handleRequest вернёт управление, не дожидаясь auditLog, и никто эту корутину не отменит и не дождётся. Плюс каждый вызов handleRequest плодит новую область, и они утекают одна за другой.

Признак проблемы простой: если внутри suspend‑функции вы создаёте CoroutineScope(...) руками — почти наверняка что‑то не так. suspend‑функция должна работать в области того, кто её вызвал, а не заводить свою.

Правильно — либо принять область как параметр, либо использовать структурные билдеры, которые наследуют область автоматически:

suspend fun handleRequest(req: Request) = coroutineScope {
    launch { auditLog(req) }
    respond(req)
}

Теперь auditLog привязан к запросу: отменится вместе с ним и завершится до того, как handleRequest вернёт управление. Если же аудит должен пережить запрос и уехать в фон, его запускают в явной долгоживущей области уровня приложения — но именно осознанно, а не случайной CoroutineScope(...) посреди обработчика.

Flow, который собирают не там

Отдельная история про Flow — холодные потоки данных, на которых в бэкенде часто ловят неожиданности.

fun userEvents(): Flow<Event> = flow {
    val subscription = eventBus.subscribe()      // побочный эффект при сборке
    subscription.forEach { emit(it) }
}

Flow холодный: код внутри flow { } не выполняется, пока поток не начнут собирать через collect. Это сбивает с толку — вы вызвали userEvents(), а подписка не создалась, потому что collect ещё не было. И наоборот: если собрать один и тот же Flow дважды, блок выполнится дважды, и подписки будет две.

Вторая частая ошибка с Flow — менять контекст внутри через withContext:

fun loadData(): Flow<Data> = flow {
    withContext(Dispatchers.IO) {                // так нельзя
        emit(fetchFromDb())
    }
}

emit обязан вызываться в том же контексте, где стартовал сбор потока, иначе Flow бросит исключение о нарушении контекста. Для смены диспетчера у потоков есть отдельный оператор:

fun loadData(): Flow<Data> = flow {
    emit(fetchFromDb())
}.flowOn(Dispatchers.IO)                          // меняем контекст правильно

flowOn переключает контекст для всего, что выше него по цепочке, не ломая правило про emit. Это одно из тех мест, где Flow ведёт себя не как обычный suspend‑код, и об этом надо помнить.

Как это держать в голове

Почти все семь ошибок — это разные грани одного принципа, который в корутинах называется структурной конкурентностью. Каждая корутина живёт внутри области, у области есть время жизни, падение ребёнка распространяется вверх, отмена — вниз. Как только вы это нарушаете — заводите GlobalScope, создаёте область руками внутри suspend, глотаете отмену или проглядываете, что launch не бросает наверх, — начинаются висящие задачи и падения не в том месте.

Поэтому набор рабочих привычек простой. Область берётся из контекста, а не создаётся на месте. Отмена всегда пробрасывается наверх, а не ловится вместе с обычными исключениями. Между coroutineScope и supervisorScope выбирают по тому, должна ли одна упавшая часть валить остальные. Блокирующие вызовы уходят на Dispatchers.IO. И launch не бросает исключение туда, где вы ждёте его снаружи, — обрабатывать надо внутри.

Отдельно про тесты: есть библиотека kotlinx-coroutines-test, которая умеет подменять диспетчеры и управлять виртуальным временем. С ней тесты на отмену и таймауты становятся быстрыми и стабильными вместо реального ожидания секунд. Если пишете на корутинах серьёзно, её стоит освоить сразу.

Когда корутины перестают быть локальной деталью и начинают влиять на устойчивость всего сервиса, точечных исправлений уже недостаточно. В этот момент полезно расширить взгляд на Kotlin‑бэкенд: сравнить подходы к нативной компиляции и разобраться, как возможности языка помогают строить выразительные и поддерживаемые решения.

Продолжить разбор темы можно на ближайших открытых уроках:

  • 5 августа в 20:00. «Битва нативных платформ: Spring Boot 4, Quarkus, Micronaut, KMP, Go и Rust». Записаться

  • 19 августа в 20:00. «Kotlin‑магия: строим предметно‑ориентированные языки (DSL) от теории до JsonBuilder». Записаться

Больше бесплатных уроков — в дайджесте мероприятий на август.

Комментарии (0)