Загрузка
Все статьи

Kotlin'овые суспензии: приложенное исполнение и загадочный suspend — как я начал понимать

Когда я впервые увидел слово suspend перед функцией в Kotlin, я был уверен, что это какая-то магия компилятора. Оказалось, что за этим стоит принципиально иной подход к асинхронности. В этой статье разбираю, как работают корутины под капотом, зачем нужен Continuation, и почему State Machine — это не страшный монстр, а ваш друг.

Привет! Меня зовут Артём, я бэкенд-разработчик. Долгое время я жил в мире PHP, где асинхронность — это редкость, а если и нужна, то через очереди или curl_multi. Когда проекты начали требовать высоконагруженных микросервисов, я перешёл на Kotlin, и первым камнем преткновения стали корутины. А точнее — ключевое слово suspend.

В этой статье я хочу рассказать о том, как я наконец-то осознал, что такое приложенное исполнение (applicative execution) в корутинах, и почему оно кардинально отличается от всего, что я видел в PHP и JavaScript.

Что на самом деле делает suspend?

Когда вы пишете suspend fun fetchUser(): User, компилятор Kotlin не делает ничего магического в рантайме. Он берёт вашу функцию и преобразует её в конечный автомат (State Machine). Представьте, что ваш код — это книга, и компилятор нарезает её на главы по каждому вызову suspend-функции. Каждая глава — это состояние, и когда корутина «приостанавливается», она запоминает, на какой главе остановилась.

Вот упрощённый пример того, что видит компилятор:

suspend fun fetchUserAndOrders(userId: String): UserData {
    val user = apiClient.fetchUser(userId) // точка приостанова 1
    val orders = apiClient.fetchOrders(userId) // точка приостанова 2
    return UserData(user, orders)
}

После компиляции (упрощённо) это превращается примерно в следующее:

fun fetchUserAndOrders(userId: String, continuation: Continuation<UserData>): Any? {
    val sm = continuation as? FetchUserAndOrdersSM ?: FetchUserAndOrdersSM(continuation)
    when (sm.label) {
        0 -> {
            sm.label = 1
            return apiClient.fetchUser(userId, sm)
        }
        1 -> {
            sm.user = result as User
            sm.label = 2
            return apiClient.fetchOrders(userId, sm)
        }
        2 -> {
            sm.orders = result as List<Order>
            return UserData(sm.user, sm.orders)
        }
    }
}

Заметьте: параметр continuation появляется автоматически. Это объект, который хранит состояние корутины и позволяет ей возобновиться с нужного места. Вы его не передаёте — компилятор делает это за вас. Но если вы вызывали Kotlin suspend-функцию из Java, вы увидите этот Continuation в сигнатуре — и поймёте, что магия закончилась.

Applicative Execution — приложенное исполнение

Теперь к самому интересному. Когда я начал писать асинхронный код, я делал так:

// Sequential execution - BAD
val user = apiClient.fetchUser(userId)
val orders = apiClient.fetchOrders(userId)
val stats = apiClient.fetchStats(userId)

Эти три запроса не зависят друг от друга, но выполняются последовательно. На производстве, когда latency до API составляет 200мс, это значит 600мс вместо потенциальных 200мс. Для high-load сервиса это катастрофа.

В JavaScript я бы написал Promise.all([...]). В Kotlin аналогом является async из kotlinx.coroutines. Но есть нюанс — корутины используют так называемое приложенное исполнение. Это означает, что корутина не привязана к конкретному потоку. Она может начать работу на потоке A, приостановиться, а возобновиться на потоке B.

// Parallel execution - GOOD
val userDeferred = async { apiClient.fetchUser(userId) }
val ordersDeferred = async { apiClient.fetchOrders(userId) }
val statsDeferred = async { apiClient.fetchStats(userId) }

val user = userDeferred.await()
val orders = ordersDeferred.await()
val stats = statsDeferred.await()

Ключевой момент: async запускает корутину, но не блокирует текущий поток. Вы получаете Deferred<T> — отложенный результат. Когда вы вызываете await(), корутина приостанавливается до получения результата, но поток при этом освобождается и может выполнять другую работу.

Dispatchers — кто управляет потоками?

Корутина сама по себе не знает о потоках. Для переключения контекста используются Dispatchers. В продакшене я всегда явно указываю диспетчер:

withContext(Dispatchers.IO) {
    // I/O operations - network, files, databases
    val data = httpClient.get("https://api.example.com/data")
}

withContext(Dispatchers.Default) {
    // CPU-intensive tasks - JSON parsing, calculations
    val parsed = parseLargeJson(rawData)
}

Нюанс, который я усвоил кровью: если вы забудете указать Dispatchers.IO для сетевого запроса, корутина может выполниться на Dispatchers.Main (если вы в Android) или на потоке пула по умолчанию, и тогда вы можете получить блокировку. Особенно это коварно в Spring Boot, где дефолтный контекст — Dispatchers.Default, и переключение на Dispatchers.IO нужно делать вручную.

Структурированная конкурентность — почему try/catch не работает так, как вы ожидаете

Ещё одна грабля: в корутинах исключения ведут себя иначе. Если вы запустите корутину через launch и она упадёт, исключение не вылезет в текущий скоуп автоматически. Оно попадёт в CoroutineExceptionHandler, если он есть, иначе — в uncaughtExceptionHandler потока.

val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
    val data = apiClient.fetchData()
    process(data)
}
// Code continues executing! Exception is "lost" in logs

Используйте SupervisorJob, когда хотите, чтобы падение одной дочерней корутины не отменяло остальные. И всегда оборачивайте await() в try/catch при работе с async, потому что await() пробрасывает исключение напрямую в вызывающий код, в отличие от launch, который откладывает его обработку.

Корутины — мощный инструмент, но порог вхождения высок. Если вы приходите из синхронного мира PHP, как я, дайте себе время. Начните с простых suspend-функций, поймите State Machine на примерах, и только потом переходите к сложным конкурентным сценариям с async и channels.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин