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

23 KiB
Raw Blame History

title, description, tags
title description tags
Структурированная конкурентность CoroutineScope, иерархия Job, coroutineScope и supervisorScope, жизненный цикл и GlobalScope
kotlin
coroutines

Структурированная конкурентность - главное архитектурное решение корутин: каждая корутина принадлежит области видимости (scope) и имеет родителя. Из этого следуют три гарантии:

  1. родитель не завершится, пока не завершатся дети
  2. отмена родителя отменяет всех детей
  3. ошибка ребёнка доходит до родителя
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 их просто не вызвать.

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:

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-функция, которая создаёт дочернюю область и ждёт её завершения:

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-функцию, которая внутри работает параллельно: снаружи она выглядит как обычная функция, возвращающая значение, и гарантированно не оставляет фоновых задач после возврата.

Ошибка внутри отменяет остальных детей:

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:

import kotlinx.coroutines.*

fun main() = runBlocking {
    supervisorScope {
        launch {
            delay(20)
            throw IllegalStateException("первый упал")
        }
        launch {
            delay(50)
            println("второй выжил")
        }
    }
    println("область завершилась")
    // Exception in thread ... IllegalStateException: первый упал
    // второй выжил
    // область завершилась
}

Ошибка первого не затронула второго. Обратите внимание: исключение всё равно печатается - его нужно обработать, иначе оно уйдёт в обработчик по умолчанию. Подробнее: Исключения в корутинах

coroutineScope supervisorScope
ошибка ребёнка отменяет остальных детей и область не затрагивает остальных
ошибка пробрасывается наверх да, из блока нет, идёт в CoroutineExceptionHandler
отмена сверху отменяет всех детей отменяет всех детей
когда применять части одной задачи независимые подзадачи

Иерархия Job

Job - дескриптор корутины: её состояние и связи с родителем и детьми.

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 и переходы между ними:

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 --> [*]

Флаги состояния:

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 можно передать явно, и тогда корутина станет ребёнком именно его:

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: почему его не надо

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

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, оправдано только там, где «запустить и забыть» - это и есть контракт.

Ожидание нескольких результатов

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 именно потому, что проблема была признана фундаментальной:

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:

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() отменяет всё дерево кооперативно и предсказуемо. Подробнее: Отмена и таймауты

Сравнить: Взаимодействие между потоками в Java

[!warning] Подводные камни GlobalScope - утечка по определению. Задача не привязана ни к чему, не отменяется при закрытии компонента и может переживать его. В Android это классическая причина обращения к уничтоженному экрану.

CoroutineScope(...) без cancel() - та же утечка:

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 при первой ошибке:

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 в билдер разрывает связь с родителем:

import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(Job()) {            // свой Job — runBlocking НЕ ждёт
        delay(200)
        println("не напечатается")
    }
    println("main вышел")
    // main вышел
}

launch(Job()) выглядит безобидно, но делает корутину сиротой. Чтобы просто сменить диспетчер, передавайте только диспетчер.

supervisorScope не защищает от ошибки в своём теле:

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:

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 делает его непригодным навсегда:

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-функции.

Далее: Билдеры корутин