Files
obsidian/Kotlin/Корутины/Структурированная конкурентность.md
T

479 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<String> = 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<String> { 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)