--- title: Структурированная конкурентность description: CoroutineScope, иерархия Job, coroutineScope и supervisorScope, жизненный цикл и GlobalScope tags: - kotlin - coroutines --- Структурированная конкурентность - главное архитектурное решение корутин: каждая корутина принадлежит области видимости (scope) и имеет родителя. Из этого следуют три гарантии: 1. родитель не завершится, пока не завершатся дети 2. отмена родителя отменяет всех детей 3. ошибка ребёнка доходит до родителя ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { println("начало") coroutineScope { launch { delay(100); println("медленный ребёнок") } launch { delay(20); println("быстрый ребёнок") } } println("конец — только после всех детей") // начало // быстрый ребёнок // медленный ребёнок // конец — только после всех детей } ``` Без этого свойства асинхронный код неизбежно порождает «потерянные» задачи: никто не знает, сколько их работает и когда они закончатся. ## CoroutineScope Scope - это объект с одним свойством `coroutineContext`, в котором есть `Job`. Билдеры (`launch`, `async`) - расширения на `CoroutineScope`, поэтому без scope их просто не вызвать. ```kotlin import kotlinx.coroutines.* class DataLoader : CoroutineScope { private val job = SupervisorJob() override val coroutineContext = job + Dispatchers.Default fun load(id: Int) = launch { delay(20L * id) // разные задержки — порядок предсказуем println("загружено $id") } fun close() { job.cancel() // отменяет всё, что запущено этим scope } } fun main() = runBlocking { val loader = DataLoader() loader.load(1) loader.load(2) delay(80) loader.close() println("загрузчик закрыт") // загружено 1 // загружено 2 // загрузчик закрыт } ``` Такой класс владеет своими корутинами: `close()` гарантированно останавливает всё. Это основной способ связать корутины с жизненным циклом компонента. Чаще используют не наследование, а поле - так интерфейс класса не загрязняется методами scope: ```kotlin import kotlinx.coroutines.* class Service2 { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun start() { scope.launch { delay(20) println("работа выполнена") } } fun stop() = scope.cancel() } fun main() = runBlocking { val s = Service2() s.start() delay(50) s.stop() println("остановлено") // работа выполнена // остановлено } ``` Функция `CoroutineScope(context)` - фабрика, а не конструктор интерфейса. Обратите внимание: в `SupervisorJob()` - именно скобки, иначе получится ссылка на конструктор. ## coroutineScope: вложенная область `coroutineScope { }` - `suspend`-функция, которая создаёт дочернюю область и ждёт её завершения: ```kotlin import kotlinx.coroutines.* suspend fun loadAll(): List = coroutineScope { val a = async { delay(50); "первый" } val b = async { delay(30); "второй" } listOf(a.await(), b.await()) } fun main() = runBlocking { println(loadAll()) // [первый, второй] } ``` Это стандартный способ сделать `suspend`-функцию, которая внутри работает параллельно: снаружи она выглядит как обычная функция, возвращающая значение, и гарантированно не оставляет фоновых задач после возврата. Ошибка внутри отменяет остальных детей: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { try { coroutineScope { launch { try { delay(1000) println("не напечатается") } finally { println("ребёнка 1 отменили") } } launch { delay(20) throw IllegalStateException("ошибка во втором") } } } catch (e: IllegalStateException) { println("поймано: ${e.message}") } // ребёнка 1 отменили // поймано: ошибка во втором } ``` Это свойство «всё или ничего»: если часть параллельной работы провалилась, продолжать остальную обычно бессмысленно. ## supervisorScope: независимые дети Когда дети не должны зависеть друг от друга, нужен `supervisorScope`: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { supervisorScope { launch { delay(20) throw IllegalStateException("первый упал") } launch { delay(50) println("второй выжил") } } println("область завершилась") // Exception in thread ... IllegalStateException: первый упал // второй выжил // область завершилась } ``` Ошибка первого не затронула второго. Обратите внимание: исключение всё равно печатается - его нужно обработать, иначе оно уйдёт в обработчик по умолчанию. Подробнее: [Исключения в корутинах](Исключения%20в%20корутинах.md) | | `coroutineScope` | `supervisorScope` | |---|---|---| | ошибка ребёнка | отменяет остальных детей и область | не затрагивает остальных | | ошибка пробрасывается наверх | да, из блока | нет, идёт в `CoroutineExceptionHandler` | | отмена сверху | отменяет всех детей | отменяет всех детей | | когда применять | части одной задачи | независимые подзадачи | ## Иерархия Job `Job` - дескриптор корутины: её состояние и связи с родителем и детьми. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val parent = launch { val child1 = launch { delay(1000) } val child2 = launch { delay(1000) } println("детей у родителя: ${coroutineContext[Job]!!.children.count()}") child1.cancel() println("child1 отменён: ${child1.isCancelled}") println("child2 активен: ${child2.isActive}") child2.cancel() } parent.join() println("родитель завершён: ${parent.isCompleted}") // детей у родителя: 2 // child1 отменён: true // child2 активен: true // родитель завершён: true } ``` Состояния `Job` и переходы между ними: ```mermaid stateDiagram-v2 [*] --> New: "launch(start = LAZY)" [*] --> Active: "launch / async" New --> Active: "start() / join()" Active --> Completing: "тело завершилось" Completing --> Completed: "все дети завершились" Active --> Cancelling: "cancel() / ошибка" Completing --> Cancelling: "cancel() / ошибка ребёнка" Cancelling --> Cancelled: "все дети отменены" Completed --> [*] Cancelled --> [*] ``` Флаги состояния: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { delay(1000) } println("${job.isActive} ${job.isCompleted} ${job.isCancelled}") // true false false job.cancel() job.join() println("${job.isActive} ${job.isCompleted} ${job.isCancelled}") // false true true } ``` Важная тонкость: у отменённой корутины `isCompleted` тоже `true` - завершение включает и успешное, и отменённое. Различать их нужно через `isCancelled`. ## Job как часть контекста `Job` можно передать явно, и тогда корутина станет ребёнком именно его: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val group = Job() launch(group) { delay(1000); println("не напечатается 1") } launch(group) { delay(1000); println("не напечатается 2") } val independent = launch { delay(30); println("независимая выполнилась") } delay(10) group.cancel() // отменяет только своих детей independent.join() println("готово") // независимая выполнилась // готово } ``` Так делают «группы» задач, отменяемые вместе. Но осторожно: при явном `Job` корутина перестаёт быть ребёнком `runBlocking`, и тот не будет её ждать. ## GlobalScope: почему его не надо ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch { delay(1000) println("этого вы не увидите") } delay(20) println("main завершился, не дождавшись") // main завершился, не дождавшись } ``` Корутина в `GlobalScope` живёт до конца процесса, её никто не отменит и никто не узнает об ошибке. Аннотация `@DelicateCoroutinesApi` поставлена специально - API помечено как опасное. Правильные альтернативы: | Вместо `GlobalScope` | Используйте | |---|---| | фоновая задача в сервисе | свой `CoroutineScope` с явным `cancel()` | | задача на время запроса | `coroutineScope` внутри обработчика | | задача в Android ViewModel | `viewModelScope` | | задача в Android UI | `lifecycleScope` | | задача в тесте | `runTest` | | утилита или `main` | `runBlocking` | Единственный оправданный случай - задача, которая действительно должна жить весь процесс (например, периодическая отправка метрик), и даже тогда лучше завести явный `applicationScope`. ## Утечка через внешний launch ```kotlin import kotlinx.coroutines.* // плохо: функция возвращается, а работа продолжается неизвестно где fun CoroutineScope.badLoad(): Unit { launch { delay(100); println("ещё работаю") } } // хорошо: функция suspend и не оставляет хвостов suspend fun goodLoad(): String = coroutineScope { val d = async { delay(20); "результат" } d.await() } fun main() = runBlocking { println(goodLoad()) // результат badLoad() println("badLoad вернулся, но задача ещё жива") delay(150) // результат // badLoad вернулся, но задача ещё жива // ещё работаю } ``` Правило: публичная функция, выполняющая асинхронную работу, должна быть `suspend` и возвращать результат. Расширение на `CoroutineScope`, запускающее `launch`, оправдано только там, где «запустить и забыть» - это и есть контракт. ## Ожидание нескольких результатов ```kotlin import kotlinx.coroutines.* suspend fun loadPage(n: Int): String { delay(20L * n); return "страница $n" } fun main() = runBlocking { val pages = coroutineScope { (1..3).map { async { loadPage(it) } }.awaitAll() } println(pages) // [страница 1, страница 2, страница 3] val results = coroutineScope { (1..3).map { async { loadPage(it) } }.map { it.await() } } println(results) // [страница 1, страница 2, страница 3] } ``` `awaitAll` отличается от `map { it.await() }` поведением при ошибке: `awaitAll` падает сразу, как только упал любой из `Deferred`, а последовательные `await` дождутся своей очереди. > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | `ExecutorService` + `shutdown()` | `CoroutineScope` + `cancel()` | > | `CountDownLatch`, `invokeAll` | `coroutineScope`, `awaitAll` | > | `Future.cancel` по одной задаче | отмена по дереву | > | `StructuredTaskScope` (Java 21+) | `coroutineScope` | > | ошибка в `Runnable` теряется | ошибка идёт родителю | > | задачи не знают о родителе | иерархия `Job` | > > **В Java задачи плоские.** `ExecutorService` - это пул: вы отправляете задачу и получаете `Future`. Если задача запустит ещё три, пул о них ничего не узнает, `shutdown()` их не дождётся, а `Future.cancel` на родителе их не остановит. Отслеживать такие связи приходится вручную - списками `Future`, `CountDownLatch` и флагами. > > **`StructuredTaskScope` в Java 21 - прямой аналог `coroutineScope`.** Он появился в том же Project Loom именно потому, что проблема была признана фундаментальной: > ```java > try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { > var user = scope.fork(() -> loadUser(id)); > var orders = scope.fork(() -> loadOrders(id)); > scope.join().throwIfFailed(); > return new Result(user.get(), orders.get()); > } > ``` > Сравните с Kotlin: > ```kotlin > import kotlinx.coroutines.* > > suspend fun loadUser3(id: Int): String { delay(10); return "u$id" } > suspend fun loadOrders3(id: Int): List { delay(10); return listOf("o$id") } > > suspend fun result(id: Int): String = coroutineScope { > val user = async { loadUser3(id) } > val orders = async { loadOrders3(id) } > "${user.await()} ${orders.await()}" > } > > fun main() = runBlocking { println(result(1)) } // u1 [o1] > ``` > Идея одна и та же: блок не завершится, пока не завершатся подзадачи. `ShutdownOnFailure` соответствует `coroutineScope`, `ShutdownOnSuccess` - гонке через `select`. Отличие в том, что в Kotlin это единственный способ работы, а в Java - новый опциональный API поверх существующих плоских пулов. > > **Ошибка в `Runnable` исчезает.** В `executor.submit(task)` исключение складывается в `Future`, и если `get()` никто не вызовет, об ошибке не узнает никто. В `executor.execute(task)` оно уйдёт в `UncaughtExceptionHandler` потока. Корутины гарантируют, что ошибка дойдёт до родителя и, если не обработана, до обработчика области. > > **Завершение пула против отмены области.** `shutdown()` ждёт текущих задач, `shutdownNow()` прерывает потоки, а прерывание работает только на блокирующих вызовах. `scope.cancel()` отменяет всё дерево кооперативно и предсказуемо. Подробнее: [Отмена и таймауты](Отмена%20и%20таймауты.md) > > Сравнить: [Взаимодействие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md) > [!warning] Подводные камни > **`GlobalScope` - утечка по определению.** Задача не привязана ни к чему, не отменяется при закрытии компонента и может переживать его. В Android это классическая причина обращения к уничтоженному экрану. > > **`CoroutineScope(...)` без `cancel()` - та же утечка:** > ```kotlin > import kotlinx.coroutines.* > > class Leaky { > private val scope = CoroutineScope(Dispatchers.Default) > fun start() { scope.launch { delay(10_000) } } > // нет метода stop — корутина живёт до конца процесса > } > > fun main() = runBlocking { > Leaky().start() > println("объект больше не нужен, а корутина работает") > delay(20) > // объект больше не нужен, а корутина работает > } > ``` > Если класс создаёт scope, у него обязан быть метод освобождения. > > **`Job()` вместо `SupervisorJob()` в собственном scope роняет весь scope при первой ошибке:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val scope = CoroutineScope(Job()) > scope.launch { throw IllegalStateException("первая задача") } > delay(20) > val second = scope.launch { println("не запустится") } > delay(20) > println("scope отменён: ${!scope.isActive}, вторая отменена: ${second.isCancelled}") > // scope отменён: true, вторая отменена: true > } > ``` > Для долгоживущего scope почти всегда нужен `SupervisorJob`, иначе одна ошибка выводит из строя весь компонент. > > **Передача `Job` в билдер разрывает связь с родителем:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > launch(Job()) { // свой Job — runBlocking НЕ ждёт > delay(200) > println("не напечатается") > } > println("main вышел") > // main вышел > } > ``` > `launch(Job())` выглядит безобидно, но делает корутину сиротой. Чтобы просто сменить диспетчер, передавайте только диспетчер. > > **`supervisorScope` не защищает от ошибки в своём теле:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > try { > supervisorScope { > launch { delay(1000) } > throw IllegalStateException("ошибка в теле, не в ребёнке") > } > } catch (e: IllegalStateException) { > println("поймано: ${e.message}") // поймано: ошибка в теле, не в ребёнке > } > } > ``` > Supervisor меняет поведение только для **детей**; код самого блока ведёт себя обычно и отменяет область. > > **`coroutineScope` внутри `launch` не делает дочерние ошибки локальными.** `coroutineScope` пробрасывает исключение наверх; если наверху `launch`, оно уйдёт родителю и может уронить всю область. Локализовать нужно через `try/catch` вокруг `coroutineScope` или через `supervisorScope`. > > **Наследование `CoroutineScope` классом публикует `launch` в API:** > ```kotlin > import kotlinx.coroutines.* > > class Exposed : CoroutineScope { > override val coroutineContext = SupervisorJob() + Dispatchers.Default > } > > fun main() = runBlocking { > val e = Exposed() > e.launch { println("любой может запустить корутину в чужом scope") } > delay(20) > e.cancel() > // любой может запустить корутину в чужом scope > } > ``` > Внешний код получает возможность запускать задачи в вашем scope. Поле `private val scope` предпочтительнее наследования. > > **`cancel()` на scope делает его непригодным навсегда:** > ```kotlin > import kotlinx.coroutines.* > > fun main() = runBlocking { > val scope = CoroutineScope(SupervisorJob()) > scope.cancel() > val job = scope.launch { println("не выполнится") } > delay(20) > println("корутина сразу отменена: ${job.isCancelled}") // корутина сразу отменена: true > } > ``` > Отменённый scope не «перезапускается» - нужно создавать новый. В Android это причина ошибок при повторном использовании ViewModel-scope. > > **`awaitAll` и `map { await() }` ведут себя по-разному при ошибке.** `awaitAll` падает при первой ошибке любого элемента; последовательные `await()` сначала дождутся предыдущих. Для fail-fast нужен `awaitAll`. > > **Возврат `Deferred` из публичной функции ломает структурированность.** Вызывающий может не вызвать `await()`, и ошибка потеряется, либо вызвать его после закрытия scope. Возвращайте готовое значение из `suspend`-функции. Далее: [Билдеры корутин](Билдеры%20корутин.md)