CoroutineScope, структурная конкурентность и Dispatchers

Здесь проверяют не знание API, а понимание того, кто отвечает за завершение асинхронной работы.

Структурная конкурентность — принцип, при котором каждая корутина принадлежит области видимости (scope) и не может её «пережить». Отмена scope отменяет все дочерние корутины, а scope не завершится, пока не завершатся дети.

Вопрос 1: «Что такое CoroutineScope и почему нельзя пользоваться GlobalScope?»

Что проверяет интервьюер

Понимаете ли вы, что незавершённая корутина, не привязанная к экрану, — это утечка памяти, лишний трафик и падение по обращению к уничтоженному UI.

Развёрнутый ответ

CoroutineScope — это по сути обёртка над CoroutineContext, в котором обязательно есть Job. Каждая запущенная в scope корутина становится ребёнком этого Job. Отсюда три гарантии структурной конкурентности:

  1. Отмена родителя отменяет всех детей.
  2. Родитель не считается завершённым, пока не завершились дети.
  3. Необработанная ошибка ребёнка отменяет родителя и остальных детей (если родитель — обычный 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 для вычислений; в коде диспетчер внедряю, чтобы подменять его в тестах.»

Проверьте себя
1. Чем плох GlobalScope.launch для загрузки данных экрана?
Aон всегда выполняется на главном потоке и вызывает ANR
Bкорутина не привязана к жизненному циклу: она продолжит работу после закрытия экрана и удержит ссылки
Cв GlobalScope нельзя вызывать suspend-функции
DGlobalScope создаёт новый поток на каждую корутину
2. Нужно загрузить профиль и ленту параллельно и собрать из них экран. Что выбрать?
Aдва последовательных вызова suspend-функций
Bдва launch внутри viewModelScope и join()
CcoroutineScope с двумя async и последующими await
DGlobalScope.async с runBlocking для ожидания
3. Какой диспетчер подойдёт для тяжёлого парсинга большого JSON?
ADispatchers.Main, чтобы результат сразу попал в UI
BDispatchers.IO, потому что данные пришли из сети
CDispatchers.Default — пул по числу ядер, рассчитанный на CPU-работу
DDispatchers.Unconfined, чтобы не тратить время на переключение