Привет! Меня зовут Артём, я бэкенд-разработчик. Долгое время я жил в мире 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.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье