Введение: с чего начинается разговор о корутинах
«Что такое CoroutineScope?» — первый вопрос про корутины на любом собеседовании. И самая частая неудача: «ну, это то, из чего запускается launch».
Формально ответ не ложный. launch — extension-функция на CoroutineScope, без скоупа корутину не запустить. Но интервьюер спрашивает не про синтаксис. За вопросом стоит проверка трёх вещей: понимаете ли вы, зачем скоуп нужен, как он связан с жизненным циклом и что произойдёт, если скоуп не отменить.
Документация Kotlin определяет роль скоупа жёстко: новые корутины можно запускать только в CoroutineScope, который определяет и управляет их жизненным циклом. Coroutines basics, официальный гайд. Вся тема корутин — про иерархию и управление жизнью задач, и CoroutineScope — это точка входа в неё. Разберём по слоям: что внутри скоупа, как устроена отмена и чем SupervisorJob отличается от обычного Job.
Что такое CoroutineScope: контекст и иерархия
CoroutineScope — это объект, который хранит CoroutineContext: набор элементов, описывающих, как выполняются корутины. Главные из них — Job (жизненный цикл) и диспетчер (на каких потоках крутится работа). Скоуп передаёт этот контекст всем своим корутинам — они наследуют диспетчер, если не задан свой.
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-функция, и это разные вещи.
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, официальный гайд.
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-справка.
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, переживающая конфигурацию, но не отменяемая при уходе с экрана. Критическое правило: скоуп, созданный руками, нужно отменять руками.
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, разберём ваши пробелы и составим план подготовки.