Изучение Котлина

Объем, контекст и работа Kotlin Coroutine стали проще

Изучите базовую экосистему Kotlin Coroutine

Спасибо Ромену Гаю за великолепное фото!

Когда я впервые исследую сопрограмму около 3 лет назад, мы могли просто вызвать launch напрямую, чтобы запустить неблокирующую сопрограмму.

Теперь это невозможно. Для его запуска нужна область видимости или использование runBlocking. Но что за прицел?

Официальный документ дает хорошее объяснение, но мне потребовалось 3 дня, чтобы медленно его переварить. Чтобы понять область действия, нужно понимать контекст сопрограммы.



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

Основное повторение: что такое Coroutine?

Прежде чем двигаться дальше, давайте вспомним, что такое Coroutine.

  • Подобно Thread, что позволяет некоторым задачам работать асинхронно.
  • В отличие от Thread, он может работать в том же процессе потока и не блокировать поток (с помощью функции приостановки). Довольно крутая функция. Обратитесь к приведенному ниже документу, чтобы узнать, как это возможно.


Давай поэкспериментируем

runBlocking {
    println("runBlocking ${Thread.currentThread()}")
    launch {
        println("launch ${Thread.currentThread()}")
    }
}

Он напечатает

runBlocking Thread[main @coroutine#1,5,main]
launch Thread[main @coroutine#2,5,main]

Чтобы понять вывод на печать Thread, обратитесь к этому StackOverflow. Не беспокойтесь о main thread и т. Д. На диаграмме. Это будет объяснено позже в разделе диспетчера ниже.

Печать идентификатора сопрограммы в приложении для Android

Примечание. Если вы распечатаете это в приложении Android с использованием Log.d("Track", "...."), по умолчанию он не будет отображать идентификатор сопрограммы. Вместо этого вы получите следующее.

Track: runBlocking Thread[main,5,main]
Track: launch Thread[main,5,main]

Чтобы распечатать идентификатор сопрограммы, вам нужно установить

System.setProperty("kotlinx.coroutines.debug", "on" )

Вы можете установить его в классе Application

class MainApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        System.setProperty("kotlinx.coroutines.debug", "on" )
    }
}

Тогда вы получите

Track: runBlocking Thread[main @coroutine#1,5,main]
Track: launch Thread[main @coroutine#2,5,main]

Что работа"?

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

runBlocking {
    val job = launch {
        try {
            println("launch job")
            delay(1000)
            println("finish job")
        } catch (e: CancellationException) {
            println("cancel job")
        }
    }
    yield() // to start the launch
    job.cancel()
}

В приведенном выше примере с job можно легко отменить сопрограмму.

launch job
cancel job

Задания могут быть иерархическими (родительско-дочерние отношения)

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

Такие родительско-дочерние отношения вызовут два поведения, описанных ниже.

1. Когда родительская работа отменяется, детская работа также отменяется.

runBlocking {
    val parentJob = launch {
        try {
            println("launch parent job")
            val childJob = launch {
                try {
                    println("launch child job")
                    delay(1000)
                    println("finish child job")
                } catch (e: CancellationException) {
                    println("cancel child job")
                }
            }
            delay(1000)
            println("finish parent job")
        } catch (e: CancellationException) {
            println("cancel parent job")
        }
    }
    delay(500)
    parentJob.cancel()
}

Обратите внимание, что когда родительский job отменяется, дочерний job отменяется, как показано в выходных данных ниже.

launch parent job
launch child job
cancel child job
cancel parent job

Однако, если мы отменим job дочернего элемента, job родителя продолжится.

val parentJob = launch {
    try {
        println("launch parent job")
        val childJob = launch {
            try {
                println("launch child job")
                delay(1000)
                println("finish child job")
            } catch (e: CancellationException) {
                println("cancel child job")
            }
        }
        delay(500)
        childJob.cancel()
        delay(500)
        println("finish parent job")
    } catch (e: CancellationException) {
        println("cancel parent job")
    }
}

Результат, как показано ниже

launch parent job
launch child job
cancel child job
finish parent job

2. Если в работе ребенка возникает ошибка, задание родителя также отменяется.

runBlocking {
    val parentJob = launch {
        try {
            println("launch parent job")
            val childJob = launch {
                try {
                    println("launch child job")
                    delay(500)
                    throw Exception()
                } catch (e: CancellationException) {
                    println("cancel child job")
                } 
            }
            delay(1000)
            println("finish parent job")
        } catch (e: CancellationException) {
            println("cancel parent job")
        }
    }
}

Обратите внимание, когда дочерний job выходит из строя, родительский job отменяется, как показано в выходных данных ниже.

launch parent job
launch child job
cancel parent job

Точно так же, когда родительский job является ошибочным, дочерний job отменяется, как показано в выходных данных ниже.

runBlocking {
    val parentJob = launch {
        try {
            println("launch parent job")
            val childJob = launch {
                try {
                    println("launch child job")
                    delay(1000)
                    println("finish child job")
                } catch (e: CancellationException) {
                    println("cancel child job")
                }
            }
            delay(500)
            throw Exception()
            println("finish parent job")
        } catch (e: CancellationException) {
            println("cancel parent job")
        }
    }
}

Результат, как показано ниже

launch parent job
launch child job
cancel child job

Эти два поведения можно суммировать, как показано на диаграмме ниже.

Что такое Диспетчер?

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

В сопрограмме мы используем Dispatchers в качестве параметра для нашей программы запуска сопрограмм (например, launch, async, runBlocking) в качестве флага, чтобы указать, в какие процессы мы хотим, чтобы она была отправлена. Обратите внимание, что диспетчер также является разновидностью контекста сопрограммы. например launch(Dispatchers.Main)

Есть 5 типов диспетчеров:

  • Dispatchers.Main, это основная тема. В отличие от других, иногда нам нужно явно определить его (например, в тестовой среде).
  • Dispatchers.IO, это для процесса "Сеть и диск". Все, что связано с извлечением или отправкой данных.
  • Dispatchers.Default, это для любого другого рабочего потока, который не является основным (т.е. фоновым), и он назначается автоматически.
  • Dispatcher.Unconfined, это специальный диспетчер, который позволяет задаче изменять целевые процессы, когда она приостанавливает и возобновляет выполнение своей задачи.
  • newSingleThreadContex, позволяют пользователю определять свои собственные процессы. После использования его нужно закрыть вручную.

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

Безпараметрическая пусковая установка

Если dispatchers не указан, сопрограмма будет отправлена ​​в те же процессы, что и вызывающая функция.

Как мы видели выше

runBlocking {
    println("runBlocking pre-yield ${Thread.currentThread()}")
    launch {
        println("launch pre-yield ${Thread.currentThread()}")
        yield()
        println("launch pos-yield ${Thread.currentThread()}")
    }
    yield()
    println("runBlocking pos-yield ${Thread.currentThread()}")
}

И runBlocking, и launch будут запущены в основном потоке. Процессы одинаковы независимо от того, до и после yield()

runBlocking pre-yield Thread[main @coroutine#1,5,main]
launch pre-yield Thread[main @coroutine#2,5,main]
launch pos-yield Thread[main @coroutine#1,5,main]
runBlocking pos-yield Thread[main @coroutine#2,5,main]

Параметризация родителя с помощью ребенка без параметров

Если мы предоставим dispatchers как параметр для runBlocking, но не launch, как показано ниже

runBlocking(Dispatchers.IO) {
    println("runBlocking pre-yield ${Thread.currentThread()}")
    launch {
        println("launch pre-yield ${Thread.currentThread()}")
        yield()
        println("launch pos-yield ${Thread.currentThread()}")
    }
    yield()
    println("runBlocking pos-yield ${Thread.currentThread()}")
}

Вы заметите, что runBlocking теперь находится в DefaultDispatcher-worker-1 процессе. launch теперь также находится на DefaultDispatcher, поскольку он следует за своим родителем. Но это на worker-3 процессах

runBlocking pre-yield Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]
launch pre-yield Thread[DefaultDispatcher-worker-3 @coroutine#2,5,main]
launch pos-yield Thread[DefaultDispatcher-worker-3 @coroutine#2,5,main]
runBlocking pos-yield Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]

Параметризация пусковых установок

Если мы предоставим dispatchers как параметр для runBlocking и launch, как показано ниже

runBlocking(Dispatchers.IO) {
    println("runBlocking pre-yield ${Thread.currentThread()}")
    launch(Dispatchers.Main) {
        println("launch pre-yield ${Thread.currentThread()}")
        yield()
        println("launch pos-yield ${Thread.currentThread()}")
    }
    yield()
    println("runBlocking pos-yield ${Thread.currentThread()}")
}

Результат такой, как показано ниже. Вы заметите, что launch больше не отслеживает свои родительские процессы.

runBlocking pre-yield Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]
runBlocking pos-yield Thread[DefaultDispatcher-worker-1 @coroutine#1,5,main]
launch pre-yield Thread[Test Main @coroutine#2,5,main]
launch pos-yield Thread[Test Main @coroutine#2,5,main]

Следует отметить, что хотя мы используем launch(Dispatcher.Main), он отличается от значения по умолчанию main. В нашем примере, мы работаем в среде модульного тестирования, это Test Main. Dispatcher.Main необходимо явно определить

private val mainThreadSurrogate 
    = newSingleThreadContext("Test Main")
@Before
fun setUp() {
    Dispatchers.setMain(mainThreadSurrogate)
}
@After
fun tearDown() {
    // reset main dispatcher to the original Main dispatcher       
    Dispatchers.resetMain() 
    mainThreadSurrogate.close()
}

А для этого нужна библиотека

testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2'

В приложении для Android прямое использование Dispatcher.Main может заблокировать процесс согласно this StackOverflow, если он конкурирует с вызывающей функцией.

Полная таблица комбинаций

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

Из приведенной выше таблицы можно сделать несколько наблюдений.

  • Когда запуск из ничего и унаследованный поток main отличается от запуска с Dispatchers.Main
  • При запуске на Dispatchers.IO или Dispatchers.Default используется тот же пул рабочих потоков (пока).
  • При использовании рабочего потока диспетчера по умолчанию нет гарантии, что он будет использовать один и тот же рабочий поток до и после yield.
  • Dispatchers.Unconfined сначала будет следовать своему родительскому контексту, но впоследствии будет следовать изменениям в зависимости от потока функции приостановки, который он использует.

Вы можете получить код, который перебирает все возможные комбинации выше здесь.

Что такое контекст сопрограммы?

Из вышесказанного мы знаем, что job и dispatcher оба являются контекстом сопрограммы. Помимо job и dispatcher, существуют другие контексты сопрограмм, такие как CoroutineName, ContinuationInterceptor и CoroutineExceptionHandler.

Контекст сопрограммы - это просто набор элементов (которые также являются контекстами), которые обеспечивают контекст для сопрограммы.

Давайте посмотрим на другие 3 контекста

Имя сопрограммы

Это просто имя для сопрограммы для упрощения отладки.

fun testCoroutineName() {
    runBlocking {
        launch(CoroutineName("My-Own-Coroutine")) {
            println(coroutineContext)
        }
    }
}

Он выведет что-то, как показано ниже

[CoroutineName(My-Own-Coroutine), CoroutineId(2), "My-Own-Coroutine#2":StandaloneCoroutine{Active}@363ee3a2, BlockingEventLoop@4690b489]

Обратите внимание, что в Android вам необходимо настроить System.setProperty(“kotlinx.coroutines.debug”, “on” ) в приложении, чтобы увидеть имя в соответствии с этим StackOverflow.

Обработчик исключений сопрограммы

Этот контекст позволяет сопрограмме иметь настраиваемую обработку исключений.

fun testCoroutineExceptionHandler() {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }

    runBlocking {
        MainScope().launch(handler) {
            launch {
                throw IllegalAccessException("Just testing")
            }
        }.join()
    }
}

Запуск его приведет к исключению, захваченному с необходимостью try-catch

CoroutineExceptionHandler got java.lang.IllegalAccessException: Just testing

Примечание. Я попытался установить runBlocking, но не смог зафиксировать ошибку. Об этом сообщается на StackOverflow.

Продолжение перехватчика

Перехватчик продолжения позволяет определить, как должны продолжаться сопрограммы.

В приведенном ниже коде я просто распечатываю его всякий раз, когда выполняется перехват.

fun testContinuationInterceptor() {
    val interception = object: ContinuationInterceptor {
        override val key: CoroutineContext.Key<*>
            get() = ContinuationInterceptor

        override fun <T> interceptContinuation(
            continuation: Continuation<T>): Continuation<T> {
            println("  ## Interception Setup for 
                ${continuation.context[Job]} ##")
            return Continuation(continuation.context) {
                println("  ~~ Interception for 
                    {continuation.context[Job]} ~~")
                continuation.resumeWith(it)
            }
        }
    }

    runBlocking(CoroutineName("runBlocker") + interception) {
        println("Started runBlocking")
        launch(CoroutineName("launcher")) {
            println("Started launch")
            delay(10)
            println("End launch")
        }
        delay(10)
        println("End runBlocking")
    }
}

В результате для каждого delay(10) будет напечатано ~~Intercdeption, как показано ниже.

## Interception Setup for "runBlocker#1":BlockingCoroutine{Active}@6a01e23 ##
  ~~ Interception for "runBlocker#1":BlockingCoroutine{Active}@6a01e23 ~~
Started runBlocking
  ## Interception Setup for "launcher#2":StandaloneCoroutine{Active}@14d3bc22 ##
  ~~ Interception for "launcher#2":StandaloneCoroutine{Active}@14d3bc22 ~~
Started launch
  ~~ Interception for "launcher#2":StandaloneCoroutine{Active}@14d3bc22 ~~
End launch
  ~~ Interception for "runBlocker#1":BlockingCoroutine{Active}@6a01e23 ~~
End runBlocking

Объединение контекста

Если вы внимательно посмотрите на код выше, вы заметите эту строку

runBlocking(CoroutineName("runBlocker") + interception)

И interception, и CoroutineName("runBlocker") являются контекстом. Используя +, мы объединяем их в новый Context, который хранит другой как Element из Context. Вот как группируются контексты сопрограмм.

Обратите внимание, что оператор + имеет некоторую защиту от повторяющихся комбинаций, например. приведенное ниже приведет к ошибке при компиляции.

runBlocking(Dispatchers.IO + Dispatchers.Main){ }

Однако оператор + не выдает ошибку во всех случаях. Например. для нижеприведенного он не выдаст ошибку, а будет использовать последнее указанное имя

runBlocking(CoroutineName("Drop Name") + 
            CoroutineName("Taken Name")){ }

Переключение контекста

Иногда нам нужно, чтобы наша сопрограмма переключалась между контекстом, находясь в той же сопрограмме. Мы можем сделать это с помощью withContext.

fun testSwitchContext() {
    newSingleThreadContext("Ctx1").use { ctx1 ->
        newSingleThreadContext("Ctx2").use { ctx2 ->
            runBlocking(ctx1) {
                println("Started in ctx1 $coroutineContext")
                withContext(ctx2) {
                        println("Working in ctx2 $coroutineContext")
                }
                println("Back to ctx1 $coroutineContext")
            }
        }
    }
}

Используя это, мы получим следующий результат

Started in ctx1 [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@7ff11457, ThreadPoolDispatcher[1, Ctx1]]
Working in ctx2 [CoroutineId(1), "coroutine#1":DispatchedCoroutine{Active}@1aefb6f2, ThreadPoolDispatcher[1, Ctx2]]
Back to ctx1 [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@7ff11457, ThreadPoolDispatcher[1, Ctx1]]

Вы заметите, что все они по-прежнему CoroutineId(1), но переключаются между Ctx1 и Ctx2.

Что такое Coroutine Scope?

Наконец, что такое Coroutine Scope? Ниже мои 3 цента

  • С точки зрения Coroutine Context может быть набором элементов контекста, чтобы лучше управлять ими, он обернут вокруг Coroutine Scope.
  • Помимо просто обертывания контекста, Coroutine Scope также отслеживает все его дочерние области. например при выполнении launch в другой области автоматически создается дочерняя область.
  • Он предоставляет все утилиты для launch, async, а также cancel и т. Д. Другими словами, это основной интерфейс разработчика для управления сопрограммами.

Эта статья описывает это более подробно.

Если вы обратили внимание на большую часть приведенного выше примера кода, мне придется использовать runBlocking, прежде чем я смогу делать какие-либо launch. Это потому, что launch может быть запущен только из CoroutineScope.

Какие у нас есть прицелы?

  • GlobalScope - если вы просто хотите иметь простой launch, не беспокоясь об управлении им, мы можем использовать GlobalScope.launch { }. Однако это не идеально, как гласит эта статья.
  • MainScope - запускает сопрограмму в основном потоке (потоке пользовательского интерфейса) по умолчанию при использовании MainScope().launch { }.
  • CoroutineScope - это позволяет вам определять настраиваемую область, предоставляя свой собственный контекст, например CoroutineScope(Dispatchers.IO).launch { }.
  • LifecycleScope - это связанная с Android область сопрограммы, которая связывает жизненный цикл Android области (то есть способность автоматически завершаться по окончании жизненного цикла Android Activity.

Управление родительско-дочерней областью

По умолчанию, когда мы выполняем launch в области сопрограммы, у нас есть родительская и дочерняя области, где отмена задания и триггер ошибки будут влиять друг на друга.

MainScope().launch {
    // this is a parent scope    
    launch { 
        // this is a child scope
    }
}

Однако, если мы хотим разъединить эту связь, мы можем сделать это, создав другой объект Scope в пределах области сопрограммы.

MainScope().launch {
    // this is a parent scope    
    GlobalScope.launch { 
        // this is a not a child scope of the parent
    }
}

Если launch выполняется вне области сопрограммы, мы также можем установить отношения родительско-дочерней области, назначив родительское задание определению дочерней области, как показано ниже.

val parentJob = MainScope().launch {
    // this is a parent scope
}

CoroutineScope(parentJob).launch {
    // this is a child scope
}

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