Корутиной называют вычисление, которое умеет приостанавливаться: в точке приостановки она отпускает поток и продолжается позже, возможно, уже на другом потоке. Поток является ресурсом ОС с собственным стеком; приостановленная корутина хранится как небольшой объект в куче, поэтому 100 000 корутин помещаются на нескольких потоках.
Стоимость: поток резервирует около 1 МБ стека и место в планировщике ОС; приостановленная корутина хранит только нужные локальные переменные и метку, откуда продолжить.
Приостановка, а не блокировка: delay(1_000) освобождает поток для других корутин, Thread.sleep(1_000) держит его. На главном потоке первое оставляет UI отзывчивым, второе замораживает его и грозит ANR.
Кооперативность: корутина отдаёт поток только в точке приостановки; плотный CPU-цикл без приостановок держит поток, как обычный код.
Структурность: каждая корутина живёт в скоупе, поэтому отмена и ошибки идут по дереву родителей и потомков, а не управляются вручную.
AndroidMiddle
Корутины Kotlin
Suspend-функции, scope, dispatcher-ы, структурная конкурентность, отмена, обработка исключений
Закрыто вопросов
0 / 17
- 0Повторить
- 9Часто спрашивают
Вопросы
runBlocking запускает корутину и блокирует текущий поток, пока она не завершится; GlobalScope запускает корутины без родителя, которые живут столько же, сколько процесс. Первое замораживает поток, на котором вызвано, второе даёт утечку работы, которую никто не может отменить.
**runBlocking на главном потоке**: блокирует UI, а если коду внутри нужен главный поток для продолжения, получается deadlock. Он для main() и редких мостов к блокирующим API; в тестах берут runTest.
**GlobalScope**: у него нет Job, поэтому запрос продолжает выполняться после ухода с экрана, держит на него ссылки и может писать в уже мёртвый UI. Поэтому API помечено @DelicateCoroutinesApi.
Что использовать вместо: viewModelScope или lifecycleScope для работы экрана, внедрённый скоуп уровня приложения CoroutineScope(SupervisorJob() + Dispatchers.Default) для работы, которая должна пережить экран, и WorkManager для работы, которая должна пережить процесс.
launch запускает работу «выстрелил и забыл» и возвращает Job; async вычисляет значение и возвращает Deferred<T>, которое ждут через await(). Ошибка в launch сразу уходит родителю; в async она ещё и сохраняется в Deferred и пробрасывается из await().
**launch**: для побочных эффектов: сохранить, залогировать, собрать flow. Необработанное исключение поднимается до корня и попадает в CoroutineExceptionHandler или в обработчик крэшей.
**async**: для нужных результатов, обычно нескольких параллельно. await() приостанавливается, пока значение не готово.
Ловушка: try/catch вокруг await() недостаточно. Упавший async, который является потомком обычного Job, всё равно отменяет родителя ещё до того, как дело дойдёт до await(). Оборачивайте в try/catch весь coroutineScope { } или используйте supervisorScope.
**Корневой async**: держит исключение до await().
Оба по умолчанию стартуют сразу; start = CoroutineStart.LAZY ждёт start() или await().
Structured concurrency (структурированная конкурентность) означает, что каждая корутина стартует внутри скоупа и становится потомком его Job: родитель не завершается, пока не завершатся потомки, отмена родителя отменяет их, а ошибка потомка сообщается родителю. Ничто не переживает скоуп, которому принадлежит.
Нет утечек: viewModelScope отменяется в onCleared(), поэтому все запросы, запущенные из него, останавливаются, когда экрана больше нет.
Нет потерянных ошибок: упавший потомок отменяет соседей, а родитель пробрасывает исключение, вместо того чтобы оно исчезло где-то на фоновом потоке.
Предсказуемое завершение: coroutineScope { } возвращается только после завершения всех потомков, поэтому suspend-функция не может оставить работу, продолжающуюся после её возврата.
Билдеры скоупов: coroutineScope (потомки падают вместе), supervisorScope (потомки падают независимо), withContext (сменить контекст и дождаться результата).
Что ломает структуру: GlobalScope или CoroutineScope(Job()), созданный на месте и никогда не отменяемый.
CoroutineContext устроен как индексированный набор элементов, которые настраивают корутину; CoroutineScope является интерфейсом с одним контекстом и ресивером для launch и async, поэтому новые корутины его наследуют. Типичные элементы: Job, диспетчер, CoroutineExceptionHandler, CoroutineName.
**Job: жизненный цикл и связь между родителем и потомком; отмена идёт через него.
CoroutineDispatcher: на каком потоке или пуле выполняется корутина.
CoroutineExceptionHandler: последний рубеж для необработанных исключений в корневых корутинах.
CoroutineName: метка для логов и отладки.
Сложение**: контексты объединяются через +, по каждому ключу побеждает правая часть. Новая корутина получает контекст скоупа, плюс переданные аргументы, плюс новый Job, родителем которого становится Job скоупа.
**ThreadLocal**: не следует за корутиной между потоками; threadLocal.asContextElement(value) кладёт его в контекст, и он восстанавливается при каждом продолжении.
Dispatchers.Main нужен для UI, Dispatchers.Default для CPU-нагрузки (столько потоков, сколько ядер), Dispatchers.IO для блокирующих вызовов (по умолчанию до 64 потоков). Default и IO делят один пул потоков, поэтому переход между ними часто даже не меняет поток.
**Main.immediate**: выполняет блок сразу, если вы уже на главном потоке, а не отправляет его в Looper. Его используют viewModelScope и lifecycleScope, чтобы обновление стейта не откладывалось на кадр.
**Default: парсинг, сортировка, диффы, обработка изображений. Если заблокировать его I/O, вся остальная CPU-работа приложения голодает.
IO: файловые потоки, JDBC, старые блокирующие SDK. Лишние потоки у него есть потому, что большую часть времени они просто ждут.
limitedParallelism(n)**: обёртка над диспетчером, которая выполняет не более n корутин одновременно на пуле родителя. Dispatchers.IO.limitedParallelism(1) сериализует доступ к одному ресурсу; для n больше 64 вид получает собственный лимит потоков.
**Unconfined**: выполняется в потоке вызывающего до первой приостановки, затем там, где продолжился; для тестов и редких пограничных случаев.
Suspend-библиотеки вроде Retrofit и Room уже main-safe, оборачивать их в withContext(Dispatchers.IO) не нужно.
suspend помечает функцию, которая может приостановиться, не блокируя поток. Компилятор переписывает её в стиле continuation-passing: скрытый параметр Continuation, результат либо COROUTINE_SUSPENDED, а тело становится машиной состояний с состоянием на каждую точку приостановки.
Скрытый параметр: suspend fun user(id: Long): User превращается примерно в fun user(id: Long, cont: Continuation<User>): Any?. Поэтому suspend-функцию можно вызвать только из другой suspend-функции или из билдера корутин.
Машина состояний: локальные переменные, нужные после приостановки, сохраняются полями объекта-continuation вместе с label, который говорит, откуда продолжить. Один небольшой объект на вызов, а не поток.
Приостановка: когда вызываемая функция возвращает COROUTINE_SUSPENDED, все фреймы вверх по цепочке тоже возвращаются, и поток свободен. Позже колбэк вызывает resumeWith(result); диспетчер в роли ContinuationInterceptor планирует продолжение на своём потоке, и машина состояний переходит к сохранённой метке.
Потоки сами не меняются: suspend сам по себе ничего не уводит с главного потока. Suspend-функция с блокирующей работой всё равно блокирует; переключают потоки withContext и диспетчеры.
withContext выполняет один блок в другом контексте и приостанавливается до его возврата, то есть работает последовательно. async запускает конкурентного потомка: сначала запустите все, потом вызывайте await. Три async внутри coroutineScope { } занимают столько, сколько самый медленный вызов, а не сумму.
**withContext**: один результат на другом диспетчере, например withContext(Dispatchers.Default) { parse(json) }. async { }.await() в одну строку делает то же самое, только хуже.
Незаметная последовательность выполнения: async { a() }.await(), а за ним async { b() }.await() выполняются друг за другом. Параллельность появляется, только если оба запущены до первого await.
Один вызов упал: внутри coroutineScope первое исключение отменяет два других и пробрасывается из скоупа, так что ни один запрос не работает ради результата, который никому не нужен. Для списка есть сокращение awaitAll().
Частичные результаты: если экран может отрисоваться без одной части, используйте supervisorScope, чтобы соседи выжили, и ловите исключение вокруг await() этой части.
Нет. launch только планирует корутину и сразу возвращается; исключение выбрасывается позже, внутри корутины, когда блок try уже завершён. Оно уходит в скоуп и без обработчика роняет приложение.
Ловите внутри корутины: ставьте try/catch вокруг самого suspend-вызова, там, где исключение действительно возникает.
Распространение: необработанное исключение в потомке отменяет родителя и соседей и поднимается до корня. В viewModelScope, где SupervisorJob, падает только эта корневая корутина, но исключение всё равно остаётся необработанным.
**CoroutineExceptionHandler**: вызывается только для необработанных исключений корневой корутины (launch прямо в скоупе или прямой потомок супервизора). Установленный на вложенном потомке, он игнорируется, потому что потомок передаёт исключение родителю. Для async он не срабатывает никогда, потому что там исключение ждёт await().
Обработчик нужен для логирования, а не для восстановления: к моменту его вызова корутина уже завершена.
Отмена: пробрасывайте CancellationException из любого широкого catch.
cancel() только помечает Job как отменяемый; код останавливается в ближайшей точке приостановки вроде delay или withContext, которая проверяет job и выбрасывает CancellationException. Код, который не приостанавливается, продолжает работать, как и код, который ловит это исключение и идёт дальше.
CPU-циклы: вызывайте ensureActive(), проверяйте isActive или вызывайте yield(), который заодно даёт поработать другим корутинам.
Блокирующие вызовы: Thread.sleep или блокирующее чтение игнорируют отмену; runInterruptible { } превращает отмену в прерывание потока.
**Ловушка runCatching**: runCatching { } и catch (e: Exception) ловят и CancellationException, поэтому корутина продолжает работать после ухода с экрана. Ловите его первым и пробрасывайте.
Очистка: finally выполняется при отмене, но любой suspend-вызов в нём сразу бросает исключение. Оборачивайте suspend-очистку в withContext(NonCancellable) { }.
Таймауты: withTimeout отменяет блок и бросает TimeoutCancellationException; withTimeoutOrNull вместо этого возвращает null.
viewModelScope живёт до onCleared(), lifecycleScope живёт, пока не уничтожены Activity или Fragment, а repeatOnLifecycle(STARTED) запускает свой блок на STARTED и отменяет его на STOPPED. launchWhenStarted блок только приостанавливал, поэтому апстрим продолжал производить данные в фоне.
**viewModelScope**: SupervisorJob() + Dispatchers.Main.immediate; переживает поворот экрана; место для загрузки данных и хранения стейта.
**lifecycleScope**: привязан к владельцу. Во Fragment используйте viewLifecycleOwner.lifecycleScope, потому что Fragment живёт дольше своего view.
Классический баг: lifecycleScope.launch { flow.collect { } } в onCreate собирает до onDestroy. Flow сокета или геолокации продолжает доставлять данные, пока приложение в фоне, и UI обновляется, когда его никто не видит.
**repeatOnLifecycle(STARTED)**: сбор останавливается в onStop, поэтому апстрим с WhileSubscribed тоже отключается, и перезапускается в onStart. flowWithLifecycle делает то же для одного flow.
**launchWhenStarted: приостанавливал сборщик, но оставлял корутину живой, поэтому продюсеры продолжали работать и копить данные; объявлен устаревшим в Lifecycle 2.6.
Compose**: collectAsStateWithLifecycle() даёт то же поведение.
Для одного результата берите suspendCancellableCoroutine: запустите вызов, возобновите continuation из колбэка и отмените исходный вызов в invokeOnCancellation. Для потока колбэков берите callbackFlow: отправляйте каждое значение через trySend и отписывайте слушателя в awaitClose.
Возобновлять ровно один раз: повторный resume бросает IllegalStateException; защищайтесь от API, которые могут вызвать колбэк дважды.
Отмена: без invokeOnCancellation отмена корутины оставляет запрос и его колбэк живыми. Обычный suspendCoroutine отменить нельзя, и он редко бывает нужен.
**awaitClose обязателен**: без него блок callbackFlow сразу завершается и flow падает с IllegalStateException; заодно именно там удаляется слушатель.
**trySend**: не приостанавливается и безопасен с любого потока; переполнение настраивается через .buffer() или .conflate() на результате.
Сначала поищите готовый адаптер: kotlinx-coroutines-play-services даёт Task.await(), а Retrofit напрямую поддерживает suspend-методы.
counter++ означает «прочитать, изменить, записать», а Dispatchers.Default выполняет эти корутины на нескольких потоках одновременно, и обновления перезаписывают друг друга. Исправляется атомиком, Mutex или привязкой состояния к одному потоку.
Не делите состояние: пусть каждая корутина возвращает результат, а вы их объединяете: map { async { } }.awaitAll().sum().
Атомики: AtomicInteger.incrementAndGet() для одного числа; MutableStateFlow.update { it + 1 } для стейта (это цикл compare-and-set). state.value = state.value + 1 теряет обновления так же, как counter++.
**Mutex.withLock: когда несколько полей должны меняться вместе. Он приостанавливает, а не блокирует, но не реентерабелен: повторный захват внутри блока даёт deadlock.
Привязка к потоку**: все записи через Dispatchers.Default.limitedParallelism(1) или только на главном потоке.
**synchronized**: вызвать из корутины короткий не-suspend метод с synchronized можно; приостановка внутри блока synchronized даёт ошибку компиляции.
**@Volatile**: делает запись видимой другим потокам, но не атомарной.
Корутины дают код, выглядящий последовательным, отмену, привязанную к скоупам, и обычный try/catch. RxJava зрелая и дружелюбная к Java, с более богатым набором операторов, но Disposable приходится контролировать вручную, а порог входа выше.
Flow и Rx-потоки: Flow покрывает большую часть того, что делали Rx-потоки, меньшим числом более простых операторов.
Жизненный цикл: в Rx нужен CompositeDisposable.clear() в правильный момент; в корутинах отмена скоупа останавливает всё.
Ошибки: в Rx onError завершает поток; в корутинах обычный try/catch, а во Flow catch и retry.
Потоки: subscribeOn / observeOn против flowOn плюс собственного контекста сборщика.
Пулы потоков: Rx Schedulers.io() использует неограниченный кэширующий пул, который может разрастись до сотен потоков, а computation() по числу ядер. В корутинах Default (по ядрам) и IO (по умолчанию 64) делят один пул, поэтому переход между ними часто обходится без смены потока.
Соответствие типов: Single / Maybe / Completable становятся suspend-функциями, Observable / Flowable превращаются в Flow, BehaviorSubject в StateFlow, PublishSubject в SharedFlow.
Миграция: kotlinx-coroutines-rx3 (asFlow(), rxSingle { }) позволяет переводить кодовую базу слой за слоем. Рекомендации Google для Android ставят корутины на первое место.
Под SupervisorJob или supervisorScope упавший потомок не отменяет ни родителя, ни соседей. Но Job, переданный в launch, становится родителем новой корутины вместо job скоупа, поэтому scope.launch(SupervisorJob()) создаёт сироту, до которой scope.cancel() не дотягивается.
Что меняет супервизия
С обычным Job ошибка потомка отменяет родителя, а тот отменяет всех остальных потомков.
Под супервизором ошибка остаётся у потомка, поэтому он должен обработать её сам (try/catch или CoroutineExceptionHandler), иначе она считается необработанной.
viewModelScope и lifecycleScope используют SupervisorJob, поэтому один упавший запрос не останавливает остальные.
Супервизор следит только за прямыми потомками; ошибка уровнем глубже отменяет своего родителя как обычно.
Почему переданный Job всё ломает
launch(context) объединяет контекст скоупа с вашим, и Job из результата становится родителем. Обычно это job скоупа.
Передайте новый SupervisorJob(), и родителем станет он. У него нет собственного родителя, поэтому никто его не ждёт и не отменяет, и работа утекает за пределы ViewModel или Activity. К тому же он ничего не супервизирует: у новой корутины под ним нет соседей.
Любой другой элемент передавать безопасно: диспетчер, CoroutineName, обработчик. withContext(NonCancellable) намеренно использует тот же механизм, поэтому ему место только в finally.
Исправление
supervisorScope { } внутри корутины или скоуп, созданный один раз как CoroutineScope(SupervisorJob() + dispatcher): там супервизором служит собственный job скоупа, и scope.cancel() по-прежнему достаёт каждого потомка.
Да, упадёт. async стартует сразу и, как потомок launch, сообщает об ошибке родителю немедленно, не дожидаясь await(). launch отменяется, лента так и не показывается, а необработанное IOException доходит до обработчика крэшей.
**Потомок или корневой async**: правило «исключение ждёт await()» действует только для корневого async, запущенного прямо в скоупе, или для прямого потомка супервизора. Там, если никто не ждёт, ошибка молча теряется, а это ещё хуже.
Исправление для необязательного значения: ловите ожидаемое исключение внутри блока async и возвращайте null или запускайте необязательную часть в supervisorScope и ловите исключение вокруг её await().
Вывод: async, результат которого никто не ждёт, оказывается замаскированным launch. Если результат не нужен, используйте launch и обрабатывайте ошибки внутри.
validToken() держит Mutex и вызывает refresh(), который пытается захватить его повторно. Mutex не реентерабельный, поэтому корутина приостанавливается в ожидании самой себя: первый же вызов без токена или с истёкшим токеном приводит к deadlock, и все последующие вызовы навсегда встают в очередь за ней.
Почему это тихо: withLock приостанавливает, а не блокирует, поэтому ни один поток не завис: нет ANR, нет крэша, нет заблокированного потока в thread dump. Запросы просто никогда не завершаются (столько, сколько живёт синглтон TokenProvider).
Почему он не реентерабельный: Mutex не привязан к потоку, потому что корутина после каждой приостановки может продолжиться на другом потоке. Он не может отличить владельца от чужого, как это делают synchronized или ReentrantLock.
Исправление: вынесите работу в приватный refreshLocked(), который рассчитывает, что блокировка уже взята, и захватывайте мьютекс только в публичных точках входа. Публичный refresh(), например из обработчика 401, по-прежнему берёт блокировку и остаётся последовательным.
Как поймать в следующий раз: тест на runTest, вызывающий validToken() с истёкшим токеном, зависает до таймаута теста. В отладочном коде withLock(owner) превращает повторный захват в мгновенное IllegalStateException: «This mutex is already locked by the specified owner».