Как стать автором
Обновить

Kotlin Coroutines под капотом: CoroutineContext и CoroutineScope

Время на прочтение12 мин
Количество просмотров3.3K

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.

Такая организация дает следующие фишки:

  1. Корутины не висят в воздухе, они обязательно должны быть закреплены за определенным CoroutineScope, это уменьшает возможное количество ошибок.

  2. При запуске новой корутины создается новый CoroutineScope у которого свой CoroutineContext, это очень удобно для построения иерархии, например можно создать связь между дочерней Job'ой и родительской.

  3. 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:

  1. Использовать билдер функцию CoroutineScope()

  2. Написать свою реализацию интерфейса CoroutineScope

  3. Использовать 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'а, последний меняется по двум причинам:

  1. Сама корутина является элементом контекста таким как Job и поэтому новый CoroutineScope должен содержать контекст с обновленной Job'ой.

  2. При создании корутины вы можете задать новый контекст, тем самым поменять или добавить новые элементы, но тут надо быть осторожным так как можно указать Job'у, не привязанную к родительской, что приведет к потере возможности отменить сразу весь CoroutineScope.

Таким образом можно строить иерархии корутин - создавать связи между родительской и дочерней Job'ами, обрабатывать исключения в одном месте через CoroutineExceptionHandler и тд.

Заключение

Давайте подведем итоги:

  1. Structured Concurrency - это возможность оперировать иерархией корутин через единый интерфейс, например можно отменить все корутины с помощью CoroutineScope.cancel() метода.

  2. Корутины могут запускаться только в пределах CoroutineScope, что позволяет ограничивать их жизненный цикл и уменьшить возможное количество ошибок.

  3. CoroutineScope это простейший интерфейс, содержащий в себе единственное поле coroutineContext, может быть создан через функцию билдер CoroutineScope(), реализован самостоятельно или предоставлен корутиной, последняя кстати сама реализует CoroutineScope интерфейс.

  4. CoroutineContext - это структура данных, основанная на паттерне Компоновщик, хранит в себе важные штуки для выполнения корутин такие как CoroutineDispatcher, Job, CoroutineName, CoroutineExceptionHandler, CoroutineId и тд.

  5. Для изменения CoroutineContext'а используется оператор +, важно что фактически новые элементы добавляются только когда их нет, в противном случае заменяются старые.

  6. При запуске корутины создается новый CoroutineScope с измененной версией CoroutineContext'а, чаще всего меняется только Job'а, так как корутина сама является Job'ой и должна предоставлять своим детям актуальное значение контекста, дополнительно вы можете поменять CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler и другие элементы контекста.

  7. Несмотря на то что CoroutineContext может быть изменен для каждой корутины, есть элементы которые используются не во всех корутинах, например CoroutineExceptionHandler актуален только для самой высокоуровневой корутины, поэтому нет смысла менять этот элемент в дочерних корутинах.

Полезные ссылки:

  1. Мой технический телеграм канал

  2. Предыдущая статья по корутинам

  3. Курс по корутинам от Android Broadcast

  4. Официальная дока

  5. Крутой доклад от создателя библиотеки

  6. Исходный код корутин

Пишите в комментах ваше мнение и всем хорошего кода!

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+4
Комментарии0

Публикации

Работа

Ближайшие события