23 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Структурированная конкурентность | CoroutineScope, иерархия Job, coroutineScope и supervisorScope, жизненный цикл и GlobalScope |
|
Структурированная конкурентность - главное архитектурное решение корутин: каждая корутина принадлежит области видимости (scope) и имеет родителя. Из этого следуют три гарантии:
- родитель не завершится, пока не завершатся дети
- отмена родителя отменяет всех детей
- ошибка ребёнка доходит до родителя
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,invokeAllcoroutineScope,awaitAllFuture.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-функции.
Далее: Билдеры корутин