之前在掘金上对Kotlin协程的解析比较零散,小小地推荐一下《深入理解Kotlin协程》,从源码和实例出发,结合图解,系统地分析 Kotlin 协程启动,挂起,恢复,异常处理,线程切换,并发等流程,只用一顿饭钱!感兴趣的朋友可以了解下,互相交流,不喜勿喷。
- Kotlin协程之基础使用
- Kotlin协程之深入理解协程工作原理
- Kotlin协程之协程取消与异常处理
- Kotlin协程之再次读懂协程工作原理-推荐
- Kotlin协程之Flow工作原理
- Kotlin协程之一文看懂StateFlow和SharedFlow
- Kotlin协程之一文看懂Channel管道
- Kotlin系列|一文看懂Lazy机制
概述
协程 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
的情况看起:
- 从
Element.fold()
的源码可以知道,fold 方法就是直接执行后面的 operation 代码块,那么参数acc = Element1
,element = Element2
。 Element1.minusKey(Element2.key)
有两条分支:Element1.key == Element2.key
: 则removed = EmptyCoroutineContext
,最终返回Element2
,Element1
直接被忽略掉了。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)
}
}
-
传入的
context = Element3
,根据其 fold 方法可知会直接执行后面的 operation 代码块,参数acc = CombinedContext(left = Element1, element = Element2),element = Element3
。 -
再看看
CombinedContext.minusKey(Element3.key)
方法,也有几条分支:-
Element2.key == Element3.key
: 则removed = Element1
,Element2 被剔除了,最终 plus 返回CombinedContext(left = Element1, element = Element3)
。 -
Element2.key != Element3.key
: 则接着执行Element1.minusKey(key)
,又要根据Element1.key
和Element3.key
是否相等,分两个分支:- 相等,则返回的
newLeft = EmptyCoroutineContext
,Element1 被剔除,于是removed = Element2
,最终 plus 返回CombinedContext(left = Element2, element = Element3)
。 - 不相等,则返回的
newLeft = Element1 == left
,于是removed = CombinedContext(left = Element1, element = Element2)
,最终 plus 返回CombinedContext(left = CombinedContext(left = Element1, element = Element2), element = Element3)
。
- 相等,则返回的
-
划重点:
- 对于
Element1 + Element2 + Element3
的情况,如果三者的 key 都不一样,则结果是CombinedContext(left = CombinedContext(left = Element1, element = Element2), element = Element3)
。- 如果有 key 相等的情况,则始终会使用
+
号后面的 Element 对象,前面相同 key 的元素都被剔除。比如说假设 Element1 和 Element2 的 key 一样,不等于 Element3 的 key,则结果是CombinedContext(left = Element2, element = Element3)
。
这个 plus 操作可以用下图表示,比较清晰的左偏结构:
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)
用一张图总结这个行为:
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:
总结
这篇文章介绍了 CoroutineContext 的数据结构:
- 它是一个
left-biased list
(左偏)结构,是一系列元素的集合,单个元素本身也是一个 CoroutineContext 上下文。介于 set 和 map 之间的结构,所以每个元素都有一个 Key,且对应 Key 的元素是唯一的。它是有层级的,和 set 和 map 这种平面结构不同,遍历时从右到左进行,更右的元素有更高的优先级,会被优先遍历到。 - 两个 CoroutineContext 相加的结果会合并成一个新的左偏结构。
+
号右边是 Element 类型,则该元素会把自身作为被追加的元素,结果总会出现在左偏结构的右层;+
号右边是 CombinedContext 类型,则它的右层还是在原来的位置,左层 left 会和+
号左边的元素融合成新的左偏结构。
通过这种方式,CoroutineContext 可以去组装上下文,并在父子协程或 withContext 的时候进行传递,每个 coroutine 中都可以拥有自己独特的上下文,用来决定这些协程该怎么运行(运行线程,异常处理等)。
后面进一步分析 Job, Dispatcher, CoroutineExceptionHandler 等上下文 Element 是咋工作的。
文中内容如有错误欢迎指出,共同进步!更新不易,觉得不错的留个赞再走哈~
Android视图系统:Android 视图系统相关的底层原理解析,看完定有收获。
Kotlin专栏:Kotlin 学习相关的博客,包括协程, Flow 等。
Android架构学习之路:架构不是一蹴而就的,希望我们有一天的时候,能够从自己写的代码中找到架构的成就感,而不是干几票就跑路。工作太忙,更新比较慢,大家有兴趣的话可以一起学习。
Android实战系列:记录实际开发中遇到和解决的一些问题。
Android优化系列:记录Android优化相关的文章,持续更新。