深潜Koltin协程:协程上下文

Posted RikkaTheWorld

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深潜Koltin协程:协程上下文相关的知识,希望对你有一定的参考价值。

系列电子书:传送门


如果查看协程构建器的定义,你将看到它们的第一个参数类型是 CoroutineContext

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job 
    ...

函数的接收者和最后一个参数的接收者都是 CoroutineScope 类型,这个 CoroutineScope 似乎是一个重要的概念,所以来看看它的定义:

public interface CoroutineScope 
    public val coroutineContext: CoroutineContext

它似乎只是 CoroutineContext 的包装器,由此你可能会想到 Continuation 是如何定义的:

public interface Continuation<in T> 
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)

Continuation 也包含着 CoroutineContext。既然 Kotlin 最重要的协程元素也用着它,那么它一定是一个非常重要的概念,它是什么呢?

CoroutineContext 接口

CoroutineContext 是一个表示元素或元素集合的接口。它在概念上类似于 map 或者 set :它是有索引的 Element 实例集,如 JobCoroutineNameCoroutineDispatcher 等。不同寻常的是,每个 Element 也是一个 CoroutineContext。因此,集合中的每个元素自己本身就是一个集合。

这个概念很直观,想象一个杯子,它是单个元素,但是也是包含多个元素的集合。当你添加另一个杯子时,你就拥有了一个包含了两个元素的集合。

为了方便地规范和修改上下文,每个 CoroutineContext 的元素本身就是一个 CoroutineContext,如下面的例子所示(添加上下文和设置协程构建器上下文将在后面解释)。仅仅指定或添加上下文要比显示的创建集合要容易的多:

launch(CoroutineName("Name1"))  ... 
launch(CoroutineName("Name2") + Job())  ... 

这个集合中的每一个元素都有唯一的 Key 来标识它。这些键通过引用进行比较。

例如 CoroutineNameJob 实现了 CoroutineContext.Element 接口,而该接口又实现了 CoroutineContext 接口。

fun main() 
    val name: CoroutineName = CoroutineName("A name")
    val element: CoroutineContext.Element = name
    val context: CoroutineContext = element

    val job: Job = Job()
    val jobElement: CoroutineContext.Element = job
    val jobContext: CoroutineContext = jobElement

这与 SuperviseJobCoroutineExceptionHandlerDispatcher 中的分发器是一样的。这些是最重要的协程上下文。它们将在下一章中解释。

在 CoroutineContext 中找到元素

由于 CoroutineContext 类似于集合,我们可以使用 get 找到具有相同键的元素。另一种选择是使用方括号,因为在 Kotlin 中,get 方法是一个操作符,可以使用方括号来调用。就像在 Map 中,当元素在上下文时,它将被返回,否则返回null。

fun main() 
    val ctx: CoroutineContext = CoroutineName("A name")

    val coroutineName: CoroutineName? = ctx[CoroutineName]
    // or ctx.get(CoroutineName)
    println(coroutineName?.name) // A name

    val job: Job? = ctx[Job] // or ctx.get(Job)
    println(job) // null

要查找 CoroutineName,我们只需传入 CoroutineName。它不是类或类型,而是一个伴生对象。这是 Kotlin 中的一个特性:一个类的名字可以被用做它的伴生对象的引用,所以 ctx[CoroutineName]ctx[CoroutineName.Key] 的便捷写法。

data class CoroutineName(
    val name: String
) : AbstractCoroutineContextElement(CoroutineName) 
    
    override fun toString(): String = "CoroutineName($name)"
    
    companion object Key : CoroutineContext.Key<CoroutineName>

这是 Kotlinx.coroutines 的常见做法,使用伴生对象作为作为同名元素的键。这样更加容易记住。一个键可能指向一个类(CoroutineName),或一个接口(Job),该接口有许多具有相同键的类实现(如 JobSupervisorJob):

interface Job : CoroutineContext.Element 
    companion object Key : CoroutineContext.Key<Job>
    // ...

添加上下文

CoroutineContext 真正有用的地方在于它能够将两者合并到一起。

当添加两个具有不同键的元素时,最终的上下文将会响应这两个键。

fun main() 
    val ctx1: CoroutineContext = CoroutineName("Name1")
    println(ctx1[CoroutineName]?.name) // Name1
    println(ctx1[Job]?.isActive) // null
    
    val ctx2: CoroutineContext = Job()
    println(ctx2[CoroutineName]?.name) // null
    println(ctx2[Job]?.isActive) // true, 因为 “Active” 是job创建后的初始状态

    val ctx3 = ctx1 + ctx2
    println(ctx3[CoroutineName]?.name) // Name1
    println(ctx3[Job]?.isActive) // true

当添加具有相同的键的另一个元素时,就像在 map 中一样,新元素将替换前一个元素。

fun main() 
    val ctx1: CoroutineContext = CoroutineName("Name1")
    println(ctx1[CoroutineName]?.name) // Name1

    val ctx2: CoroutineContext = CoroutineName("Name2")
    println(ctx2[CoroutineName]?.name) // Name2
    
    val ctx3 = ctx1 + ctx2
    println(ctx3[CoroutineName]?.name) // Name2

空的协程上下文

因为 CoroutineContext 就像一个集合,所以我们也有一个空的上下文。这样的上下文本身不返回任何元素,如果我们把它添加到另一个上下文去,最终的行为和被添加的上下文完全一样:

fun main() 
    val empty: CoroutineContext = EmptyCoroutineContext
    println(empty[CoroutineName]) // null
    println(empty[Job]) // null

    val ctxName = empty + CoroutineName("Name1") + empty
    println(ctxName[CoroutineName]) // CoroutineName(Name1)

删去元素

还可以使用 minusKey 函数通过传入元素的键,从上下文中删除指定元素。

CoroutineContext 的减号操作符没有被重载,我认为这是因为它的含义还不够清晰,正如 《Effective Kotlin》中第12条:操作符的行为应该与其名称一致所阐述的那样。

fun main() 
    val ctx = CoroutineName("Name1") + Job()
    println(ctx[CoroutineName]?.name) // Name1
    println(ctx[Job]?.isActive) // true

    val ctx2 = ctx.minusKey(CoroutineName)
    println(ctx2[CoroutineName]?.name) // null
    println(ctx2[Job]?.isActive) // true
    
    val ctx3 = (ctx + CoroutineName("Name2"))
        .minusKey(CoroutineName)
    println(ctx3[CoroutineName]?.name) // null
    println(ctx3[Job]?.isActive) // true

折叠上下文

如果我们需要对上下文中的每个元素执行某些操作,可以使用 fold 方法,该方法与集合的 fold 功能类似,它具备:

  • 累加器初始值
  • 根据累加器的当前状态和当前被调用的元素,生成累加器下一个状态的操作
fun main() 
    val ctx = CoroutineName("Name1") + Job()
    
    ctx.fold("")  acc, element -> "$acc$element " 
        .also(::println)
    // CoroutineName(Name1) JobImplActive@dbab622e
    
    val empty = emptyList<CoroutineContext>()
    ctx.fold(empty)  acc, element -> acc + element 
        .joinToString()
        .also(::println)
    // CoroutineName(Name1), JobImplActive@dbab622e

协程上下文和构建器的关系

所以,CoroutineContext 只是保存和传递数据的一种方式。默认情况下,父协程将上下文传递给子协程,这是父协程与子协程关系产生的一种效果。我们会这样描述:子协程继承了父协程的上下文。

fun CoroutineScope.log(msg: String) 
    val name = coroutineContext[CoroutineName]?.name
    println("[$name] $msg")


fun main() = runBlocking(CoroutineName("main")) 
    log("Started") // [main] Started
    val v1 = async 
        delay(500)
        log("Running async") // [main] Running async
        42
    
    
    launch 
        delay(1000)
        log("Running launch") // [main] Running launch
    
    log("The answer is $v1.await()")
    // [main] The answer is 42

每个子协程都可以在参数中定义一个特定的上下文,这个上下文会覆盖来自父协程的上下文:

fun main() = runBlocking(CoroutineName("main")) 
    log("Started") // [main] Started
   
    val v1 = async(CoroutineName("c1")) 
        delay(500)
        log("Running async") // [c1] Running async
        42
    

    launch(CoroutineName("c2")) 
        delay(1000)
        log("Running launch") // [c2] Running launch
    

    log("The answer is $v1.await()")
    // [main] The answer is 42

一个计算协程上下文的简化公式是:

defaultContext + parentContext + childContext

子协程的上下文总是覆盖父协程上下文中具有相同键的元素。默认值仅用于未指定时。目前,当没有设置 ContinuationInterceprot 时,默认值为 Dispatcher.Default ,并且只有当应用程序在调试模式时才设置 CoroutineId

有一个特殊的上下文叫做 Job,它是可变的,用于父协程与子协程的通信。接下来的章节将会专门讨论这种通信的影响。

在挂起函数中访问上下文

CoroutineScope 有一个可用于访问上下文的 coroutineContext 属性,但是在一个普通的挂起函数中,是如何拥有上下文呢?你可能还记得,在底层的协程中一章说到,上下文被 continuation 引用, continuation 被传递给每个挂起函数。因此,可以在挂起函数中访问父协程的上下文。为此,我们可以直接使用 coroutineContext 属性,该属性可以用于每个挂起的作用域。

suspend fun printName() 
    println(coroutineContext[CoroutineName]?.name)


suspend fun main() = withContext(CoroutineName("Outer")) 
    printName() // Outer
    launch(CoroutineName("Inner")) 
        printName() // Inner
    
    delay(10)
    printName() // Outer

创建我们专属的上下文

这不是一个常见的需求,但是我们可以很容易地创建自己专属的协程上下文。为此,最简单的方法是创建一个实现了 CoroutinContext.Element 接口的类。这样的类需要 CoroutineContext.Key<*> 类型的属性作为键。此键将用作标识上下文的键。通常的做法是使用该类的伴生对象作为键。下面是一个非常简单的实现协程上下文的方式:

class MyCustomContext : CoroutineContext.Element 
    override val key: CoroutineContext.Key<*> = Key

    companion object Key :
        CoroutineContext.Key<MyCustomContext>

这样的上下文非常像 CoroutineName:它将父协程传递到子协程,但任何子协程都可以用相同键的不同上下文覆盖它。要在实践中了解这一点,下面可以看到一个用于打印连续数字的上下文示例:

class CounterContext(
    private val name: String
) : CoroutineContext.Element 
    override val key: CoroutineContext.Key<*> = Key
    private var nextNumber = 0
    
    fun printNext() 
        println("$name: $nextNumber")
        nextNumber++
    
    
    companion object Key:CoroutineContext.Key<CounterContext>


suspend fun printNext() 
    coroutineContext[CounterContext]?.printNext()


suspend fun main(): Unit = withContext(CounterContext("Outer")) 
    printNext() // Outer: 0
    launch 
        printNext() // Outer: 1
        launch 
            printNext() // Outer: 2
        
        launch(CounterContext("Inner")) 
            printNext() // Inner: 0
            printNext() // Inner: 1
            launch 
                printNext() // Inner: 2
            
        
    
    printNext() // Outer: 3

我有看到自定义上下文被用作一种依赖注入的方式 —— 在生产环境中比测试环境中更容易注入不同的值,然而,我不认为这将成为标准的做法:

data class User(val id: String, val name: String)

abstract class UuidProviderContext : CoroutineContext.Element 
    abstract fun nextUuid(): String
    
    override val key: CoroutineContext.Key<*> = Key
    companion object Key :CoroutineContext.Key<UuidProviderContext>


class RealUuidProviderContext : UuidProviderContext() 
    override fun nextUuid(): String = UUID.randomUUID().toString()


class FakeUuidProviderContext(
    private val fakeUuid: String
) : UuidProviderContext() 
    override fun nextUuid(): String = fakeUuid


suspend fun nextUuid(): String =
    checkNotNull(coroutineContext[UuidProviderContext]) 
        "UuidProviderContext not present" 
    
    .nextUuid()
    
// 下面是测试函数
suspend fun makeUser(name: String) = User(
    id = nextUuid(),
    name = name
)
        
suspend fun main(): Unit 
    // 生产环境中的用例
    withContext(RealUuidProviderContext()) 
        println(makeUser("Michał"))
        // e.g. User(id=d260482a-..., name=Michał)
    

    // 测试用例
    withContext(FakeUuidProviderContext("FAKE_UUID")) 
        val user = makeUser("Michał")
        println(user) // User(id=FAKE_UUID, name=Michał)
        assertEquals(User("FAKE_UUID", "Michał"), user)
    

总结

CoroutineContext 在概念上类似于集合或映射。它是有索引的 Element 实例集,其中每个 Element 也是一个 CoroutinContext,它里面的每个元素都有唯一的 Key 用来标识它。这样, CoroutineContext 就是一种通用的将对象分组并传递给协程的方法。这些对象由协程保存,并可以决定这些协程应该如何运行(它们的状态是什么,在哪个线程,等等)。在下一章中,我们将讨论 Kotlin 协程库中最重要的协程上下文。

以上是关于深潜Koltin协程:协程上下文的主要内容,如果未能解决你的问题,请参考以下文章

深潜Koltin协程:异常处理

深潜Koltin协程:协程构建器

深潜Koltin协程:协程的内置支持 vs 协程库

深潜Koltin协程:协程的取消

深潜Koltin协程:底层中的协程

深潜Koltin协程:挂起是如何工作的?