
Structured Concurrency это одна из главных фишек Kotlin Coroutines, позволяющая оперировать иерархиями корутин через единый интерфейс, благодаря такой организации можно легко отменить сразу все корутины, имея ссылку только на самый высокоуровневый объект. В этой статье я разберу две базовые штуки на основе которых строится Structured Concurrency - CoroutineContext и CoroutineScope. Поехали!
Знакомимся: CoroutineContext и CoroutineScope.
Напишем простой примерчик с запуском корутины:
fun main() {
// запускаем новую корутину: ошибка компиляции
launch {
...
}
}
Произойдет ошибка компиляции, так как функции launch()
не существует, но если сделать вот так:
fun main() = runBlocking {
// запускаем новую корутину: все ок
launch {
...
}
}
Чтобы разобраться почему так, провалимся в исходники runBlocking()
:
fun <T> runBlocking(
context: CoroutineContext,
// блок запускаемый в runBlocking выполняется в пределах CoroutineScope
block: suspend CoroutineScope.() -> T
): T {
...
}
Обратите внимание, runBlocking()
запускает suspend блок в пределах CoroutineScope
, то есть все что вы пишите внутри этой функции имеет доступ к публичным полям и методам CoroutineScope
:
interface CoroutineScope {
// CoroutineScope имеет только одно публичное поле coroutineContext
val coroutineContext: CoroutineContext
}
fun main() = runBlocking {
// можем получить доступ к coroutineContext полю
println(coroutineContext)
}
Остался один нерешенный момент: почему корутину не получилось запустить без runBlocking()
вызова? А все просто, функция launch()
является Extension функцией для CoroutineScope
:
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
...
}
В итоге мы не можем запустить корутину без CoroutineScope
, а CoroutineScope
не может существовать без переопределения поля coroutineContext
.
Такая организация дает следующие фишки:
Корутины не висят в воздухе, они обязательно должны быть закреплены за определенным
CoroutineScope
, это уменьшает возможное количество ошибок.При запуске новой корутины создается новый
CoroutineScope
у которого свойCoroutineContext
, это очень удобно для построения иерархии, например можно создать связь между дочерней Job'ой и родительской.CoroutineContext
организован очень удобно и позволяет хранить в себе полезные штуки для выполнения корутин:CoroutineDispatcher
,Job
,CoroutineExceptionHandler
и тд.
Давайте разберемся подробнее и начнем с базовой структуры - CoroutineContext
.
Что такое CoroutineContext и как он устроен?
Прежде чем углубляться в CoroutineContext
рассмотрим интересную фишку Kotlin'а - вы можете получить реализацию интерфейса по имени класса если этой самой реализацией является companion object этого класса:
// простая аналогия корутиновского контекста
private interface CoroutineContext {
// в CoroutineContext ключами являются реализации интерфейса Key
interface Key<E : Element>
interface Element : CoroutineContext {
val key: Key<*>
operator fun <E : Element> get(key: Key<E>): E? =
if (this.key == key) this as E else null
}
}
// элемент CoroutineContext'а
private open class Job : CoroutineContext.Element {
// ключом является companion object, он реализует интерфейс Key
companion object Key : CoroutineContext.Key<Job>
// так как companion object реализует интерфейс Key можно использовать имя класса
// в котором лежит этот companion object
override val key: CoroutineContext.Key<*> = Job
override fun toString() = "Job"
}
// конкретные реализации Job будут иметь один и тот же ключ,
// так как ключом является companion object родительского класса,
// напоминаю что companion object это статическое финальное поле,
// другими словами синглетон класса
private class LaunchCoroutine : Job() {
override fun toString() = "LaunchCoroutine"
}
private class AsyncCoroutine : Job() {
override fun toString() = "AsyncCoroutine"
}
// аналогичная история с диспатчерами
private open class Dispatcher : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Dispatcher>
override val key: CoroutineContext.Key<*> = Dispatcher
override fun toString() = "Dispatcher"
}
private class DispatcherDefault : Dispatcher() {
override fun toString() = "DispatcherDefault"
}
private class DispatcherIO : Dispatcher() {
override fun toString() = "DispatcherIO"
}
// CoroutineContext который может содержать в себе 2 элемента
// нужно для примера, чтобы показать как извлекаются элементы по ключу
private class CombinedElement(
val left: CoroutineContext.Element,
val right: CoroutineContext.Element
) : CoroutineContext {
operator fun <E : CoroutineContext.Element> get(key: CoroutineContext.Key<E>): E? {
if (left.key == key) return left as E
if (right.key == key) return right as E
return null
}
}
fun main() {
val combined = CombinedElement(
LaunchCoroutine(),
DispatcherDefault()
)
// вот так можно извлекать конкретные реализации Job или Dispatcher
println(combined[Job])
println(combined[Dispatcher])
// полная форма
println(combined[Job.Key])
println(combined[Dispatcher.Key])
}
Если глянуть декомпилированный байт-код на Java то можно увидеть что компилятор Kotlin'а подставляет вместо имени класса реализацию из companion object'а:
class Job implements CoroutineContext.Element {
// companion object это статическое финальное поле класса
public static final Key Key = new Key();
...
public static final class Key implements CoroutineContext.Key {}
}
CombinedElement combined1 = new CombinedElement(
(CoroutineContext.Element)(new LaunchCoroutine()),
(CoroutineContext.Element)(new DispatcherDefault())
);
// вместо названия класса подставляется реализация companion object
CoroutineContext.Element var1 = combined1.get((CoroutineContext.Key) Job.Key);
System.out.println(var1);
// тоже самое и для Dispatcher
var1 = combined1.get((CoroutineContext.Key)Dispatcher.Key);
System.out.println(var1);
Вот такую интересную особенность Kotlin'а вы можете использовать в своих библиотеках.
Идем дальше, CoroutineContext
является структурой данных и построен на основе паттерна Компоновщик:
private interface CoroutineContext {
interface Key<E : Element>
// основной прикол паттерна Компоновщика состоит в том что есть
// один общий интерфейс, который позволяет обращаться к разным
// типам элементов одинаково, в данном случае это метод извлечения
operator fun <E : Element> get(key: Key<E>): E?
// есть конкретные элементы, такие как Job, Dispatcher и тд
interface Element : CoroutineContext {
val key: Key<*>
override operator fun <E : Element> get(key: Key<E>): E? =
if (this.key == key) this as E else null
}
}
// конкретные элементы не могут содержать другие
// в терминах паттерна они называются листами
private class Job : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Job>
override val key: CoroutineContext.Key<*> = Job
override fun toString() = "Job"
}
private class Dispatcher : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<Dispatcher>
override val key: CoroutineContext.Key<*> = Dispatcher
override fun toString() = "Dispatcher"
}
private class CoroutineExceptionHandler : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler
override fun toString() = "CoroutineExceptionHandler"
}
private class CoroutineName(val name: String) : CoroutineContext.Element {
companion object Key : CoroutineContext.Key<CoroutineName>
override val key: CoroutineContext.Key<*> = CoroutineName
override fun toString() = "CoroutineName($name)"
}
// есть комплексные элементы, которые могут содержать другие
private class CombinedContext(
val left: CoroutineContext,
val right: CoroutineContext.Element
) : CoroutineContext {
override operator fun <E : CoroutineContext.Element> get(key: CoroutineContext.Key<E>): E? {
var current = this
while (true) {
// если нашли элемент по ключу возвращаем его
current.right[key]?.let { return it }
// иначе проваливаемся глубже
val next = current.left
if (next is CombinedContext) {
current = next
} else {
return next[key]
}
}
}
}
fun main() {
// можно создавать глубокие иерархии и хранить стоко элементов скоко захочется
val combined = CombinedContext(
CombinedContext(
CombinedContext(
CoroutineExceptionHandler(),
CoroutineName("coroutine #1")
),
Dispatcher()
),
Job()
)
// получение элементов по ключам
println(combined[Job])
println(combined[Dispatcher])
println(combined[CoroutineName])
println(combined[CoroutineExceptionHandler])
}
В итоге мы имеем древовидную структуру данных у которой ключами являются реализации интерфейса CoroutineContext.Key.
Помимо извлечения элементов CoroutineContext
может суммироваться:
private class CoroutineExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) = Unit
override fun toString(): String = "CoroutineExceptionHandler"
}
fun main() {
val context = CoroutineExceptionHandler() + Dispatchers.Main + CoroutineName("coroutine #1")
println(context) // [CoroutineExceptionHandler, CoroutineName(coroutine #1), Dispatchers.Main]
// при суммировании контекстов старые значения заменяются новыми
// в данном примере меняется Dispatchers.Main на Dispatchers.Default
val contextWithChangedDispatcher = context + Dispatchers.Default
println(contextWithChangedDispatcher) // [CoroutineExceptionHandler, CoroutineName(coroutine #1), Dispatchers.Default]
// в данном примере меняется CoroutineName, Dispatcher остается прежним
val contextWithChangedName = contextWithChangedDispatcher + CoroutineName("coroutine #2")
println(contextWithChangedName) // [CoroutineExceptionHandler, CoroutineName(coroutine #2), Dispatchers.Default]
}
Если в текущем CoroutineContext
уже есть элемент с аналогичным ключом из другого контекста, то он будет заменен новым значением.
Давайте глянем как это реализовано под капотом:
// this это первый операнд в суммировании, то есть текущий
// context это второй операнд в суммировании
operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
// fold выполняет суммирование каждого элемента из context (второй операнд)
// c текущем (первый операнд) и возвращает итоговую сумму
context.fold(this) { acc, element ->
// удаляем из текущего контекста элемент, ключ которого уже есть
// removed это новый контекст без указанного элемента element
val removed = acc.minusKey(element.key)
// если текущий контекст состоял только из одного элемента и
// ключ этого элемента совпал с элементом из второго операнда,
// просто заменяем его
if (removed === EmptyCoroutineContext) element else {
// дальнейшая логика нужна чтобы сохранить CoroutineDispatcher
// в итовом контексте, так как в большинстве случаев корутины
// выполняются на диспатчерах
// Справка: ContinuationInterceptor это родительский класс
// для CoroutineDispatcher
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor)
else CombinedContext(CombinedContext(left, element), interceptor)
}
}
}
Теперь вы знаете что при суммировании CoroutineContext
элементов не всегда происходит операция добавления, а напротив существующие элементы заменяются новыми, если у них совпали ключи, благодаря такой организации вы можете поменять CoroutineDispatcher
или указать другую реализацию Job
такую как SupervisorJob
и тд.
CoroutineScope под капотом
Как уже было отмечено CoroutineScope
это простейший интефейс, содержащий CoroutineContext
:
interface CoroutineScope {
// CoroutineScope имеет только одно публичное поле coroutineContext
val coroutineContext: CoroutineContext
}
Есть несколько способов создать CoroutineScope
:
Использовать билдер функцию
CoroutineScope()
Написать свою реализацию интерфейса
CoroutineScope
Использовать
CoroutineScope
, предоставляемый корутиной (сама корутина реализуетCoroutineScope
интерфейс)
Рассмотрим каждый способ более подробно.
Билдер функция CoroutineScope()
Самый прикладной способ создать новый CoroutineScope
- использовать специальный билдер:
fun main() {
val coroutineScope = CoroutineScope(Dispatchers.Default)
println(coroutineScope)
}
// если в переданном CoroutineContext нет Job'ы, функция билдер
// добавит реализацию по умолчанию
fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
// реализация очень простая
class ContextScope(context: CoroutineContext) : CoroutineScope {
override val coroutineContext: CoroutineContext = context
// CoroutineScope is used intentionally for user-friendly representation
override fun toString(): String = "CoroutineScope(coroutineContext=$coroutineContext)"
}
Как видите CoroutineScope
это очень простая штука, можно без проблем написать свою реализацию.
Пишем свою реализацию CoroutineScope
Достаточно переопределить единственное поле:
fun main() {
val coroutineScope = object : CoroutineScope {
// указываем CoroutineContext какой нам нужен
override val coroutineContext: CoroutineContext = Dispatchers.IO
// можно добавить свой вариант toString()
override fun toString(): String = "MyScope(context=$coroutineContext)"
}
println(coroutineScope)
}
Ладно, первые 2 способа немного скучные, глянем как корутины реализует CoroutineScope
.
CoroutineScope в корутинах
Возьмем наиболее распространенный вид корутин, запускаемый через launch()
функцию:
// корутина является Job и CoroutineScope одновременно
abstract class AbstractCoroutine<in T>(
parentContext: CoroutineContext,
initParentJob: Boolean,
active: Boolean
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
// так как корутина является CoroutineScope требуется
// переопределить coroutineContext поле
override val coroutineContext: CoroutineContext get() =
// this это элемент контекста Job, которым также является корутина
parentContext + this
}
Как вы уже знаете корутина может быть создана только в пределах CoroutineScope
, а значит parentContext
принадлежит CoroutineScope
, в котором запускается корутина, но это еще не все, корутина сама является новым CoroutineScope
:
fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
// блок запускается в CoroutineScope, который реализует корутина
block: suspend CoroutineScope.() -> Unit
): Job {
...
}
launch { // корутина является CoroutineScope'ом
// контекст будет содержать другую Job - только что созданную корутину!
println(coroutineContext)
}
В итоге при запуске корутины создается новый CoroutineScope
с измененной Job'ой
в CoroutineContext'е
, в качестве новой Job'ы
выступает сама корутина, также вы можете поменять и другие элементы контекста если захотите:
launch(Dispatchers.Default) {
// кроме Job поменяется еще диспатчер
println(coroutineContext)
}
Вот так реализуется CoroutineScope
в самих корутинах.
Как работает Structured Concurrency
Пришло время собрать все знания о CoroutineScope
/ CoroutineContext
воедино и сформировать целостную картину:
private class CoroutineExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) = Unit
override fun toString(): String = "CoroutineExceptionHandler"
}
// runBlocking() это тоже корутина, а значит является CoroutineScope'ом
fun main() = runBlocking { // блок выполняется в CoroutineScope
// runBlocking() контекст содержит специальный диспатчер
// который дожидается выполнения всех корутин
println("runBlocling() CoroutineContext -> $coroutineContext")
// корутина #1
launch(CoroutineExceptionHandler()) { // при запуске новой корутины создается новый CoroutineScope
// coroutineContext содержит новый CoroutineExceptionHandler,
// диспатчер из runBlocking и другую Job,
// другой Job'ой является корутина #1
println("CoroutineContext#1 -> $coroutineContext")
// корутина #2
launch(Dispatchers.IO) { // также создается новый CoroutineScope
// coroutineContext содержит новый диспатчер Dispatchers.IO и другую Job,
// другой Job'ой является корутина #2
println("CoroutineContext#2 -> $coroutineContext")
}
// корутина #3
launch(CoroutineName("Coroutine#3")) { // новый CoroutineScope
// coroutineContext содержит новый элемент контекста - имя корутины,
// диспатчер из runBlocking и другую Job,
// другой Job'ой является корутина #3
println("CoroutineContext#3 -> $coroutineContext")
// корутина #4
launch { // новый CoroutineScope
// coroutineContext содержит диспатчер из runBlocking(),
// предыдущее имя корутины и другую Job,
// другой Job'ой является корутина #4
println("CoroutineContext#4 -> $coroutineContext")
}
}
}
Unit
}
При каждом запуске корутины создается новый CoroutineScope
с измененной версией CoroutineContext'а
, последний меняется по двум причинам:
Сама корутина является элементом контекста таким как
Job
и поэтому новыйCoroutineScope
должен содержать контекст с обновленнойJob'ой
.При создании корутины вы можете задать новый контекст, тем самым поменять или добавить новые элементы, но тут надо быть осторожным так как можно указать
Job'у
, не привязанную к родительской, что приведет к потере возможности отменить сразу весьCoroutineScope
.
Таким образом можно строить иерархии корутин - создавать связи между родительской и дочерней Job'ами
, обрабатывать исключения в одном месте через CoroutineExceptionHandler
и тд.
Заключение
Давайте подведем итоги:
Structured Concurrency - это возможность оперировать иерархией корутин через единый интерфейс, например можно отменить все корутины с помощью
CoroutineScope.cancel()
метода.Корутины могут запускаться только в пределах
CoroutineScope
, что позволяет ограничивать их жизненный цикл и уменьшить возможное количество ошибок.CoroutineScope
это простейший интерфейс, содержащий в себе единственное полеcoroutineContext
, может быть создан через функцию билдерCoroutineScope()
, реализован самостоятельно или предоставлен корутиной, последняя кстати сама реализуетCoroutineScope
интерфейс.CoroutineContext - это структура данных, основанная на паттерне Компоновщик, хранит в себе важные штуки для выполнения корутин такие как
CoroutineDispatcher
,Job
,CoroutineName
,CoroutineExceptionHandler
,CoroutineId
и тд.Для изменения
CoroutineContext'а
используется оператор +, важно что фактически новые элементы добавляются только когда их нет, в противном случае заменяются старые.При запуске корутины создается новый
CoroutineScope
с измененной версиейCoroutineContext'а
, чаще всего меняется толькоJob'а
, так как корутина сама являетсяJob'ой
и должна предоставлять своим детям актуальное значение контекста, дополнительно вы можете поменятьCoroutineDispatcher
,CoroutineName
,CoroutineExceptionHandler
и другие элементы контекста.Несмотря на то что
CoroutineContext
может быть изменен для каждой корутины, есть элементы которые используются не во всех корутинах, напримерCoroutineExceptionHandler
актуален только для самой высокоуровневой корутины, поэтому нет смысла менять этот элемент в дочерних корутинах.
Полезные ссылки:
Пишите в комментах ваше мнение и всем хорошего кода!