Введение: с чего начинается разговор о корутинах

«Что такое CoroutineScope?» — первый вопрос про корутины на любом собеседовании. И самая частая неудача: «ну, это то, из чего запускается launch».

Формально ответ не ложный. launch — extension-функция на CoroutineScope, без скоупа корутину не запустить. Но интервьюер спрашивает не про синтаксис. За вопросом стоит проверка трёх вещей: понимаете ли вы, зачем скоуп нужен, как он связан с жизненным циклом и что произойдёт, если скоуп не отменить.

Документация Kotlin определяет роль скоупа жёстко: новые корутины можно запускать только в CoroutineScope, который определяет и управляет их жизненным циклом. Coroutines basics, официальный гайд. Вся тема корутин — про иерархию и управление жизнью задач, и CoroutineScope — это точка входа в неё. Разберём по слоям: что внутри скоупа, как устроена отмена и чем SupervisorJob отличается от обычного Job.

Что такое CoroutineScope: контекст и иерархия

CoroutineScope — это объект, который хранит CoroutineContext: набор элементов, описывающих, как выполняются корутины. Главные из них — Job (жизненный цикл) и диспетчер (на каких потоках крутится работа). Скоуп передаёт этот контекст всем своим корутинам — они наследуют диспетчер, если не задан свой.

Example.kt
val scope = CoroutineScope(Dispatchers.Default + Job())

val job: Job = scope.launch {
    delay(1_000)
    println("done")
}

val deferred: Deferred<Int> = scope.async { 42 }
println(deferred.await())

launch возвращает Job — дескриптор задачи. async возвращает Deferred, который тоже реализует Job, но с результатом, который забирают через await().

Дальше начинается главное — structured concurrency. Корутины образуют дерево «родитель — ребёнок»:

  • родитель ждёт завершения всех детей, прежде чем закончится сам;
  • отмена или падение родителя каскадно отменяет всех детей;
  • новую корутину можно запустить только внутри скоупа, поэтому «сирот» без родителя не бывает.

Именно поэтому корутина не может «утечь» из своего скоупа: связь с жизненным циклом заложена в модель, а не в дисциплину разработчика.

Суть скоупа

CoroutineScope = контекст (Job + диспетчер) + правила structured concurrency. Он определяет, где и сколько живут корутины. Отменили скоуп — отменились все его корутины. Это и есть ответ на вопрос «зачем скоуп нужен».

CoroutineScope и coroutineScope(): не путайте

Ещё один источник путаницы на собеседованиях — одинаковые имена. CoroutineScope — интерфейс и фабрика скоупов. А coroutineScope { } — suspend-функция, и это разные вещи.

Example.kt
suspend fun loadAll() = coroutineScope {
    val profile = async { repository.loadProfile() }
    val orders = async { repository.loadOrders() }
    show(profile.await(), orders.await())
}

coroutineScope() создаёт новый скоуп внутри текущей корутины: запущенные в нём дети — полноценные структурированные дети. Функция ждёт завершения всех, а падение любого ребёнка отменяет остальных и пробрасывается наружу. Удобно для группировки: «эти запросы — одна логическая операция, которая либо доедет целиком, либо упадёт целиком». Документация определяет её как корень поддерева корутин, который ждёт завершения блока и всех запущенных в нём корутин. Coroutines basics, официальный гайд.

Вопрос-добивка следом: «Чем coroutineScope() отличается от withContext()withContext — переключение контекста внутри текущей корутины (например, на Dispatchers.IO) с возвратом результата. coroutineScope — создание структурного поддерева для параллельных задач. Обе — suspend-функции, обе ждут завершения, но задачи разные: «сменить диспетчер» против «запустить группу детей».

Job и отмена: как это работает на деле

Вопрос-добивка после определения скоупа: «А как отменить корутину? И что, если внутри цикл?»

Отмена в kotlinx.coroutines — кооперативная. Никто не убивает корутину принудительно. При вызове job.cancel() корутина помечается отменённой, и в ближайшей точке приостановки (suspend-точке) выбрасывает CancellationException. Встроенные suspend-функции — delay(), await() и остальные — проверяют отмену при приостановке сами. Cancellation and timeouts, официальный гайд.

CancellationExample.kt
val job = scope.launch {
    repeat(10_000_000) { i ->
        ensureActive() // проверка отмены в цикле
        heavyCompute(i)
    }
}

job.cancel()
job.join()

Два момента, которые отличают сильный ответ от слабого:

  • Плотный CPU-цикл без suspend-точек не увидит отмену. Нужно вручную проверять isActive или вызывать ensureActive() / yield() — как в примере выше.
  • job.cancel() не ждёт остановки. Если нужно дождаться реального завершения — cancelAndJoin(). А отмена ребёнка не отменяет родителя: это работает только в обратную сторону.

Частая ошибка в ответах — «CancellationException нужно ловить, чтобы обработать отмену». Ловить можно, но перебрасывать — обязательно. Документация предупреждает прямо: пойманный и не переброшенный CancellationException ломает распространение отмены по иерархии.

Как звучит сильный ответ целиком:

«Скоуп — это граница жизни корутин. Я создаю его с Job и диспетчером, запускаю задачи через launch и async. Когда владелец скоупа умирает — вызываю cancel, и вся иерархия гасится каскадом. Если ветки не должны валить друг друга — SupervisorJob. Если нужно сгруппировать параллельные запросы внутри корутины — coroutineScope(). Отмена кооперативная: suspend-функции проверяют её сами, плотные циклы — через ensureActive().»

SupervisorJob: когда дети должны выживать

Теперь ключевая развилка. Обычный Job: упал ребёнок с исключением — падает родитель, а вместе с ним все братья. Это fail-fast поведение, и оно по умолчанию правильное: если задача провалилась, незачем тихо продолжать зависеть от её результата.

Но бывают задачи, которые не должны валить друг друга: загрузка профиля и уведомлений на экране. Упал один запрос — остальные пусть доезжают. Для этого существует SupervisorJob: дети такого скоупа падают независимо, падение ребёнка не отменяет ни родителя, ни соседей. SupervisorJob, API-справка.

SupervisorExample.kt
val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

uiScope.launch { loadProfile() }        // упал — остальные живы
uiScope.launch { loadNotifications() }

И здесь всплывает второе дно, на котором тонут кандидаты: SupervisorJob не проглатывает исключения. Оно никуда не исчезает:

  • исключение ребёнка, запущенного через launch, уходит в CoroutineExceptionHandler из контекста;
  • исключение ребёнка, запущенного через async, забирают через await().

Без обработки необработанное исключение всё равно доберётся до дефолтного обработчика. Так что «поставил SupervisorJob и забыл» — это не решение, это отсрочка.

Job vs SupervisorJob

Job — «упал один, упали все» (fail-fast, дефолт для structured concurrency). SupervisorJob — «упал один, остальные живут» (изоляция независимых веток). Но исключение не исчезает: его всё равно нужно обработать — через CoroutineExceptionHandler или await().

Скоупы в Android: где брать и когда создавать свой

На собеседовании после теории спрашивают практику: «Какой скоуп использовать в ViewModel, а какой во Fragment?»

Стандарт — готовые скоупы из Jetpack:

  • viewModelScope — привязан к ViewModel, отменяется автоматически, когда ViewModel очищается (onCleared). Use Kotlin coroutines with lifecycle-aware components.
  • lifecycleScope — привязан к Lifecycle-владельцу: Activity, Fragment, Service.

Отвечать нужно не «использую viewModelScope», а с объяснением, почему: скоуп живёт ровно столько же, сколько его владелец, значит, корутины не переживут экран и не утекут.

Свой скоуп создают, когда нужен жизненный цикл, которого нет у готовых скоупов — например, загрузка данных в Fragment, переживающая конфигурацию, но не отменяемая при уходе с экрана. Критическое правило: скоуп, созданный руками, нужно отменять руками.

ProfileFragment.kt
class ProfileFragment : Fragment() {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        scope.launch {
            val profile = withContext(Dispatchers.IO) { repository.loadProfile() }
            render(profile)
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        scope.cancel()
    }
}

Ошибка-классика — собственный CoroutineScope без cancel(). Скоуп живёт, корутины внутри работают, ссылки на View и Context висят — классическая утечка памяти, которую потом ищут через LeakCanary. Правило простое: создал скоуп — отмени его в соответствующей точке жизненного цикла.

И отдельно про GlobalScope: это скоуп уровня всего приложения, корутина в нём живёт, пока не завершится сама. Документация относит его к delicate API и требует явного opt-in. В приложении почти всегда это баг: задача отвязана от любого жизненного цикла.

  • CoroutineScope хранит контекст: Job + диспетчер; launch и async — extension-функции на нём.
  • Structured concurrency: родитель ждёт детей, отмена родителя каскадно отменяет детей.
  • Отмена кооперативная: CancellationException в suspend-точках; циклы проверяют ensureActive().
  • SupervisorJob изолирует падения детей, но исключения всё равно нужно обрабатывать.
  • В Android — viewModelScope и lifecycleScope; свой скоуп создаётся только с отменой в жизненном цикле.

Плаваете в Coroutines на собеседованиях?

Я Рустем Бикбулатов — senior Android-разработчик и ментор Яндекс Практикума. Помогаю разработчикам с опытом 1–2 года выйти на middle за 4–8 недель: закрываю пробелы в корутинах, архитектуре и Kotlin, тренирую ответы на собеседованиях. Бесплатный чек-лист подготовки: конкретные пункты, что закрыть и в каком порядке.

Итоги

CoroutineScope — это не «обёртка над launch», а точка входа в structured concurrency: контекст, иерархия и правила жизни корутин. Три тезиса для собеседования:

  • Скоуп определяет жизненный цикл: отменили скоуп — отменились все корутины. Отсюда готовые скоупы в Android и ручная отмена своих.
  • Job vs SupervisorJob — вопрос про изоляцию падений, но исключения в обоих случаях требуют обработки.
  • Отмена кооперативная: suspend-функции проверяют её сами, плотные циклы — через ensureActive().

Если хотите проверить себя — попробуйте объяснить это вслух без подглядывания, как на собеседовании. Если чувствуете, что тема корутин «плавает» — напишите в Telegram, разберём ваши пробелы и составим план подготовки.