Корутины — это способ писать асинхронный код так, будто он обычный, последовательный. Без колбэков, без ручного переключения потоков, без «а что если пользователь ушёл с экрана раньше, чем пришёл ответ». Вместо этого — функция, помеченная suspend, которая умеет приостановиться и продолжиться там, где остановилась.
Эта страница — карта темы целиком: от первой корутины до реактивной архитектуры Android-приложения. Каждый раздел объясняет суть и ведёт в подробный разбор. Читать можно подряд или нырять в нужное место через оглавление.
Оглавление
- В каком порядке изучать
- Что такое корутина
- Билдеры: launch, async, runBlocking
- Структурированная конкурентность
- Контекст корутины
- Диспетчеры
- Job, жизненный цикл и отмена
- Обработка исключений
- Тестирование корутин
- Каналы: когда поток не подходит
- Холодные потоки Flow
- Горячие потоки: StateFlow и SharedFlow
- Операторы: управление потоком
- Тестирование потоков
- Где запускать корутины в Android
- Flow в Jetpack Compose
- Room: база обновляет интерфейс сама
- Retrofit: сеть и состояния
- Flow, LiveData или RxJava
- Семь частых ошибок
- Вопросы и ответы
В каком порядке изучать
Если вы начинаете, порядок важнее полноты. Темы ниже выстроены так, что каждая следующая опирается на предыдущую, и перепрыгивать не стоит — почти все мучения с корутинами происходят от того, что к операторам Flow переходят раньше, чем разобрались с отменой.
- Основы. Что такое suspend, три билдера, структурированная конкурентность. Пока не станет понятно, кто кому родитель и что происходит при отмене, дальше идти бессмысленно.
- Контекст и диспетчеры. Где выполняется код и как его переключать.
- Job, отмена, исключения. Самая болезненная часть и источник большинства багов в продакшене.
- Холодные потоки. Flow, подписка, почему он выполняется заново на каждого подписчика.
- Горячие потоки. StateFlow как состояние экрана, SharedFlow как события, stateIn и shareIn.
- Операторы. Только теперь — map, debounce, flatMapLatest и остальные.
- 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.