Перейти к основному содержимому

Корутины в Kotlin

Разработчику Архитектору

Корутины в Kotlin

Представьте официанта в ресторане — он принял заказ у стола 1, передал его на кухню и сразу идёт к столу 2 — он не стоит у плиты, пока жарится стейк. Корутина в программе похожа на такого официанта: задача "ждёт сеть", но поток ОС в это время может выполнять другую корутину.

Корутины (kotlinx.coroutines) — способ писать асинхронный код на Kotlin линейно (сверху вниз), без глубокой вложенности callback'ов. Один поток JVM может обслуживать тысячи лёгких корутин.

Где это нужно в экосистеме Kotlin:

  • Ktor — обработка HTTP;
  • Android — сеть, база Room;
  • Flow — поток значений во времени.

Сравнение с Java — асинхронность Java (CompletableFuture, virtual threads). Полное сравнение языков — Java и Kotlin.


Словарь терминов

ТерминПростыми словами
Поток (thread)Ресурс ОС: тяжёлый, их обычно десятки–сотни.
КорутинаЛёгкая задача внутри потока; создавать тысячи дешевле.
suspendФункцию можно приостановить без блокировки потока (сеть, delay).
launch"Запустить работу в фоне", результат не возвращает.
asyncЗапустить работу и потом забрать результат через await().
Scope"Граница жизни" корутин: отменили scope — отменились дочерние задачи.
JobРучка отмены одной корутины.
DispatcherНа каком пуле потоков выполнять код (Main, IO, Default).
delayПауза в корутине; поток при этом свободен для других задач.
Главное правило

runBlocking — только в main и тестах.

В Android — lifecycleScope / viewModelScope; в Ktor сервер уже внутри корутинного контекста.


Блокирующий код и suspend

Блокирующий вызов (например Thread.sleep(1000) на UI-потоке Android) "замораживает" экран.

Suspend-функция сообщает компилятору: здесь можно отдать поток другим корутинам:


import kotlinx.coroutines.*

suspend fun fetchTitle(): String {
delay(100) // учебная пауза; в реальности — HTTP, БД
return "Kotlin"
}

Разбор:

  • import kotlinx.coroutines.* подключает API корутин — delay, launch, runBlocking и другие примитивы.
  • suspend fun fetchTitle() объявляет приостанавливаемую функцию, которую можно вызывать только из корутины или другой suspend-функции.
  • : String фиксирует возвращаемый тип результата, что делает контракт функции явным и проверяемым компилятором.
  • delay(100) делает неблокирующую паузу: текущая корутина приостанавливается, но поток не простаивает.
  • return "Kotlin" возвращает итоговое значение после возобновления корутины.

delay(100)не Thread.sleep: корутина приостанавливается, поток живёт.

Вызвать fetchTitle() можно только из другой suspend-функции или из корутины (launch, async, runBlocking).


Первый пример — launch и runBlocking

Код ITЗагрузка примера кода…

Разбор:

СтрокаЧто происходит
fun main() = runBlocking { ... }main блокирует поток до завершения блока — допустимо только здесь и в тестах.
launch { ... }Старт корутины "огонь и забыл"; две корутины могут идти параллельно.
fetchTitle() внутриSuspend: пока delay, другая корутина может работать.

Ожидаемый вывод: строки A: и B: (порядок может меняться — задачи параллельны).


async — два результата и один ответ

Когда нужны оба результата перед продолжением:

suspend fun fetchCount(): Int {
delay(50)
return 42
}

suspend fun loadDashboard(): Pair<String, Int> = coroutineScope {
val title = async { fetchTitle() }
val count = async { fetchCount() }
title.await() to count.await()
}

Разбор:

  • fetchCount() — отдельная suspend-функция, которая имитирует вторую асинхронную операцию с результатом Int.
  • coroutineScope { ... } создаёт структурированную область: все дочерние задачи завершатся до выхода из функции.
  • async { fetchTitle() } и async { fetchCount() } запускают две независимые корутины параллельно и возвращают Deferred.
  • await() в каждой переменной дожидается конкретного результата и пробрасывает ошибку вызывающему коду, если она возникла.
  • title.await() to count.await() собирает оба значения в Pair<String, Int> через инфиксный оператор to.
APIКогда
launchОбновить UI, записать в лог — результат не нужен
asyncПараллельно загрузить два URL и сложить ответы

coroutineScope { }structured concurrency: если одна дочерняя корутина упала с ошибкой, scope отменит остальных.


Dispatchers — "на каком потоке"

withContext(Dispatchers.IO) {
// чтение файла, HTTP
}
withContext(Dispatchers.Main) {
// обновление UI (Android)
}

Разбор:

  • withContext(...) временно переключает корутину на другой CoroutineDispatcher и возвращает её обратно после блока.
  • Dispatchers.IO подходит для I/O-задач — сеть, диск, обращения к базе через блокирующие драйверы.
  • Dispatchers.Main привязывает выполнение к главному UI-потоку, где разрешено менять состояние интерфейса.
  • Такой шаблон разделяет тяжёлую работу и обновление UI, снижая риск подвисания экрана.
DispatcherТипичное использование
Dispatchers.MainUI Android (главный поток)
Dispatchers.IOСеть, диск, БД
Dispatchers.DefaultCPU: парсинг, сортировка больших списков

withContext переключает контекст и возвращается в прежний после блока.


Scope в приложении

Код ITЗагрузка примера кода…

Разбор:

  • CoroutineScope(SupervisorJob() + Dispatchers.IO) создаёт долгоживущий scope для репозитория и изолирует ошибки дочерних задач.
  • SupervisorJob() оставляет соседние корутины живыми, если одна из них завершилась исключением.
  • refresh(...) стартует асинхронную загрузку через scope.launch, не блокируя вызывающий поток.
  • withContext(Dispatchers.Main) переносит колбэк onDone в UI-поток, где безопасно обновлять экран.
  • close() вызывает scope.cancel(), чтобы при уничтожении объекта гарантированно остановить фоновые работы.
ScopeГде
runBlockingmain, простые тесты
coroutineScopeВнутри suspend: дочерние завершатся вместе с родителем
supervisorScopeОшибка в одном child не отменяет соседей
viewModelScopeAndroid ViewModel — отмена при очистке VM
lifecycleScopeПривязка к жизненному циклу экрана

SupervisorJob + scope.cancel() в close() — явная отмена фоновых задач при уничтожении репозитория.


Flow — кратко

Одна корутина — одна задача. Flowмного значений во времени (тикер, обновления из БД):

fun ticker(): Flow<Int> = flow {
var i = 0
while (true) {
emit(i++)
delay(500)
}
}

Разбор:

  • Flow<Int> описывает поток целых значений, которые приходят последовательно во времени.
  • flow { ... } создаёт cold stream: код внутри запускается только при подписке (collect).
  • var i = 0 хранит текущее состояние счётчика между эмитами.
  • emit(i++) отправляет значение подписчику и увеличивает счётчик для следующей итерации.
  • delay(500) делает паузу между событиями, не блокируя поток исполнения.

Подробно: Flow. Для UI-состояния на экране чаще StateFlow — там же.


Обработка ошибок

scope.launch {
try {
risky()
} catch (e: IOException) {
// лог, повтор
}
}

Разбор:

  • scope.launch запускает корутину для побочного эффекта, где результат не нужен как возвращаемое значение.
  • try { risky() } оборачивает потенциально аварийный участок для локальной обработки.
  • catch (e: IOException) перехватывает сетевые и файловые ошибки и позволяет задать fallback-логику.
  • Такой шаблон предотвращает неконтролируемое падение корутины и улучшает наблюдаемость через логирование.

Необработанное исключение в launch можно перехватить через CoroutineExceptionHandler. В async ошибка приходит при await().


Отмена и таймауты

Отмена — центральная часть корутин. Долгая задача должна регулярно проверять isActive или вызывать suspend-операции, которые кооперативно реагируют на отмену.

suspend fun loadWithTimeout(): String = withTimeout(1_000) {
delay(200)
"ok"
}

fun stopScope(scope: CoroutineScope) {
scope.cancel("Экран закрыт")
}

Разбор:

  • withTimeout(1_000) вводит жёсткий лимит выполнения в миллисекундах и автоматически отменяет внутренний блок при превышении.
  • delay(200) имитирует асинхронную операцию, которая корректно реагирует на отмену.
  • Возврат "ok" показывает успешный путь, если задача завершилась в пределах таймаута.
  • stopScope(...) демонстрирует централизованную отмену всех дочерних корутин по событию жизненного цикла.
  • Причина "Экран закрыт" помогает в отладке и логах понять источник отмены.
ИнструментНазначение
withTimeout(ms)Ограничить время операции, иначе TimeoutCancellationException
withTimeoutOrNull(ms)Вернуть null вместо исключения
job.cancel()Ручная отмена задачи
ensureActive()Явная проверка отмены в CPU-циклах

Так вы удерживаете предсказуемое поведение сервиса и UI при медленной сети или закрытии экрана.


Тесты корутин


import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals

@Test
fun loadsTitle() = runTest {
val title = fetchTitle()
assertEquals("Kotlin", title)
}

Разбор:

  • runTest поднимает тестовый корутинный scope с виртуальным временем и управляемым scheduler.
  • @Test помечает функцию как тестовый сценарий для JUnit 5.
  • val title = fetchTitle() показывает прямой вызов suspend-функции внутри теста без runBlocking.
  • assertEquals("Kotlin", title) проверяет бизнес-контракт функции и документирует ожидаемый результат.
  • Такой формат делает тесты быстрыми и детерминированными даже при наличии delay в коде.

runTest — виртуальное время: delay в тестах не ждёт реальные миллисекунды. Зависимость: kotlinx-coroutines-test.


Сравнение с Java

JavaKotlin
CompletableFuture.supplyAsyncasync / suspend
ExecutorServiceDispatchers.IO
Virtual threads (Java 21+)Корутины + явные suspend в API

Частые ошибки

СимптомПричина
UI "замирает"Thread.sleep или блокирующий HTTP на Main
Утечка корутинScope живёт дольше экрана; нет cancel()
GlobalScope.launch в UIЖивёт дольше Activity — используйте scope жизненного цикла
Вызов suspend из fun без suspendНужен launch / runBlocking / другая suspend-обёртка

Что попробовать

  1. Два launch в runBlocking — убедиться, что A и B выводятся параллельно.
  2. async + awaitAll — "загрузить" три искусственных delay.
  3. Flow + Room — список из БД на экране.

Практикум — параллельная загрузка

Сценарий из практикума. suspend-функция download имитирует сеть через delay.

Последовательно

Каждый download в forEach дожидается конца предыдущего — задержки складываются.

suspend fun download(url: String, delayMs: Long): String {
delay(delayMs)
return "Данные с $url"
}

suspend fun loadAllSequential(urls: List<Pair<String, Long>>) {
val start = System.currentTimeMillis()
urls.forEach { (url, ms) -> download(url, ms) }
println("Последовательно: ${System.currentTimeMillis() - start} мс")
}

Параллельно — async и awaitAll

Внутри coroutineScope каждый URL получает свою async-задачу; awaitAll ждёт все сразу.

suspend fun loadAllParallel(urls: List<Pair<String, Long>>) = coroutineScope {
val start = System.currentTimeMillis()
urls.map { (url, ms) -> async { download(url, ms) } }.awaitAll()
println("Параллельно: ${System.currentTimeMillis() - start} мс")
}
  • async — запуск с возможностью получить результат позже;
  • awaitAll — дождаться списка Deferred;
  • coroutineScope — отмена всех дочерних при ошибке одной.

Полный листинг:

Код ITЗагрузка примера кода…

Сравнение с Java CompletableFutureтаблица в статье. Общий практикум — раздел "Асинхронность".


Дальше

Flow · тесты · Ktor · Java async


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.