深入Kotlin协程系列|图解上下文

8,729 阅读9分钟

Kotlin系列文章- Kotlin专栏

概述

协程 CoroutineContext 是使用协程时不可避免会接触到的概念,可以解释为协程上下文,这比较抽象,于是尝试去看看它的源码:

好家伙,更抽象了。能看到有个叫 Element 的东西贯穿了 CoroutineContext 的始终,可以猜测 CoroutineContext 类似一个容器,里面存着执行协程需要用到的 Element 元素,那么 CoroutineContext 里面存的是啥呢?又有啥作用?

CoroutineContext

协程中有个常用的写法,类似下面:

scope.launch(Dispatchers.IO + SupervisorJob()) {}

看到这个 + 号,可以猜测是重载了操作符,点进去看看源码:

环顾四周,还有个 Element 接口,它同时也继承了 CoroutineContext 接口,

public interface Element : CoroutineContext

多看几眼,这里面又是 key,又是 get 的,大致能猜出来这个 CoroutineContext 应该是个类 Map 结构,为什么说是个 类 Map 结构呢?因为看代码,这不像我们通常理解的 Map 容器,根据官方注释,称它是一个 indexed set,介于 set 和 map 之间的一个结构,CoroutineContext 是包含一系列 CoroutineContext 的集合。

接下来我们就来解析一下这个结构到底是怎么工作的。

Plus 操作

再回到上面 plus 的代码,可以看到除了传入的参数是 EmptyCoroutineContext 外(即 + 号右边),其他情况都返回一个 CombinedContext 对象。看看这是个啥:

// this is a left-biased list, so that `plus` works naturally
internal class CombinedContext(
    private val left: CoroutineContext,
    private val element: Element
) : CoroutineContext, Serializable

看注释和属性得知,这是一个 left-biased list。我们把影响阅读的逻辑都去掉,留下这个:

// context 就是加号右边的对象
public operator fun plus(context: CoroutineContext): CoroutineContext =
    context.fold(this) { acc, element ->
        val removed = acc.minusKey(element.key)
        if (removed === EmptyCoroutineContext) element else {
            CombinedContext(removed, element)
        }
    }

首先从 Element1 + Element2 的情况看起:

  1. Element.fold() 的源码可以知道,fold 方法就是直接执行后面的 operation 代码块,那么参数 acc = Element1element = Element2
  2. Element1.minusKey(Element2.key) 有两条分支:
    1. Element1.key == Element2.key: 则 removed = EmptyCoroutineContext,最终返回 Element2Element1 直接被忽略掉了。
    2. Element1.key != Element2.key: 则 removed = Element1,最终返回 CombinedContext(Element1, Element2)

划重点:

对于 Element1 + Element2 的情况,如果这俩 key 一样,直接返回 Element2,如果 key 不一样,那么返回的就是 CombinedContext(left = Element1, element = Element2),看起来很好理解,对吧?

接着,我们再用上面的结果去 plus 另一个 Element3 对象,即 Element1 + Element2 + Element3 = CombinedContext(left = Element1, element = Element2) + Element3,为了方便阅读,把 plus 代码再贴一下:

// context = Element3
public operator fun plus(context: CoroutineContext): CoroutineContext =
    context.fold(this) { acc, element ->
        val removed = acc.minusKey(element.key)
        if (removed === EmptyCoroutineContext) element else {
            CombinedContext(removed, element)
        }
    }
  1. 传入的 context = Element3,根据其 fold 方法可知会直接执行后面的 operation 代码块,参数 acc = CombinedContext(left = Element1, element = Element2),element = Element3

  2. 再看看 CombinedContext.minusKey(Element3.key) 方法,也有几条分支:

    1. Element2.key == Element3.key: 则 removed = Element1,Element2 被剔除了,最终 plus 返回 CombinedContext(left = Element1, element = Element3)

    2. Element2.key != Element3.key: 则接着执行 Element1.minusKey(key),又要根据 Element1.keyElement3.key 是否相等,分两个分支:

      1. 相等,则返回的 newLeft = EmptyCoroutineContext,Element1 被剔除,于是 removed = Element2,最终 plus 返回 CombinedContext(left = Element2, element = Element3)
      2. 不相等,则返回的 newLeft = Element1 == left,于是 removed = CombinedContext(left = Element1, element = Element2),最终 plus 返回 CombinedContext(left = CombinedContext(left = Element1, element = Element2), element = Element3)

划重点:

  1. 对于 Element1 + Element2 + Element3 的情况,如果三者的 key 都不一样,则结果是 CombinedContext(left = CombinedContext(left = Element1, element = Element2), element = Element3)
  2. 如果有 key 相等的情况,则始终会使用 + 号后面的 Element 对象,前面相同 key 的元素都被剔除。比如说假设 Element1 和 Element2 的 key 一样,不等于 Element3 的 key,则结果是 CombinedContext(left = Element2, element = Element3)

这个 plus 操作可以用下图表示,比较清晰的左偏结构:

image.png

Get 操作

接下来看看 get 方法。Element 的 get 方法如下:

CombinedContext 的 get 方法如下:

其实就是迭代取,先从 element 开始,如果它的 key 等于要取的 key 就直接返回,否则再从 left 里取,如此循环,直到最后的 left 不再是 CombinedContext 后,直接从它里面取,取不到就返回空的。

划重点:

可以看出 get 的迭代取元素是有优先级的,根据上面画的左偏结构图,越靠右边的元素取出的优先级越高。CombinedContext 是有层级的,它和 set 和 map 这种平面结构不同,遍历时从右到左进行,更右的元素有更高的优先级。

这里再补充一下最开始 plus 方法里忽略的代码:

可以看到永远会把 ContinuationInterceptor 这个 Key 的 Element 放到右侧,即优先级最高的位置,这个做法会使得第一次就能取出:thus is fast to get when present,我猜应该是因为它的使用频率最高,因为每次启动协程或者是 withContext 都会先调用 intercepted 去拦截。

Key是什么

好了,弄明白 CoroutineContext 的 plus 和 get 操作后,那我们又有个疑问,这个结构里的 Key 在我们实际使用中是啥?

首先看下 Key 这个接口的定义:

public interface Key<E : Element>

public interface Element : CoroutineContext {
    public val key: Key<*>
}

Key 的定义里有个范型,它必须是 Element 的子类,且每个 Element 的实现类里都需要实现 Key,我们挑几个常见的看看:

public interface Job : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<Job>
}

public interface ContinuationInterceptor : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
}

可以看出 Job.Key 和 ContinuationInterceptor.Key 都是 Key,它们是伴生对象,可以直接使用 Job 和 ContinuationInterceptor 表示。这样我们就能理解下面经常会看到的用法了:

val job = coroutineContext[Job]

就是从 coroutineContext 里通过 get 方法取出 Job.Key 这个 Key 对应的 Element 对象了,而哪些 Element 是以 Job 为 Key 的呢?可以自己翻翻源码,我们一般通过 SupervisorJob()Job() 创建出来的 Job 实例都是用 Job 作为 Key 的,包括协程 launch 源码里可以看到的 AbstractCoroutine 实例也是用 Job 作为 Key 的,对于 JobSupport 的子类,都以 Job 为 Key:

public open class JobSupport constructor(active: Boolean) : Job, ChildJob, ParentJob {
    final override val key: CoroutineContext.Key<*> get() = Job

Fold 和 MinusKey

这里再回过头来看看上面用到的 fold 和 minusKey 方法,上面只给出了这两个方法对于实际输入后的输出是啥,这个章节我们理一下这两个方法的作用。

fold

fold 方法需要传入一个 initial 初始值和一个 operation 累加算法,首先看看 Element 的 fold 方法:

// Element
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
    operation(initial, this)

直接执行 operation 算法,然后将传入的 initial 作为左值,自身作为右值(被追加)传入。结合上面 plus 传入的 operation 块可以知道:如果 + 号右边是个 Element(即只包含自己一个元素),则该元素会把自身作为被追加的元素,结果总会出现在左偏结构的右层。即:

element1 + element2
-> element2.fold(element1)
-> operation(element1, element2)
-> CombinedContext(left = element1, element = element2)

再看看 CombinedContext 的 fold 方法:

// CombinedContext
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
    operation(left.fold(initial, operation), element)

它会先对 left 进行递归运算,然后再跟 element 做运算。如果 + 号右边是个 CombinedContext,则它的右层还是在原来的位置,左层 left 会和 + 号左边融合成新的左偏结构。即:

val context = element1 + element2 = CombinedContext(left = element1, element = element2)
element3 + context
-> context.fold(element3)
-> operation(element1.fold(element3, operation), element2)
-> operation(operation(element3, element1), element2)
-> operation(CombinedContext(left = element3, element = element1), element2)
-> CombinedContext(CombinedContext(left = element3, element = element1), element2)

用一张图总结这个行为:

image.png

minusKey

分别看看 Element 和 CombinedContext 的 minusKey 方法:

// Element
public override fun minusKey(key: Key<*>): CoroutineContext =
    // 如果要去掉的是自身,则返回 EmptyCoroutineContext 空上下文,否则返回自己
    if (this.key == key) EmptyCoroutineContext else this
    
// CombinedContext
public override fun minusKey(key: Key<*>): CoroutineContext {
    // 如果最右侧就是对应的 Key 就直接返回 left 左侧节点
    element[key]?.let { return left }
    // 从左侧节点递归
    val newLeft = left.minusKey(key)
    return when {
        // 左侧节点也不包含对应 Key 的元素,则返回自身
        newLeft === left -> this
        // 左侧节点中除了对应 Key 的元素外不包含其他元素,则返回右侧元素
        newLeft === EmptyCoroutineContext -> element
        // 否则把移除了对应 Key 元素的左侧节点和右侧元素组合成新的上下文
        else -> CombinedContext(newLeft, element)
    }
}

划重点:

在 Context 结构中剔除对应 Key 的元素,然后将剩下的结构按原顺序重新组合成左偏结构。

如下图,假设 A 元素的 Key 是 a:

image.png

总结

这篇文章介绍了 CoroutineContext 的数据结构:

  1. 它是一个 left-biased list (左偏)结构,是一系列元素的集合,单个元素本身也是一个 CoroutineContext 上下文。介于 set 和 map 之间的结构,所以每个元素都有一个 Key,且对应 Key 的元素是唯一的。它是有层级的,和 set 和 map 这种平面结构不同,遍历时从右到左进行,更右的元素有更高的优先级,会被优先遍历到。
  2. 两个 CoroutineContext 相加的结果会合并成一个新的左偏结构。
    1. + 号右边是 Element 类型,则该元素会把自身作为被追加的元素,结果总会出现在左偏结构的右层;
    2. + 号右边是 CombinedContext 类型,则它的右层还是在原来的位置,左层 left 会和 + 号左边的元素融合成新的左偏结构。

image.png

通过这种方式,CoroutineContext 可以去组装上下文,并在父子协程或 withContext 的时候进行传递,每个 coroutine 中都可以拥有自己独特的上下文,用来决定这些协程该怎么运行(运行线程,异常处理等)。

后面进一步分析 Job, Dispatcher, CoroutineExceptionHandler 等上下文 Element 是咋工作的。

文中内容如有错误欢迎指出,共同进步!更新不易,觉得不错的留个再走哈~


Android视图系统:Android 视图系统相关的底层原理解析,看完定有收获。

Kotlin专栏:Kotlin 学习相关的博客,包括协程, Flow 等。

Android架构学习之路:架构不是一蹴而就的,希望我们有一天的时候,能够从自己写的代码中找到架构的成就感,而不是干几票就跑路。工作太忙,更新比较慢,大家有兴趣的话可以一起学习。

Android实战系列:记录实际开发中遇到和解决的一些问题。

Android优化系列:记录Android优化相关的文章,持续更新。