Корутины в Kotlin: полное руководство

Корутины — это способ писать асинхронный код так, будто он обычный, последовательный. Без колбэков, без ручного переключения потоков, без «а что если пользователь ушёл с экрана раньше, чем пришёл ответ». Вместо этого — функция, помеченная suspend, которая умеет приостановиться и продолжиться там, где остановилась.

Эта страница — карта темы целиком: от первой корутины до реактивной архитектуры Android-приложения. Каждый раздел объясняет суть и ведёт в подробный разбор. Читать можно подряд или нырять в нужное место через оглавление.

Оглавление

В каком порядке изучать

Если вы начинаете, порядок важнее полноты. Темы ниже выстроены так, что каждая следующая опирается на предыдущую, и перепрыгивать не стоит — почти все мучения с корутинами происходят от того, что к операторам Flow переходят раньше, чем разобрались с отменой.

  1. Основы. Что такое suspend, три билдера, структурированная конкурентность. Пока не станет понятно, кто кому родитель и что происходит при отмене, дальше идти бессмысленно.
  2. Контекст и диспетчеры. Где выполняется код и как его переключать.
  3. Job, отмена, исключения. Самая болезненная часть и источник большинства багов в продакшене.
  4. Холодные потоки. Flow, подписка, почему он выполняется заново на каждого подписчика.
  5. Горячие потоки. StateFlow как состояние экрана, SharedFlow как события, stateIn и shareIn.
  6. Операторы. Только теперь — map, debounce, flatMapLatest и остальные.
  7. Android. Где запускать, Compose, Room, Retrofit, тестирование.

Что такое корутина

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

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

Ключевое слово suspend само по себе ничего не запускает. Оно лишь говорит компилятору: эта функция может приостановиться, поэтому вызывать её разрешено только из другой suspend-функции или из корутины.

suspend fun loadUser(id: Long): User {
    val response = api.getUser(id)   // приостановка, поток свободен
    return response.toUser()
}

Подробный разбор: Корутины Kotlin: быстрый старт.

Под капотом это преобразование в стиле передачи продолжения: компилятор разрезает suspend-функцию по точкам приостановки и складывает «что делать дальше» в объект Continuation. Когда ответ пришёл, выполнение возобновляется с сохранённого места — возможно, уже на другом потоке. Знать это устройство в деталях для работы не обязательно, но оно объясняет, почему корутины дёшевы: вместо стека потока хранится маленький объект состояния.

Отсюда же важное следствие: приостановка — не блокировка. Заблокированный поток стоит и ничего не делает, приостановленная корутина поток отпускает. Именно поэтому Thread.sleep(1000) внутри корутины — ошибка, а delay(1000) — нет.

Билдеры: launch, async, runBlocking

Корутину создаёт билдер. Их три, и путать их — самая частая ошибка новичка.

  • launch — запустить и забыть. Возвращает Job, результата не отдаёт. Для действий: сохранить, отправить, обновить.
  • async — запустить и получить результат. Возвращает Deferred, значение забирается через await(). Нужен, когда две операции должны идти параллельно, а результат нужен от обеих.
  • runBlocking — мост из обычного кода в корутинный, блокирует поток до конца работы. Место ему в main() и в тестах, в Android-коде он почти всегда ошибка.
// параллельно, время = максимум из двух, а не сумма
val user = async { api.getUser(id) }
val posts = async { api.getPosts(id) }
render(user.await(), posts.await())

Структурированная конкурентность

Это главная идея библиотеки и то, чем корутины отличаются от голых потоков. Каждая корутина запускается в области видимости — CoroutineScope — и становится ребёнком той корутины, внутри которой создана.

Из этого следуют три правила. Родитель не завершится, пока не завершились все дети. Отмена родителя отменяет всех детей. Ошибка в ребёнке по умолчанию валит родителя и остальных детей.

Практический смысл: утечь корутина не может. Нет «забытой» фоновой работы, которая продолжает жить после закрытия экрана и падает с NullPointerException на мёртвой вьюхе. Когда область видимости умирает, умирает всё, что в ней запущено.

Собрать несколько задач в одну область помогают coroutineScope (ошибка ребёнка отменяет всю группу) и supervisorScope (дети падают независимо).

suspend fun loadScreen(): Screen = coroutineScope {
    val user = async { api.getUser() }
    val feed = async { api.getFeed() }
    Screen(user.await(), feed.await())
}   // не вернётся, пока не завершатся обе; упала одна — отменится вторая

Такая функция безопасна в любом окружении: она не оставляет после себя работающих задач ни при успехе, ни при ошибке, ни при отмене снаружи. Это и есть структурированность — время жизни задач повторяет структуру кода.

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

CoroutineContext — набор настроек, с которыми живёт корутина. Это Job (её жизненный цикл), диспетчер (на каком потоке выполняться), имя для отладки и обработчик исключений.

Контекст наследуется от родителя и достраивается элементами, которые передали билдеру. Внутри корутины контекст меняют через withContext — он переключает выполнение и возвращает результат, дождавшись завершения блока.

viewModelScope.launch {                 // Dispatchers.Main
    val data = withContext(Dispatchers.IO) {
        repository.load()                // фоновый поток
    }
    state.value = data                   // снова главный поток
}

Диспетчеры

Диспетчер решает, на каком потоке исполняется код корутины.

  • Dispatchers.Main — главный поток Android, только он имеет право трогать интерфейс.
  • Dispatchers.IO — пул для ожидания: сеть, файлы, база. Потоков много, потому что они в основном простаивают.
  • Dispatchers.Default — пул под нагрузку на процессор, размером с число ядер: разбор больших ответов, сортировка, вычисления.
  • Dispatchers.Unconfined — особый случай, в прикладном коде не нужен.

Важная деталь, которую легко упустить: выбор диспетчера — ответственность функции, а не вызывающего. Хорошо написанный репозиторий сам оборачивает тяжёлую работу в withContext(Dispatchers.IO), и его можно спокойно вызывать с главного потока.

Job, жизненный цикл и отмена

Job — ручка управления корутиной: состояние, ожидание через join(), отмена через cancel(). Отмена в корутинах кооперативная: библиотека не убивает код насильно, она ставит отметку, а код должен её заметить.

Все suspend-функции из kotlinx.coroutines проверяют отметку сами и бросают CancellationException. А вот длинный цикл вычислений про отмену не знает — её нужно проверять руками через isActive, ensureActive() или yield().

CancellationException — не ошибка, а штатный сигнал. Ловить его в try/catch и глушить нельзя: так корутина становится неотменяемой. Если нужно освободить ресурсы — try/finally.

viewModelScope.launch {
    try {
        while (isActive) {          // проверяем отмену сами
            tick()
            delay(1000)             // а эта функция проверит за нас
        }
    } finally {
        releaseResources()          // выполнится и при отмене
    }
}

Частая ошибка — catch (e: Exception) вокруг тела корутины: он поймает и CancellationException, и корутина перестанет отменяться. Либо ловите конкретные типы, либо пробрасывайте отмену дальше.

Обработка исключений

Здесь интуиция подводит чаще всего. Обернуть launch в try/catch снаружи бесполезно: билдер возвращает управление сразу, а исключение возникает позже и уходит вверх по дереву корутин — к родителю, потом к обработчику области видимости.

Правила простые. Внутри launch ловите там же, внутри корутины. У async исключение хранится в Deferred и вылетает при await() — значит, try/catch ставится вокруг await(). CoroutineExceptionHandler работает только в корне области видимости и только для launch.

Чтобы падение одной задачи не уносило соседей, область строится на SupervisorJob или на supervisorScope.

// так НЕ работает
try {
    scope.launch { risky() }
} catch (e: Exception) { }      // сюда ничего не прилетит

// так работает
scope.launch {
    try { risky() } catch (e: IOException) { showError() }
}

// а здесь — вокруг await
try { deferred.await() } catch (e: IOException) { showError() }

Тестирование корутин

Асинхронный код тестируется без Thread.sleep и без флаки-тестов. Библиотека kotlinx-coroutines-test даёт виртуальное время: задержки не ждут по-настоящему, а проматываются.

Точка входа — runTest. Внутри работает тестовый диспетчер, delay(10_000) проходит мгновенно, а advanceUntilIdle() догоняет всю отложенную работу. Для кода, завязанного на главный поток, подменяется Dispatchers.setMain.

@Test
fun loads() = runTest {
    viewModel.load()
    advanceUntilIdle()
    assertEquals(expected, viewModel.state.value)
}

Каналы: когда поток не подходит

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

В прикладном Android-коде каналы нужны редко — почти всё, для чего их когда-то брали, сегодня лучше решается через SharedFlow. Но понимать их стоит: горячие потоки построены на той же механике, и слова «буфер», «переполнение», «conflated» приходят именно отсюда.

Холодные потоки Flow

Корутина возвращает одно значение. Когда значений много и они приходят во времени — нужен Flow.

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

Это и достоинство, и ловушка. Достоинство — ничего лишнего не происходит без потребителя. Ловушка — в Compose, где рекомпозиция может пересоздать подписку и дёрнуть сеть по второму кругу.

Подробный разбор: Ныряем в холодные потоки Kotlin Flow.

Подробный разбор: Холодный Flow в Android: ловушка для Compose-разработчика.

Горячие потоки: StateFlow и SharedFlow

Горячий поток живёт независимо от подписчиков и раздаёт одни и те же значения всем сразу.

StateFlow — это состояние: в нём всегда есть текущее значение, новый подписчик немедленно его получает, одинаковые значения подряд отсекаются автоматически. Идеальный тип для состояния экрана во ViewModel.

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

Превратить холодный поток в горячий помогают stateIn и shareIn. Их третий аргумент, SharingStarted.WhileSubscribed(5000), — рабочая лошадка Android: поток живёт, пока есть подписчики, и ещё пять секунд после, чтобы пережить поворот экрана.

Подробный разбор: StateFlow как состояние экрана.

Подробный разбор: stateIn() и shareIn(): как подогреть холодный Flow для UI.

class FeedViewModel(repo: Repo) : ViewModel() {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    // или сразу из холодного источника
    val feed = repo.observeFeed()
        .map(::toUiState)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
}

Операторы: управление потоком

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

  • map, filter, transform — базовые преобразования.
  • debounce — не реагировать, пока пользователь не перестал печатать. distinctUntilChanged — не повторяться. Вместе они убирают большую часть лишних запросов в поиске.
  • flatMapLatest и семейство *Latest — отменить обработку устаревшего значения, как только пришло новое. Ровно то, что нужно живому поиску.
  • combine и zip — склеить несколько потоков в один.
  • catch, retry, onEach — ошибки, повторы и побочные эффекты.
  • flowOn — сменить контекст всей части цепочки выше по течению.

Подробный разбор: Управление потоком в Kotlin Flow.

Подробный разбор: debounce, distinctUntilChanged и filter.

Подробный разбор: flatMapLatest и семейство *Latest.

Тестирование потоков

Собрать поток в список и сравнить — работает только для конечных потоков. Состояние экрана бесконечно, поэтому такой тест просто повиснет.

Для этого есть Turbine: он подписывается на поток и даёт забирать значения по одному — awaitItem(), — проверяя каждое. Тест читается как сценарий: подписались, получили загрузку, получили данные, больше ничего не пришло.

Где запускать корутины в Android

Готовых областей видимости две, и выбор между ними определяет, переживёт ли работа поворот экрана.

viewModelScope живёт, пока жива ViewModel, и поворот переживает. Сюда идёт загрузка данных. lifecycleScope привязан к активности или фрагменту и умирает вместе с ними — сюда идёт то, что к интерфейсу и привязано.

Отдельная история — сбор потока во время, когда экран не виден. Подписка должна останавливаться на фоне, иначе приложение продолжает работать вхолостую: для этого есть repeatOnLifecycle(Lifecycle.State.STARTED).

Подробный разбор: lifecycleScope vs viewModelScope.

Flow в Jetpack Compose

В Compose поток превращается в состояние и сам вызывает рекомпозицию. Правильный способ — collectAsStateWithLifecycle(): он, в отличие от collectAsState(), прекращает сбор, когда экран уходит в фон.

Запускать корутины прямо в composable-функции нельзя — она выполняется много раз. Для этого есть LaunchedEffect (запускается при входе в композицию и при смене ключа) и rememberCoroutineScope (для запуска из обработчиков событий).

Подробный разбор: LaunchedEffect и rememberCoroutineScope.

@Composable
fun FeedScreen(viewModel: FeedViewModel = viewModel()) {
    val state by viewModel.feed.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {          // один раз при входе в композицию
        viewModel.refresh()
    }

    when (state) { /* Loading, Success, Error */ }
}

Room: база обновляет интерфейс сама

Если DAO возвращает Flow, Room сам пришлёт новый список при любом изменении таблицы. Схема «запрос — ответ — обновить вручную» исчезает: вы пишете в базу, а экран обновляется потому, что подписан на неё.

База становится единственным источником правды, а сеть — тем, что её наполняет. Отсюда один шаг до приложения, которое работает офлайн.

Подробный разбор: Room + Flow: когда база данных сама обновляет интерфейс.

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages ORDER BY date DESC")
    fun observeAll(): Flow<List<Message>>      // Room обновит сам

    @Insert
    suspend fun insert(message: Message)
}

Retrofit: сеть и состояния

Retrofit понимает suspend из коробки — метод интерфейса просто помечается им и возвращает результат. Сложность не в запросе, а в трёх состояниях, которые повторяются на каждом экране: загрузка, успех, ошибка.

Их удобно упаковать в sealed-класс и один раз написать обёртку, вместо того чтобы в каждом экране разводить флаги isLoading и isError.

Подробный разбор: NetworkResult: оборачиваем сетевой запрос в sealed-класс.

Flow, LiveData или RxJava

LiveData — это про жизненный цикл и почти ничего больше: одно значение, автоматическая отписка, никаких операторов. В новом коде её заменяет StateFlow вместе с collectAsStateWithLifecycle() или repeatOnLifecycle, которые дают ту же безопасность.

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

Flow сегодня — выбор по умолчанию для нового Android-кода. RxJava остаётся там, где на ней уже написан большой проект: переписывать работающее ради моды смысла нет, а вот новые экраны разумно делать на Flow.

Семь ошибок, которые встречаются чаще всего

  • Thread.sleep вместо delay. Первый блокирует поток целиком, второй приостанавливает одну корутину.
  • GlobalScope. Запущенное в нём не привязано ни к чему и живёт до конца процесса — ровно та утечка, от которой спасает структурированная конкурентность. В Android у вас всегда есть viewModelScope или lifecycleScope.
  • try/catch снаружи launch. Не ловит ничего, см. раздел про исключения.
  • Проглоченный CancellationException. Делает корутину неотменяемой и возвращает все утечки обратно.
  • withContext ради каждой мелочи. Переключение контекста не бесплатно; suspend-функция должна сама отвечать за свой диспетчер, а вызывающему об этом думать не нужно.
  • collectAsState вместо collectAsStateWithLifecycle. Экран ушёл в фон, а поток продолжает собираться и жечь батарею.
  • Сбор холодного потока в двух местах. Два подписчика — два запроса к серверу. Лечится переводом в горячий через stateIn.

Шесть из семи — это не про незнание синтаксиса, а про непонимание модели: кто кому родитель, кто живёт сколько и что происходит при отмене. Поэтому раздел про структурированную конкурентность стоит перечитать раньше, чем список операторов.

Вопросы и ответы

Корутина — это поток?

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

Чем launch отличается от async?

launch запускает работу и не возвращает результата, async возвращает Deferred с результатом. Если результат не нужен — launch; async оправдан там, где несколько операций идут параллельно.

Почему try/catch вокруг launch не ловит исключение?

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

Почему мой Flow выполняется дважды?

Потому что он холодный: каждая новая подписка запускает его заново. Если источник должен быть один на всех — переведите его в горячий через stateIn или shareIn.

StateFlow или LiveData?

В новом коде — StateFlow. Он не привязан к Android, тестируется обычными средствами корутин и работает вместе с остальными операторами Flow.

Нужен ли runBlocking в Android?

Почти никогда. Он блокирует поток, а если это главный поток — приложение замирает. Его место в main() консольных программ и в тестах.

Куда дальше

Эта страница — карта. Подробности живут в разборах, на которые она ссылается, а связный путь от первой корутины до готового приложения — в моей книге «Асинхронный Kotlin»: 320 страниц, сквозной проект — мессенджер для Android, исходники открыты.

Если ближе формат с задачами и проверкой, есть два курса на Stepik: Корутины в Kotlin — теория без привязки к платформе, и Android-разработка с нуля — практика с Compose, Room и Retrofit.