Корутины в Kotlin
Корутины в Kotlin
Представьте официанта в ресторане — он принял заказ у стола 1, передал его на кухню и сразу идёт к столу 2 — он не стоит у плиты, пока жарится стейк. Корутина в программе похожа на такого официанта: задача "ждёт сеть", но поток ОС в это время может выполнять другую корутину.
Корутины (kotlinx.coroutines) — способ писать асинхронный код на Kotlin линейно (сверху вниз), без глубокой вложенности callback'ов. Один поток JVM может обслуживать тысячи лёгких корутин.
Где это нужно в экосистеме Kotlin:
Сравнение с 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.Main | UI Android (главный поток) |
Dispatchers.IO | Сеть, диск, БД |
Dispatchers.Default | CPU: парсинг, сортировка больших списков |
withContext переключает контекст и возвращается в прежний после блока.
Scope в приложении
Код ITЗагрузка примера кода…
Разбор:
CoroutineScope(SupervisorJob() + Dispatchers.IO)создаёт долгоживущий scope для репозитория и изолирует ошибки дочерних задач.SupervisorJob()оставляет соседние корутины живыми, если одна из них завершилась исключением.refresh(...)стартует асинхронную загрузку черезscope.launch, не блокируя вызывающий поток.withContext(Dispatchers.Main)переносит колбэкonDoneв UI-поток, где безопасно обновлять экран.close()вызываетscope.cancel(), чтобы при уничтожении объекта гарантированно остановить фоновые работы.
| Scope | Где |
|---|---|
runBlocking | main, простые тесты |
coroutineScope | Внутри suspend: дочерние завершатся вместе с родителем |
supervisorScope | Ошибка в одном child не отменяет соседей |
viewModelScope | Android 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
| Java | Kotlin |
|---|---|
CompletableFuture.supplyAsync | async / suspend |
ExecutorService | Dispatchers.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-обёртка |
Что попробовать
- Два
launchвrunBlocking— убедиться, что A и B выводятся параллельно. async+awaitAll— "загрузить" три искусственныхdelay.- 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 как основа веб-интеграций.