Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.
Вопросы и ответы
12 подробных ответов
01Что такое корутина и чем suspend отличается от блокировки потока?
junior
Короткий ответ: Корутина — легковесная единица конкурентности поверх потоков. Приостановка (suspend) «паркует» корутину и освобождает поток под другую работу; блокировка держит поток занятым. Поэтому тысячи корутин спокойно живут на одном потоке.
Подробно:
// Блокировка: поток занят и бесполезен
fun blocking() {
Thread.sleep(1000) // поток спит — недоступен никому
}
// Приостановка: корутина паркуется, поток свободен
suspend fun suspending() {
delay(1000) // поток в это время выполняет другие корутины
}
fun main() = runBlocking {
repeat(100_000) { launch { delay(1000) } } // ок даже на одном потоке
// 100 000 потоков с Thread.sleep(1000) — OutOfMemoryError
}
- Приостановка ≠ блокировка —
delayвозвращает управление диспатчеру,Thread.sleepудерживает поток. - Лёгкость — корутина это объект в куче (continuation + стейт), а не стек ~1 МБ, как у потока.
- Кооперативность — корутина уступает поток только в точках приостановки (suspension points).
⚠️ Частая ошибка: «саспенд-функция выполняется в фоновом потоке». Нет: suspend сам по себе поток не переключает — где выполняться, решает диспатчер.
02Как suspend-функция работает под капотом?
senior
Короткий ответ: Через CPS (continuation-passing style) и конечный автомат. Компилятор добавляет функции скрытый параметр Continuation, режет тело на состояния по точкам приостановки, а при приостановке возвращает COROUTINE_SUSPENDED. Возобновление — вызов resumeWith, который снова входит в функцию с нужным label.
Подробно:
suspend fun login(user: String): Token {
val id = authorize(user) // точка приостановки 1
return fetchToken(id) // точка приостановки 2
}
// эскиз того, что генерирует компилятор:
fun login(user: String, cont: Continuation<Token>): Any? {
val sm = cont as? LoginSM ?: LoginSM(cont)
when (sm.label) {
0 -> {
sm.label = 1
val r = authorize(user, sm) // sm передаётся как колбэк
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
1 -> { /* нас возобновили: результат лежит в sm.result */ }
// ...состояние 2 — для fetchToken
}
// ...
}
- CPS — реальная сигнатура в байткоде:
login(String, Continuation<Token>): Any?— возвращает результат (если не приостановилась) илиCOROUTINE_SUSPENDED. - Конечный автомат — один continuation-объект с
labelхранит локальные переменные между состояниями. - Возобновление — нижний слой вызывает
cont.resumeWith(result), и автомат прыгает в следующую веткуwhen.
⚠️ Частая ошибка: «suspend — это магия потоков». Потоков тут нет вообще: это чистая трансформация компилятора; поток выбирает диспатчер через ContinuationInterceptor.
03Диспатчеры Main, IO, Default и Unconfined — что и где выполняется?
junior
Короткий ответ: Default — пул размером в число ядер CPU, для вычислений. IO — эластичный пул (по умолчанию до 64 потоков) для блокирующего ввода-вывода. Main — единственный поток main looper, весь UI. Unconfined — вообще не пул: возобновляется в том потоке, где закончилась приостановка.
Подробно:
| Диспатчер | Потоки | Для чего |
|---|---|---|
Dispatchers.Main |
main looper | UI, стейт «на мейне» |
Dispatchers.Default |
= числу ядер | CPU-bound: парсинг, сортировка, diff |
Dispatchers.IO |
эластичный, до 64+ | блокирующий I/O: файлы, сеть, Room |
Dispatchers.Unconfined |
нет своего | старт в текущем потоке, resume — где придётся |
- IO и Default делят потоки — это один общий пул с разными лимитами, поэтому
withContext(Dispatchers.IO)из Default может вообще не переключить поток — только сменить лимит. - IO — не про скорость — лишние потоки не ускоряют CPU-работу; они нужны, чтобы много корутин могли одновременно «висеть» в блокирующих вызовах.
- Unconfined — инструмент для тестов и редкой низкоуровневой оптимизации, не для продакшн-логики.
⚠️ Частая ошибка: «IO делает код параллельным». Диспатчер лишь говорит, где выполняться; параллельность создают launch/async. А CPU-задачи на IO — это просто больше потоков, дерущихся за те же ядра.
04Структурированная конкурентность: что гарантирует иерархия корутин?
middle
Короткий ответ: Каждая корутина живёт внутри родительского Job и не может его пережить: родитель ждёт завершения всех детей, отмена распространяется вниз, ошибки всплывают вверх. «Осиротевших» корутин не бывает — скоуп очерчивает жизненный цикл работы.
Подробно:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val user = async { api.fetchUser() }
val feed = async { api.fetchFeed() }
Dashboard(user.await(), feed.await())
} // не вернётся, пока живы дети;
// упал fetchFeed — fetchUser отменится, ошибка уйдёт вызывающему
- Родитель ждёт детей —
coroutineScopeзавершится только после всехasync/launchвнутри: забытых фоновых хвостов не остаётся. - Отмена — вниз — отменили скоуп (пользователь ушёл с экрана) — отменилось всё дерево, включая вложенные корутины любых уровней.
- Ошибки — вверх — упавший ребёнок отменяет братьев и передаёт исключение родителю.
- Скоуп = граница жизни — viewModelScope, lifecycleScope: работа привязана к владельцу, утечки корутин исключены by design, а не дисциплиной.
⚠️ Частая ошибка: заводить GlobalScope или свой CoroutineScope(Dispatchers.IO) без владельца «чтобы точно доработало». Это ровно выход из структуры: такие корутины никто не отменит и никто не дождётся.
05Job против SupervisorJob — и почему launch(SupervisorJob()) не работает так, как ждут?
middle
Короткий ответ: С обычным Job ошибка ребёнка отменяет родителя и всех братьев; SupervisorJob гасит это распространение — упавший ребёнок не трогает остальных. Ловушка: launch(SupervisorJob()) делает SupervisorJob родителем только этой одной корутины, а её собственные дети по-прежнему связаны обычными Job.
Подробно:
// ❌ выглядит как «защита», но не работает
scope.launch(SupervisorJob()) {
launch { throw IOException() } // у детей — обычные Job!
launch { /* всё равно отменится */ }
}
// ✓ supervisorScope — дети реально независимы
supervisorScope {
launch { throw IOException() } // упал один
launch { /* продолжает работать */ }
}
// ✓ скоуп, собранный с супервизором — как viewModelScope
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
- Зачем супервизор — для уровня, где падение одной задачи не должно валить остальные: скоуп экрана, скоуп приложения, параллельные независимые загрузки.
- Почему трюк не работает — Job, переданный в контекст
launch, становится родителем этой корутины. Внутри неёlaunchсоздаёт обычные child-Job, и supervisor-семантика на них не распространяется. - viewModelScope — уже построен на SupervisorJob: одна упавшая корутина не убивает весь скоуп ViewModel.
⚠️ Частая ошибка: совать SupervisorJob() в контекст launch/async как амулет от крашей. Работают только supervisorScope { } или скоуп, изначально собранный с SupervisorJob.
06Исключения в корутинах: launch против async и где реально работает CoroutineExceptionHandler?
senior
Короткий ответ: launch пробрасывает исключение сразу вверх по дереву Job; async хранит его и выбрасывает при await(). CoroutineExceptionHandler срабатывает только на корневых корутинах (в контексте скоупа или корневого launch) — вешать его на детей бесполезно: исключение уже ушло родителю.
Подробно:
val handler = CoroutineExceptionHandler { _, e -> log(e) }
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch { throw IOException() } // поймает handler
scope.launch {
launch(handler) { throw IOException() } // handler ПРОИГНОРИРОВАН: не корень
}
val d = scope.async { throw IOException() } // тихо, пока...
d.await() // ...не вызвали await
// отмену не глотать:
try { work() }
catch (e: CancellationException) { throw e } // перевыбросить обязательно
catch (e: Exception) { log(e) }
- launch — fail-fast: исключение немедленно идёт вверх; с обычным Job оно валит весь скоуп, с SupervisorJob — доходит до CEH.
- async — отложенно: без
await()исключение можно вообще не увидеть (хотя родителя с обычным Job оно всё равно отменит). - CEH — «последний рубеж» вместо краша процесса; работает ровно там, где исключению уже некуда всплывать.
⚠️ Частая ошибка: catch (e: Exception) в цикле, глотающий CancellationException, — корутина перестаёт отменяться. Отмену всегда перевыбрасывайте.
07viewModelScope, lifecycleScope и repeatOnLifecycle: как правильно собирать Flow из UI?
middle
Короткий ответ: Во ViewModel — viewModelScope: отменяется в onCleared. В UI Flow собирают через lifecycleScope.launch { repeatOnLifecycle(STARTED) { ... } }: сбор стартует на onStart и полностью отменяется на onStop, а не просто «замирает».
Подробно:
// канонический паттерн сбора стейта во фрагменте/активити
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
- viewModelScope — SupervisorJob + Main.immediate, отмена в
onCleared(): работа живёт, пока жива ViewModel, и переживает поворот экрана. - repeatOnLifecycle(STARTED) — на каждый onStart запускает блок заново, на onStop отменяет. Пока приложение в фоне, продюсер не работает: GPS, БД, сеть — свободны.
- Чем плох launchWhenStarted — он лишь ПРИОСТАНАВЛИВАЕТ коллектор, а апстрим продолжает работать в фоне; именно поэтому он объявлен deprecated.
- Compose —
collectAsStateWithLifecycle()делает то же самое одной строкой.
⚠️ Частая ошибка: lifecycleScope.launch { flow.collect { } } без repeatOnLifecycle: сбор идёт, даже когда приложение в фоне, и с горячим Flow это работа впустую вплоть до destroy.
08withContext против async/await: когда что уместно?
junior
Короткий ответ: withContext — последовательное переключение контекста: выполнить блок на другом диспатчере и вернуть результат. async/await — про конкурентность: запустить несколько работ параллельно и дождаться всех. async ради одного вызова — классический антипаттерн.
Подробно:
// последовательно: просто уйти с мейна
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
api.fetchUser() // результат возвращается вызывающему
}
// параллельно: два запроса одновременно
suspend fun loadScreen(): Screen = coroutineScope {
val user = async { api.fetchUser() }
val posts = async { api.fetchPosts() }
Screen(user.await(), posts.await()) // ~время самого долгого запроса
}
- withContext — конкурентности не добавляет: та же корутина продолжает выполняться, просто в другом контексте; вернулся блок — вернулся результат.
- async — возвращает
Deferred; смысл появляется, когда запусков минимум два и они независимы. - async { }.await() сразу же — семантически то же, что withContext, только с лишней корутиной и шумом в коде.
⚠️ Частая ошибка: оборачивать каждый вызов репозитория в async { }.await() «для асинхронности». Асинхронность уже даёт suspend; async нужен ради параллелизма.
09Холодный и горячий Flow: в чём разница и какие из них какие?
middle
Короткий ответ: Холодный Flow — рецепт: код продюсера запускается заново для каждого коллектора (flow { }, стримы Room/Retrofit). Горячий живёт независимо от подписчиков и делит эмиссии между ними: StateFlow и SharedFlow. Холодный превращают в горячий через stateIn/shareIn.
Подробно:
| Холодный | Горячий | |
|---|---|---|
| Продюсер | стартует на каждый collect | работает независимо |
| Подписчики | у каждого свой поток данных | делят один общий |
| Без коллекторов | ничего не происходит | может эмитить «в пустоту» |
| Примеры | flow {}, flowOf, Room |
StateFlow, SharedFlow |
val uiState: StateFlow<UiState> = repository.observeUser() // холодный
.map(::toUiState)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)
- WhileSubscribed(5000) — апстрим останавливается через 5 с после ухода последнего подписчика: поворот экрана (пересоздание короче 5 с) подписка переживает без рестарта апстрима, а реальный уход в фон её честно останавливает.
- stateIn против shareIn — stateIn всегда держит текущее значение (стейт), shareIn настраивается через replay (события, разделяемые стримы).
⚠️ Частая ошибка: collect-нуть холодный Flow с сетевым запросом в двух местах и удивиться двум запросам — каждый коллектор запускает продюсер заново.
11Как реализовать поиск по мере ввода (search-as-you-type) на операторах Flow?
middle
Короткий ответ: debounce, чтобы не дёргать сеть на каждый символ; distinctUntilChanged, чтобы не повторять тот же запрос; flatMapLatest, чтобы новый ввод отменял предыдущий запрос; catch — чтобы ошибка не убила цепочку.
Подробно:
val results: StateFlow<SearchState> = queryFlow // MutableStateFlow из поля ввода
.debounce(300) // ждём паузу в наборе
.distinctUntilChanged() // тот же текст повторно не ищем
.flatMapLatest { q ->
flow { emit(repo.search(q)) }
.map<List<Item>, SearchState> { SearchState.Data(it) }
.onStart { emit(SearchState.Loading) }
.catch { emit(SearchState.Error(it)) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)
- flatMapLatest — на каждую новую эмиссию отменяет предыдущий внутренний flow вместе с его сетевым запросом: устаревший ответ физически не может приехать поверх свежего.
- Почему не flatMapMerge/Concat — merge выполняет запросы параллельно, и ответы приходят вперемешку (старый может перезаписать новый); concat честно ждёт каждый — растёт очередь заведомо устаревших запросов.
- catch внутри flatMapLatest — ошибка одного запроса превращается в стейт ошибки, а не в смерть всего потока поиска.
⚠️ Частая ошибка: поставить catch снаружи flatMapLatest: первая же сетевая ошибка завершит всю цепочку — поиск «умирает» до пересоздания экрана.
12Почему корутина «отказывается» отменяться и как это чинить?
senior
Короткий ответ: Отмена кооперативна: cancel() лишь взводит флаг, а CancellationException выбрасывается в точках приостановки. CPU-цикл без yield()/ensureActive()/isActive флага не замечает и молотит до конца. Для обязательной очистки — withContext(NonCancellable).
Подробно:
val job = scope.launch(Dispatchers.Default) {
var i = 0
while (i < 1_000_000_000) { // ❌ ни одной точки приостановки
crunch(i++) // cancel() ничего не изменит
}
}
job.cancel() // а корутина продолжает работать
// ✓ кооперативная версия
while (isActive && i < 1_000_000_000) { crunch(i++) }
// или ensureActive() / yield() в теле цикла
// ✓ suspend-очистка после отмены
try { work() } finally {
withContext(NonCancellable) { connection.close() } // suspend-вызовы в finally
}
- Точки приостановки — delay, yield, await и все suspend-функции kotlinx.coroutines проверяют флаг и бросают CancellationException.
- isActive / ensureActive / yield — isActive как условие цикла; ensureActive бросает исключение сам; yield вдобавок уступает поток.
- NonCancellable — в уже отменённой корутине любая точка приостановки немедленно бросит отмену снова, поэтому suspend-очистка в finally обязана жить внутри withContext(NonCancellable).
⚠️ Частая ошибка: считать cancel() «убийством потока». Это просьба: код, который не приостанавливается и не проверяет флаг, не отменяется вообще.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.