CoroutineScope, структурная конкурентность и Dispatchers
Здесь проверяют не знание API, а понимание того, кто отвечает за завершение асинхронной работы.
Структурная конкурентность — принцип, при котором каждая корутина принадлежит области видимости (scope) и не может её «пережить». Отмена scope отменяет все дочерние корутины, а scope не завершится, пока не завершатся дети.
Вопрос 1: «Что такое CoroutineScope и почему нельзя пользоваться GlobalScope?»
Что проверяет интервьюер
Понимаете ли вы, что незавершённая корутина, не привязанная к экрану, — это утечка памяти, лишний трафик и падение по обращению к уничтоженному UI.
Развёрнутый ответ
CoroutineScope — это по сути обёртка над CoroutineContext, в котором обязательно есть Job. Каждая запущенная в scope корутина становится ребёнком этого Job. Отсюда три гарантии структурной конкурентности:
- Отмена родителя отменяет всех детей.
- Родитель не считается завершённым, пока не завершились дети.
- Необработанная ошибка ребёнка отменяет родителя и остальных детей (если родитель — обычный
Job, а неSupervisorJob).
GlobalScope нарушает первую гарантию: его корутины живут, пока жив процесс. Пользователь ушёл с экрана — запрос продолжается, результат приходит в мёртвую ViewModel, ссылки не освобождаются. Поэтому в Android используют готовые scope, привязанные к жизненному циклу:
| Scope | Отменяется когда | Диспетчер по умолчанию |
viewModelScope | в ViewModel.onCleared() | Dispatchers.Main.immediate |
lifecycleScope | в onDestroy владельца | Dispatchers.Main.immediate |
свой CoroutineScope(SupervisorJob() + Dispatchers.IO) | вручную, через cancel() | задаётся явно |
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
fun refresh() {
viewModelScope.launch {
_state.value = UiState.Loading
val items = repo.load() // отменится вместе с ViewModel
_state.value = UiState.Success(items)
}
}
}
Единственный оправданный случай для собственного долгоживущего scope — работа, которая обязана завершиться независимо от экрана (например, отправка аналитики или синхронизация). Тогда его создают в слое данных с SupervisorJob, а не берут GlobalScope.
Вопрос 2: «Чем launch отличается от async? А coroutineScope от supervisorScope?»
launch запускает корутину «выстрелил и забыл»: возвращает Job, результат не отдаёт, необработанное исключение сразу уходит наверх по иерархии. async возвращает Deferred<T> — результат забирается через await(), и исключение выбрасывается именно там.
// параллельная загрузка двух источников
suspend fun loadScreen(): Screen = coroutineScope {
val user = async { api.user() }
val feed = async { api.feed() }
Screen(user.await(), feed.await()) // оба запроса идут одновременно
}
Важная деталь: coroutineScope { } создаёт дочерний scope и приостанавливается, пока не завершатся все корутины внутри. Если одна из них упала — отменяются остальные, а исключение пробрасывается наружу. Это правильное поведение для «загрузить экран целиком».
supervisorScope { } отличается тем, что падение одного ребёнка не отменяет остальных. Подходит, когда части независимы: не загрузились рекомендации — баннер всё равно должен показаться.
Вопрос 3: «Какие бывают Dispatchers и как выбрать?»
| Диспетчер | Где выполняется | Для чего |
Dispatchers.Main | главный поток Android | обновление UI, вызов ViewModel |
Dispatchers.IO | пул до 64 потоков | сеть, файлы, база — то, что ждёт ввода-вывода |
Dispatchers.Default | пул по числу ядер | тяжёлые вычисления: парсинг, сортировка, обработка изображений |
Dispatchers.Unconfined | текущий поток до первой приостановки | практически только внутри библиотек и тестов |
Логика разделения IO и Default: операции ввода-вывода почти всё время ждут, поэтому потоков может быть много и они дешёвы; вычисления реально загружают процессор, и потоков сверх числа ядер только вредит. Пустить Default-задачу на IO — значит устроить конкуренцию 64 потоков за 8 ядер.
Переключение делается через withContext, который приостанавливает корутину и возвращает результат уже в исходном контексте:
suspend fun parseCatalog(json: String): List<Item> =
withContext(Dispatchers.Default) { // CPU-работа
Json.decodeFromString(json)
}
Отдельный балл сверху — сказать, что диспетчер стоит внедрять, а не хардкодить: тогда в тестах вместо Dispatchers.IO подставляется StandardTestDispatcher, и тесты становятся детерминированными.
Типичные ошибки кандидатов
GlobalScope.launchв примерах на доске — почти всегда минус.- Считают, что
asyncнужен всегда: для одной задачи без параллелизма это лишняя обёртка. - Вызывают
asyncи сразуawait()— параллелизма не будет, получится обычный последовательный вызов. - Ставят
Dispatchers.IOна любую фоновую работу, включая вычисления. - Не знают, что
viewModelScopeпо умолчанию на главном потоке — и потому боятся обновлять состояние прямо вlaunch.
Как ответить кратко (20–30 секунд)
«CoroutineScope хранит
Job, к которому привязываются все запущенные корутины — отсюда структурная конкурентность: отмена scope отменяет детей, а scope ждёт их завершения.GlobalScopeэту связь рвёт, корутина живёт до конца процесса и течёт, поэтому в Android беруviewModelScopeилиlifecycleScope.launch— «запустил и забыл», возвращаетJob;asyncвозвращаетDeferredи нужен для параллельных задач.coroutineScopeвалит всех детей при ошибке одного,supervisorScope— нет. Диспетчеры:Mainдля UI,IOдля сети и диска,Defaultдля вычислений; в коде диспетчер внедряю, чтобы подменять его в тестах.»