Корутины
Корутина — это не поток. Это приостанавливаемое вычисление, которое компилятор Kotlin превращает в конечный автомат и мультиплексирует на потоки через диспетчер. Поток — это ресурс операционной системы: стек около мегабайта, планирование ядром, переключение контекста в микросекундном масштабе. «Стек» же корутины — это объект в куче (тот самый Continuation), поэтому их помещаются миллионы, а переключение между ними — обычный возврат из функции. Ключ ко всей теме — что делает приостановка: она освобождает поток, а не блокирует его. Приостановленная корутина отдаёт поток обратно в пул, и тот берёт другую работу. Именно поэтому один поток крутит сотни корутин, и именно поэтому блокирующий вызов внутри корутины — самый частый боевой баг: Thread.sleep, синхронный JDBC-запрос или любой блокирующий IO удерживает поток заложником и морит диспетчер голодом, тогда как delay() ту же паузу отдаёт даром.
На этом различии — «приостановиться или заблокировать» — держится вся тема. suspend не магическое слово рантайма, а трансформация компилятора: он добавляет скрытый параметр Continuation (continuation-passing style) и переписывает тело в машину состояний, где каждая точка приостановки — отдельный label. Билдеры launch и async запускают корутину и по-разному сообщают об ошибке; withContext не создаёт новую корутину, а лишь переключает контекст; runBlocking блокирует поток и уместен только в main и тестах. Каждая корутина рождается в scope и становится ребёнком его Job — это структурная конкурентность, гарантия того, что ни одна корутина не переживёт владельца и не утечёт (в отличие от GlobalScope). А разделяемое состояние в приостанавливаемом коде защищают Mutex, а не synchronized, потому что монитор нельзя удерживать через точку приостановки. Разбор по слоям — ниже.
Карта темы
- Корутина против потока — почему корутина не владеет потоком, чем её
Continuationв куче отличается от мегабайтного стека потока и почему приостановка освобождает поток, а блокирующий вызов — нет. - suspend и Continuation — как компилятор переписывает
suspend-функцию в конечный автомат с передачей продолжения и почемуsuspendCancellableCoroutine— правильный мост из callback-API. - Билдеры корутин —
launchпротивasync, где всплывает исключение каждого, почемуrunBlockingтолько дляmainи тестов и как получить настоящий параллелизм от двухasync. - CoroutineContext и withContext — контекст как неизменяемый набор элементов, наследование с заменой
JobиwithContextкак последовательное переключение диспетчера без новой корутины. - Диспетчеры —
Main/IO/Default/Unconfined, почемуIOэластично растёт, аDefaultдержит по потоку на ядро, и почемуIOне делает блокирующий код неблокирующим. - Структурная конкурентность — три гарантии scope,
coroutineScopeпротивsupervisorScope,viewModelScope/lifecycleScopeи почемуGlobalScope— канонная утечка. - Mutex и разделяемое состояние — почему
synchronizedнельзя держать через приостановку,Mutex.withLockкак приостанавливающий и нереентерабельный лок и безлоковые альтернативы.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Называть корутину «легковесным потоком» и считать, что она владеет одним | Корутина не владеет потоком — диспетчер мультиплексирует её на потоки пула, и после приостановки она может продолжиться на другом |
Думать, что suspend блокирует или паркует поток на время ожидания | Приостановка освобождает поток; заложником его держит только настоящий блокирующий вызов (Thread.sleep, синхронный JDBC) |
Полагать, что suspend-функция всегда возобновляется на начавшем её потоке | После приостановки её возобновляет Continuation — возможно, на другом потоке пула |
Считать, что само слово suspend делает блокирующий вызов неблокирующим | Нет; блокирующий вызов нужно унести на IO/Default через withContext |
Ждать, что сбой async всплывёт без вызова await() | Deferred держит исключение до await() — хотя родителю оно всё равно уходит и отменяет соседей |
Считать, что async ленив и стартует только на await() | По умолчанию он стартует сразу; лениво — только при CoroutineStart.LAZY |
Ждать параллелизма от async { … }.await() в той же строке | Это два последовательных вызова; сначала запустите оба билдера, затем ждите оба |
Думать, что runBlocking приостанавливается, как withContext | Он блокирует вызывающий поток; место ему в main и тестах, но не в проде и не на Main |
Думать, что withContext создаёт новую корутину, как launch | Новой корутины нет: он приостанавливает текущую, выполняет блок и возвращает результат |
Думать, что дочерняя корутина разделяет Job родителя | Она наследует контекст, но получает собственный новый Job — именно так строится дерево |
Считать CoroutineContext изменяемой map по строковым ключам | Это неизменяемый индексированный набор Element, объединяемый через + — один элемент на ключ |
Выполнять блокирующий ввод-вывод на Dispatchers.Default | Занятый поток морит голодом CPU-корутины; блокирующее — на IO |
Считать, что Dispatchers.IO делает блокирующий код неблокирующим | Нет; он лишь даёт поток, которому разрешено блокироваться, и растит их пул |
Думать, что любой launch уносит тело с главного потока | launch не меняет диспетчер; на viewModelScope тело останется на UI-потоке и даст ANR |
Считать GlobalScope безобидным для короткой работы | Он не привязан ко времени жизни — его корутины никогда не отменяются; это канонная утечка |
| Думать, что корутина отменяется, когда её объект-владелец собран сборщиком мусора | GC тут ни при чём — корутину отменяет только отмена её scope |
Удерживать synchronized через точку приостановки | Корутина может возобновиться на другом потоке и попытаться отпустить монитор, который не брала |
Считать Mutex реентерабельным, как монитор JVM | Он не реентерабелен; повторный withLock того же Mutex даёт deadlock |
Значение для собеседований
Корутины — раздел, где интервьюер проверяет не словарь, а модель исполнения. Вопрос «чем корутина отличается от потока» — не про определения: правильный ответ звучит как «корутина не поток, а приостанавливаемое вычисление, которое компилятор превратил в конечный автомат и мультиплексирует на потоки; приостановка освобождает поток, а не блокирует его». Тот же приём применяют ко всей теме: что физически делает компилятор с suspend-функцией, что происходит с потоком в момент приостановки, почему launch и async по-разному сообщают об ошибке и в какой ровно момент scope отменяет своих детей. Практические задачи — «почему два async идут две секунды», «почему viewModelScope.launch замораживает экран», «сделайте счётчик потокобезопасным» — проверяют ту же модель под другим углом.
Что обычно проверяют:
- Чем корутина отличается от потока и почему их можно запустить миллионы.
- Что компилятор делает с
suspend-функцией и почему приостановка не блокирует поток. launchпротивasync: что возвращают и где всплывает исключение каждого.- Почему
runBlockingуместен вmainи тестах, но не в продакшене. - Что лежит в
CoroutineContextи как ребёнок его наследует; чемwithContextотличается отlaunch. - Когда брать
Main,IOилиDefaultи почемуIOдержит куда больше потоков, чем ядер. - Что такое структурная конкурентность и почему
GlobalScope— утечка. - Почему разделяемое состояние защищают
Mutex, а неsynchronized, и чтоMutexне реентерабелен.
Типичный неверный ответ: «корутина — это лёгкий поток, а suspend делает вызов неблокирующим». Обе половины ложны и ведут к боевым багам. Корутина не поток и не владеет потоком — она приостанавливаемое вычисление, которое диспетчер мультиплексирует на потоки пула. А suspend сам по себе ничего не делает неблокирующим: он лишь позволяет функции приостанавливаться, но если внутри неё стоит Thread.sleep или синхронный драйвер, поток всё равно будет удержан. Кандидат, поверивший в этот миф, кладёт блокирующий вызов на Dispatchers.Default (или прямо на Main) и морит пул голодом, а на просьбу «сделать вызов неблокирующим» просто дописывает suspend к сигнатуре вместо withContext(Dispatchers.IO) вокруг блокирующего тела.