Корутины
Приостанавливаемые вычисления на JVM — чем корутина отличается от потока, suspend как передача продолжения, билдеры launch/async/runBlocking, структурная конкурентность и CoroutineScope, CoroutineContext, диспетчеры Main/IO/Default и Mutex вместо synchronized.
21 вопросов
JuniorТеорияОчень частоКогда выбирать Dispatchers.Main, Dispatchers.IO или Dispatchers.Default?
Когда выбирать Dispatchers.Main, Dispatchers.IO или Dispatchers.Default?
Диспетчер решает, на каком потоке выполняется корутина. Main — единственный UI-поток, трогать интерфейс можно только там. IO — большой эластичный пул для блокирующего ввода-вывода: файлы, сокеты, вызовы базы. Default — пул под CPU-нагрузку, размером в число ядер.
Типичные ошибки
- ✗Выполнять блокирующий ввод-вывод на
Defaultи морить голодом CPU-задачи - ✗Думать, что
IOиDefaultотличаются лишь приоритетом планирования - ✗Трогать состояние UI из фонового диспетчера
Уточняющие вопросы
- →Почему
Dispatchers.IOможет держать намного больше потоков, чем ядер у машины? - →Что произойдёт, если тяжёлый CPU-цикл выполнится на
Dispatchers.Main?
JuniorТеорияОчень частоЧем launch и async отличаются по возвращаемому значению и по тому, как сообщают об ошибке?
Чем launch и async отличаются по возвращаемому значению и по тому, как сообщают об ошибке?
launch возвращает Job — запустил и забыл; если его тело бросит исключение, оно немедленно уходит родителю. async возвращает Deferred<T> с результатом и удерживает исключение до вызова await() — именно там сбой и перебрасывается.
Типичные ошибки
- ✗Ждать, что сбой
asyncвсплывёт без вызоваawait() - ✗Думать, что
launchможет вернуть значение вызывающему - ✗Считать, что
asyncблокирует вызывающего до готовности результата
Уточняющие вопросы
- →Почему
asyncвнутриsupervisorScopeвсё равно бросает исключение наawait()у вызывающего? - →Когда
launch— правильный билдер, даже если результат тоже нужен?
JuniorТеорияОчень частоЧто даёт CoroutineScope и что такое структурная конкурентность?
Что даёт CoroutineScope и что такое структурная конкурентность?
CoroutineScope владеет Job, который становится родителем каждой запущенной в нём корутины. Это и есть структурная конкурентность: дети привязаны к времени жизни, scope дожидается их, а его отмена отменяет всех — поэтому ни одна корутина не переживает владельца и не утекает.
Типичные ошибки
- ✗Думать, что scope — лишь группирующая метка без собственного времени жизни
- ✗Считать, что запущенный ребёнок выживет после отмены своего scope
- ✗Полагать, что
GlobalScopeдаёт те же гарантии, что и владеемый scope
Уточняющие вопросы
- →Чем
coroutineScope { }отличается от создания объектаCoroutineScope? - →Почему
GlobalScope— это утечка внутри AndroidViewModel?
JuniorТеорияОчень частоЧем корутина отличается от потока и почему их можно запустить миллионы?
Чем корутина отличается от потока и почему их можно запустить миллионы?
Корутина — не поток: это приостанавливаемое вычисление, которое компилятор превращает в конечный автомат. Приостановка освобождает поток, а не блокирует его, поэтому один поток крутит множество корутин. Каждая стоит небольшого объекта в куче, а не стека в мегабайт — отсюда миллионы.
Типичные ошибки
- ✗Называть корутину легковесным потоком и считать, что она владеет потоком
- ✗Думать, что
suspendблокирует или паркует текущий поток на время ожидания - ✗Полагать, что каждая корутина выделяет собственный стек вызовов
Уточняющие вопросы
- →Что генерирует компилятор, чтобы хранить локальное состояние
suspend-функции? - →Почему блокирующий вызов внутри корутины всё равно вреден?
JuniorТеорияЧастоЧем Deferred<T> отличается от Future, на котором приходится блокироваться?
Чем Deferred<T> отличается от Future, на котором приходится блокироваться?
Deferred<T> — это Job, который вдобавок несёт результат. Читают его приостанавливающей await(): она освобождает поток до готовности значения, тогда как Future.get() блокирует вызывающий поток. Deferred — ребёнок своего scope и отменяется вместе с ним.
Типичные ошибки
- ✗Считать, что
await()блокирует поток так же, какFuture.get() - ✗Думать, что
Deferredотвязан от создавшего его scope - ✗Полагать, что
asyncленив и стартует лишь на первомawait()
Уточняющие вопросы
- →Что меняет
async(start = CoroutineStart.LAZY)? - →Почему два
Deferred, ожидаемые один за другим, всё равно выполняются параллельно?
MiddleТеорияЧастоЧто лежит в CoroutineContext и как дочерняя корутина его наследует?
Что лежит в CoroutineContext и как дочерняя корутина его наследует?
CoroutineContext — это индексированный набор элементов: обычно Job, диспетчер, CoroutineName и CoroutineExceptionHandler. Ребёнок наследует контекст родителя, но получает собственный новый Job, а всё переданное билдеру перекрывает унаследованный элемент с тем же ключом.
Типичные ошибки
- ✗Думать, что ребёнок разделяет экземпляр
Jobродителя, а не получает новый - ✗Считать контекст изменяемой map, в которую ребёнок может писать
- ✗Полагать, что аргумент билдера игнорируется, раз у родителя уже есть контекст
Уточняющие вопросы
- →Почему
+двух контекстов оставляет лишь один элемент на ключ? - →Что сломается, если ребёнок переиспользует
Jobродителя вместо нового?
MiddleТеорияЧастоПочему состояние в приостанавливаемом коде защищают через Mutex, а не через synchronized?
Почему состояние в приостанавливаемом коде защищают через Mutex, а не через synchronized?
synchronized блокирует поток и не должен удерживаться через точку приостановки: корутина может возобновиться на другом потоке и отпустить монитор, который никогда не брала. Mutex.withLock вместо этого приостанавливается. Он не реентерабелен — вложенный захват даёт deadlock.
Типичные ошибки
- ✗Удерживать блок
synchronizedчерез приостанавливаемый вызов - ✗Считать
Mutexреентерабельным, как монитор JVM - ✗Думать, что
Mutex.withLockблокирует нижележащий поток на время ожидания
Уточняющие вопросы
- →Когда однопоточный диспетчер — лучшее решение, чем
Mutex? - →Что гарантирует
Mutex.withLock, если корутину отменили внутри него?
MiddleТеорияЧастоПочему runBlocking уместен в main и в тестах, но не в продакшен-коде корутин?
Почему runBlocking уместен в main и в тестах, но не в продакшен-коде корутин?
runBlocking блокирует вызывающий поток, пока не завершатся его тело и все дети — это мост из блокирующего кода в мир корутин, и именно он нужен в main и в тесте. Внутри кода корутин он тратит поток, ради освобождения которого существует приостановка, а на Main замораживает интерфейс.
Типичные ошибки
- ✗Думать, что
runBlockingприостанавливается, а не блокирует вызывающего - ✗Использовать
runBlockingвнутриsuspend-функции, чтобы вызвать другую такую же - ✗Вызывать
runBlockingна UI-потоке, чтобы быстро получить результат
Уточняющие вопросы
- →Какой deadlock могут вызвать вложенные
runBlockingна однопоточном диспетчере? - →Почему в юнит-тестах корутин предпочитают
runTest, а неrunBlocking?
MiddleТеорияЧастоЧто компилятор делает с suspend-функцией и почему приостановка не блокирует поток?
Что компилятор делает с suspend-функцией и почему приостановка не блокирует поток?
Компилятор переписывает suspend-функцию в конечный автомат и добавляет скрытый параметр Continuation — это continuation-passing style. Каждая точка приостановки — состояние: приостановка сохраняет состояние, выходит из функции и освобождает поток, а Continuation позже возобновляет её, возможно на другом потоке.
Типичные ошибки
- ✗Думать, что
suspendблокирует или паркует несущий поток на время ожидания - ✗Считать, что
suspend-функция всегда возобновляется на начавшем её потоке - ✗Полагать, что само слово
suspendделает блокирующий вызов неблокирующим
Уточняющие вопросы
- →Почему
suspend-функцию можно вызвать только из другой такой же или из билдера? - →Что хранит
Continuation, кроме точки возобновления?
MiddleТеорияЧастоПочему для переноса работы на другой диспетчер используют withContext, а не launch?
Почему для переноса работы на другой диспетчер используют withContext, а не launch?
withContext не создаёт новую корутину: он приостанавливает текущую, выполняет блок на заданном диспетчере и возобновляет её с результатом, поэтому он последователен и возвращает значение. launch же запускает конкурентного ребёнка и возвращает Job, а не результат.
Типичные ошибки
- ✗Думать, что
withContextзапускает новую корутину, какlaunch - ✗Считать, что смена диспетчера сохраняется после выхода из блока
withContext - ✗Брать
launch, когда вызывающему нужно значение и он обязан его дождаться
Уточняющие вопросы
- →Почему
withContext(Dispatchers.IO)внутриsuspend-функции делает её безопасной дляMain? - →Что делает
withContext, если целевой диспетчер совпадает с текущим?
SeniorДизайнЧастоВ пул-реквесте вы находите GlobalScope.launch в десятке мест — во ViewModel для загрузки экрана, в репозитории для прогрева кеша и в Activity для отправки аналитического события. Автор возражает, что так проще, чем протаскивать scope через код, и что корутины всё равно короткоживущие. Объясните, что вы отметите на ревью: от какой гарантии отказывается GlobalScope, что конкретно ломается на экране, который пользователь закрыл посреди загрузки, и что вы попросите вместо этого в каждом из трёх мест.
В пул-реквесте вы находите GlobalScope.launch в десятке мест — во ViewModel для загрузки экрана, в репозитории для прогрева кеша и в Activity для отправки аналитического события. Автор возражает, что так проще, чем протаскивать scope через код, и что корутины всё равно короткоживущие. Объясните, что вы отметите на ревью: от какой гарантии отказывается GlobalScope, что конкретно ломается на экране, который пользователь закрыл посреди загрузки, и что вы попросите вместо этого в каждом из трёх мест.
GlobalScope не привязан ни к какому времени жизни, поэтому его корутины никогда не отменяются: закрытый экран продолжает грузиться, держит достижимой свою ViewModel и может писать в мёртвый UI. Замените его на scope владельца — viewModelScope, lifecycleScope, внедрённый app-scope.
Типичные ошибки
- ✗Считать, что короткая работа делает
GlobalScopeбезобидным - ✗Думать, что корутина отменяется, когда объект-владелец собран сборщиком мусора
- ✗Путать
GlobalScopeсо scope, у которого просто нет обработчика исключений
Уточняющие вопросы
- →Почему внедрённый app-scope допустим в репозитории, но не во ViewModel?
- →Какое одно законное применение
GlobalScopeвы всё же пропустите на ревью?
SeniorТеорияЧастоЧем структурная конкурентность безопаснее ручного запуска работы через thread-pool API ExecutorService?
Чем структурная конкурентность безопаснее ручного запуска работы через thread-pool API ExecutorService?
Отправленная в пул задача — сирота: её никто не ждёт, отмена вызывающего её не останавливает, а её падение лежит непрочитанным в Future. Scope же делает каждую корутину потомком своей Job — scope не завершится, пока не завершатся все потомки, отмена расходится вниз по ним, а падение потомка доходит до родителя и отменяет соседей.
Типичные ошибки
- ✗Думать, что
executor.shutdown()даёт ту же гарантию ожидания, что scope даёт своим потомкам - ✗Считать структурную конкурентность вопросом производительности, а не времени жизни и распространения ошибок
- ✗Ждать, что упавший потомок останется изолированным, а не дойдёт до родителя и не отменит соседей
Уточняющие вопросы
- →Какой строительный блок scope отключает отмену соседей и когда это нужно?
- →Почему отмена требует сотрудничества — что на самом деле останавливает работающую корутину?
MiddleДебаггингИногдаПочему эти два вызова async всё равно занимают две секунды вместо одной?
Почему эти два вызова async всё равно занимают две секунды вместо одной?
Немедленный await делает вызовы последовательными: первый async дожидаются раньше, чем стартовал второй, поэтому два секундных вызова идут подряд. Сначала запустите оба билдера, сохранив Deferred в переменные, и лишь потом их дождитесь.
Типичные ошибки
- ✗Думать, что
asyncленив и стартует только наawait() - ✗Считать, что
await()блокирует поток, а не приостанавливает корутину - ✗Полагать, что два ребёнка одного scope наложатся независимо от места
await()
Уточняющие вопросы
- →Что изменил бы здесь
awaitAll(user, feed)? - →Если
loadUser()бросит исключение, что станет с ещё работающимloadFeed()?
MiddleКодИногдаСделайте общий счётчик безопасным, когда тысяча корутин инкрементирует его конкурентно
Сделайте общий счётчик безопасным, когда тысяча корутин инкрементирует его конкурентно
count++ — это чтение-изменение-запись, поэтому конкурентные корутины затирают обновления друг друга. Защитите его через mutex.withLock { count++ }: захват приостанавливает корутину, а не блокирует поток пула. AtomicInteger — безлоковая и более дешёвая альтернатива.
Типичные ошибки
- ✗Думать, что
@Volatileделаетcount++атомарной операцией - ✗Считать, что инкремент
Intна JVM атомарен - ✗Удерживать блокирующий лок
synchronizedчерез точку приостановки
Уточняющие вопросы
- →Когда
AtomicIntegerздесь предпочтительнееMutex? - →Как
limitedParallelism(1)решает ту же гонку вообще без лока?
MiddleПроизводительностьИногдаПочему Dispatchers.IO позволяет намного больше потоков, чем Dispatchers.Default?
Почему Dispatchers.IO позволяет намного больше потоков, чем Dispatchers.Default?
Default крутит CPU-нагрузку, поэтому держит примерно по потоку на ядро: больше лишь добавит переключений контекста, но не пропускной способности. Поток IO же стоит заблокированным в системном вызове, а не на CPU, поэтому IO эластично растёт до многих потоков (минимум 64).
Типичные ошибки
- ✗Выполнять блокирующий вызов на
Defaultи морить голодом CPU-корутины - ✗Думать, что больше потоков всегда означает больше пропускной способности CPU
- ✗Считать, что размер
IOдолжен равняться числу ядер, как уDefault
Уточняющие вопросы
- →Что происходит с CPU-задачами, когда блокирующий вызов занимает поток
Default? - →Как ограничить параллелизм одного репозитория, не создавая свой диспетчер?
SeniorДизайнИногдаЛегаси-SDK геолокации отдаёт только callback-API — requestLocation(onResult, onError) — и возвращает handle, который можно отменить. Команда хочет, чтобы остальное приложение видело одну suspend fun currentLocation(): Location, ведущую себя как обычный приостанавливаемый вызов: возвращает значение, бросает исключение при ошибке и останавливает запрос в SDK, когда вызывающая корутина отменена. Опишите, как вы построите такую обёртку, какой примитив корутин превращает callback в приостановку и как вы гарантируете, что continuation возобновится ровно один раз, а отмена реально дойдёт до SDK.
Легаси-SDK геолокации отдаёт только callback-API — requestLocation(onResult, onError) — и возвращает handle, который можно отменить. Команда хочет, чтобы остальное приложение видело одну suspend fun currentLocation(): Location, ведущую себя как обычный приостанавливаемый вызов: возвращает значение, бросает исключение при ошибке и останавливает запрос в SDK, когда вызывающая корутина отменена. Опишите, как вы построите такую обёртку, какой примитив корутин превращает callback в приостановку и как вы гарантируете, что continuation возобновится ровно один раз, а отмена реально дойдёт до SDK.
Мост строится через suspendCancellableCoroutine: отдайте колбэкам SDK CancellableContinuation, возобновляйте его значением или через resumeWithException и зарегистрируйте invokeOnCancellation { handle.cancel() }. Возобновить можно лишь раз — поздний callback игнорируйте.
Типичные ошибки
- ✗Брать
suspendCoroutine, который не умеет доносить отмену до SDK - ✗Возобновлять continuation дважды, когда SDK вызывает оба колбэка
- ✗Блокировать поток через
runBlockingтолько ради ожидания callback
Уточняющие вопросы
- →Что
invokeOnCancellationгарантирует о потоке, на котором выполнится его блок? - →Как обернуть callback, который срабатывает многократно, а не один раз?
SeniorДебаггингИногдаПочему этот viewModelScope.launch всё равно замораживает интерфейс?
Почему этот viewModelScope.launch всё равно замораживает интерфейс?
viewModelScope диспетчеризует на Dispatchers.Main, а launch сам работу оттуда не уносит. Оба вызова блокирующие, а не приостанавливаемые, поэтому удерживают UI-поток. Оберните запрос в withContext(Dispatchers.IO), а разбор — в withContext(Dispatchers.Default).
Типичные ошибки
- ✗Считать, что любой
launchавтоматически уносит тело с главного потока - ✗Думать, что пометка функции как
suspendделает блокирующий вызов неблокирующим - ✗Путать диспетчер
viewModelScopeпо умолчанию сDispatchers.Default
Уточняющие вопросы
- →Почему
withContext(Dispatchers.IO)был бы неверным диспетчером дляparseJson? - →Как сделать функцию репозитория безопасной для
Main, чтобы вызывающим не нужен былwithContext?
SeniorДебаггингИногдаПочему этот репозиторий навсегда зависает на первом же вызове user()?
Почему этот репозиторий навсегда зависает на первом же вызове user()?
load вызывается, когда мы уже на dbDispatcher, а runBlocking блокирует этот единственный поток. Внутренний withContext(dbDispatcher) ставит работу в очередь тому же запаркованному потоку — двигаться некому, это deadlock. Сделайте load функцией suspend, зовите db.load(id) напрямую и уберите runBlocking.
Типичные ошибки
- ✗Звать
suspend-функцию черезrunBlockingиз обычного кода, который и так уже исполняется внутри корутины - ✗Думать, что
runBlockingприостанавливает вызывающего, а не блокирует его поток - ✗Считать, что
withContextна тот же dispatcher, где вы уже находитесь, всегда бесплатен
Уточняющие вопросы
- →Почему та же вложенность даёт deadlock на
Dispatchers.Main, но часто выживает наDispatchers.IO? - →Как сделать этот deadlock детерминированным в модульном тесте?
SeniorДизайнИногдаРепозиторий ходит во флаки-API, который то отдаёт HTTP 503, то подвисает. Продукт хочет повторять каждый вызов до четырёх раз с экспоненциальным backoff и джиттером, а затем сдаваться с исходной ошибкой. Ретрай не должен повторять клиентские ошибки класса 400, обязан немедленно уважать отмену вызывающего — в том числе во время паузы между попытками — и должен ограничивать суммарное время одного вызова. Опишите, как вы реализуете такой хелпер ретрая приостанавливаемой функцией, какие примитивы корутин возьмёте для ожидания и для ограничения времени и как сделаете его композируемым, чтобы в него можно было обернуть любой вызов репозитория.
Репозиторий ходит во флаки-API, который то отдаёт HTTP 503, то подвисает. Продукт хочет повторять каждый вызов до четырёх раз с экспоненциальным backoff и джиттером, а затем сдаваться с исходной ошибкой. Ретрай не должен повторять клиентские ошибки класса 400, обязан немедленно уважать отмену вызывающего — в том числе во время паузы между попытками — и должен ограничивать суммарное время одного вызова. Опишите, как вы реализуете такой хелпер ретрая приостанавливаемой функцией, какие примитивы корутин возьмёте для ожидания и для ограничения времени и как сделаете его композируемым, чтобы в него можно было обернуть любой вызов репозитория.
Напишите suspend-хелпер, принимающий вызов лямбдой: крутите попытки с отменяемым delay(backoff) между ними, умножая его и добавляя джиттер. Неретраибельную ошибку перебрасывайте сразу, а последнюю — когда попытки кончились. Весь вызов ограничьте через withTimeout.
Типичные ошибки
- ✗Блокировать поток через
Thread.sleepв паузе между попытками - ✗Повторять клиентские ошибки, которые не станут успешными при повторе
- ✗Отвязывать ретрай от scope вызывающего, из-за чего отмена игнорируется
Уточняющие вопросы
- →Почему
delayуважает отмену, аThread.sleep— нет? - →Чем
withTimeoutотличается отwithTimeoutOrNullдля такого хелпера?
SeniorТеорияИногдаЧем корутины Kotlin отличаются от планируемых самой JVM virtual threads по модели и цене?
Чем корутины Kotlin отличаются от планируемых самой JVM virtual threads по модели и цене?
Корутина — конструкция компилятора: suspend превращается в конечный автомат с Continuation, поэтому приостановка — это возврат, а не парковка, ценой окраски всей цепочки вызовов. Virtual thread — конструкция рантайма: обычный блокирующий код снимается со своего carrier без единой правки. Обе стоят объекта в куче, а не стека ОС, но native-кадр всё ещё пиннит virtual thread.
Типичные ошибки
- ✗Называть virtual thread корутиной под другим именем, упуская, что одно — переписывание компилятором, другое — снятие с потока рантаймом
- ✗Считать, что с JDK 24 virtual thread не пиннит ничего: native- и foreign-кадры пиннят до сих пор
- ✗Выдавать structured concurrency в Java за финальную — она всё ещё preview, в отличие от scoped values
Уточняющие вопросы
- →Почему блокирующий вызов драйвера базы
JDBCвсё равно стоит потока наDispatchers.IO? - →Какие части цепочки вызовов заставляет менять
suspendи что вы получаете взамен?
SeniorДизайнРедкоРепозиторий веером шлёт конкурентные вызовы во внешний API, который допускает не более пяти запросов в полёте, а остальные отбивает с HTTP 429. Его вызывающие — обычные приостанавливаемые функции по всему приложению, и любую из них могут отменить в любой момент. Спроектируйте внутри репозитория ограничитель, который пропускает не более пяти конкурентных запросов, заставляет остальных ждать, а не падать, никогда не блокирует поток пула во время ожидания и корректно возвращает разрешение при отмене вызывающего — и когда тот ещё ждал, и когда уже был в полёте.
Репозиторий веером шлёт конкурентные вызовы во внешний API, который допускает не более пяти запросов в полёте, а остальные отбивает с HTTP 429. Его вызывающие — обычные приостанавливаемые функции по всему приложению, и любую из них могут отменить в любой момент. Спроектируйте внутри репозитория ограничитель, который пропускает не более пяти конкурентных запросов, заставляет остальных ждать, а не падать, никогда не блокирует поток пула во время ожидания и корректно возвращает разрешение при отмене вызывающего — и когда тот ещё ждал, и когда уже был в полёте.
Закройте API Semaphore(5) и оборачивайте каждый вызов в semaphore.withPermit { … }. Захват приостанавливает корутину, когда все пять заняты, поэтому поток не блокируется, а withPermit освобождает разрешение в finally, поэтому отменённый вызывающий его возвращает.
Типичные ошибки
- ✗Крутиться в цикле или блокировать поток, ожидая свободной ёмкости
- ✗Освобождать разрешение вне
finally, из-за чего отмена его теряет - ✗Путать
Mutex(одно разрешение) со случаем на пять разрешений
Уточняющие вопросы
- →Как
limitedParallelism(5)соотносится сSemaphoreдля этой задачи? - →Что вы добавите, чтобы всплески сглаживались во времени, а не только ограничивались в полёте?