深入理解Kotlin协程协程的上下文 CoroutineContext

Posted 川峰

tags:

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

CoroutineContext 使用以下元素集定义协程的行为: launch、async、runBlocking 等启动协程方式第一个参数都是需要传入一个协程上下文,当然默认的都是 EmptyCoroutineContext。可以通过向  launch 或  async 函数传递新的  CoroutineContext 替换继承的元素。  CoroutineContext接口的定义如下:
public interface CoroutineContext 

    public operator fun <E : Element> get(key: Key<E>): E?

    public fun <R> fold(initial: R, operation: (R, Element) -> R): R

    public operator fun plus(context: CoroutineContext): CoroutineContext...

    public fun minusKey(key: Key<*>): CoroutineContext

    public interface Key<E : Element>

    public interface Element : CoroutineContext ...
CoroutineContext 定义了四个核心的操作:
  • 操作符get 可以通过 key 来获取这个 Element。由于这是一个 get 操作符,所以可以像访问 map 中的元素一样使用 context[key] 这种中括号的形式来访问。
  • 操作符 plus 和 Set.plus 扩展函数类似,返回一个新的 context 对象,新的对象里面包含了两个里面的所有 Element,如果遇到重复Key的元素,那么用+号右边的 Element 替代左边的。+ 运算符可以很容易的用于结合上下文,但是有一个很重要的事情需要小心 —— 要注意它们结合的次序,因为这个 + 运算符是不对称的。
  • fun fold(initial: R, operation: (R, Element) -> R): R 和 Collection.fold 扩展函数类似,提供遍历当前 context 中所有 Element 的能力。
  • fun minusKey(key: Key<*>): CoroutineContext 返回一个上下文,其中包含该上下文中的元素,但不包含具有指定key的元素。
某些情况需要一个上下文不持有任何元素,此时就可以使用  EmptyCoroutineContext 对象,添加这个对象到另一个上下文不会对其有任何影响。                  协程下文作为一个集合,它的元素类型是什么呢?
public interface Element : CoroutineContext 

    public val key: Key<*>

    public override operator fun <E : Element> get(key: Key<E>): E? = if (this.key == key) this as E else null

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

    public override fun minusKey(key: Key<*>): CoroutineContext = if (this.key == key) EmptyCoroutineContext else this

Element 定义在 CoroutineContext 中, 是它的内部接口,不过这并不是重点,重点有两个,我们依次来分析。

  • Element 本身也实现了 CoroutineContext 接口,这看上去很奇怪,为什么元素本身也是集合了呢?其实这主要是为了 API 设计方便,Element 中是不会存放除了它自己以外的其他数据的。
  • Element 接口中有一个属性 key, 这个属性很关键。虽然我们在往 list 中添加元素的时候没有明确指出,但我们心知肚明 list 中的元素都有一个 index, 表示元素的索引,而这里协程上下文元素的 key 就是协程上下文这个集合中元素的索引,不同之处是这个索引“长”在了数据里面,这意味着协程上下文的数据在“出生”时就找到了自己的位置。
实际上它还有一个抽象类,能让我们在实现协程上下文的元素时更加方便:
public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element

创建元素不难,提供对应的 Key 即可。

协程名的实现:
public data class CoroutineName( 
    val name: String
) : AbstractCoroutineContextElement(CoroutineName) 
    public companion object Key : CoroutineContext.Key<CoroutineName>
    override fun toString(): String = "CoroutineName($name)"

协程异常处理器的实现:

public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler =
    object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler 
        override fun handleException(context: CoroutineContext, exception: Throwable) =
            handler.invoke(context, exception)
    
public interface CoroutineExceptionHandler : CoroutineContext.Element 
    public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
    public fun handleException(context: CoroutineContext, exception: Throwable)
这两类元素并不是我们随便定义的,后面会有它们各自的用处。其中 CoroutineName 允许我们为协程绑定一个名字, CoroutineExceptionHandler 允许我们在启动协程时安装一个统一的异常处理器。

协程上下文的继承

协程上下文 = 默认值 + 继承的 CoroutineContext +  构造器的上下文 参数 其中:
  • 默认值是指默认的调度器或者Name: Dispatchers.Default 是默认的 调度器 ,以及 "coroutine" 是默认的 CoroutineName;
  • 继承的 CoroutineContext 是 CoroutineScope 从父协程作用域继承的上下文;
  • 传入协程构造器的上下文参数的优先级高于继承的上下文参数,因此会覆盖对应的参数值。
请注意: CoroutineContext 可以使用 " + " 运算符进行合并。由于 CoroutineContext 是由一组元素组成的,所以加号右侧的元素会覆盖加号左侧的元素,进而组成新创建的 CoroutineContext。比如,(Dispatchers.Main, "name") + (Dispatchers.IO) = (Dispatchers.IO, "name")。

协程上下文的使用

我们把定义好的元素添加到协程上下文中,如代码清单 3-1 所示

 之所以可以这样写是因为协程上下文进行了+号运算符重载。当然也可以这样做:

这里类似于 list += listOf(l, 3) ,因为等号右边得到的实际上是一个 CoroutineContext 类型。 有了这些,我们可以把这个定义好的上下文赋值给作为完成回调的 Continuation 实例,这样就可以将它绑定到协程上了,如代码清单 3-16 所示

 为协程上下文设定协程名:

GlobalScope.launch(CoroutineName("parent")) 
        async(CoroutineName("child1")) 
            Thread.sleep(2000)
            return@async "response data"
        .await()

        async(CoroutineName("child2")) 
            Thread.sleep(2000)
            return@async "response data"
        .await()
通过协程上下文组合使用添加多个上下文类型:
suspend fun main()  
    var context: CoroutineContext = CoroutineName("CoroutineName01")

    context += CoroutineExceptionHandler  coroutineContext, throwable ->
        println("捕获到异常: $throwable 协程名字是:$coroutineContext[CoroutineName]")
    

    val job = GlobalScope.launch(context) 
        println("协程名字是:$coroutineContext[CoroutineName]")
        withContext(Dispatchers.IO) 
            println("协程名字是:$coroutineContext[CoroutineName]")
            Thread.sleep(5000)
            throw Exception("发生了异常:Error!!!!!!!!!!!") // 抛出异常
        
    

    job.join()
绑定了协程上下文,我们还没有展示如何获取这些数据,所以接下来我会简单演示下在协程中如何获取我们设置的  CoroutineExceptionHandler, 见代码清单 3-17

 不管结果如何, Continuation<T> resumeWith 一定会被调用,如果有异常出现,那么我们就从协程上下文中找到我们设置的 CoroutineExceptionHandler 的实例,调用 onError 来处理异常。当然,我们也有可能没设置 CoroutineExceptionHandler , 因此 context[Coroutine ExceptionHandler] 的结果是可空类型。

注意, context[CoroutineExceptionHandler] 中的 CoroutineExceptionHandler 实际上是异常处理类的伴生对象,也就是它在协程上下文中的 Key

在协程内部可以通过  coroutineContext 这个固定名字的全局属性直接获取当前协程的上下文,它也是标准库中的 API, 如代码清单 3-18 所示  

 提示:要捕获协程的异常,除了使用 CoroutineExceptionHandler,依然可以使用try-catch在协程体内进行捕获,但使用CoroutineExceptionHandler可以统一捕获,不用到处 try-catch

我们还可以基于上下文做一个简单的扩展方法,让协程安全的启动,定义的扩展方法如下:
fun CoroutineScope.safeLaunch(
    onError: ((Throwable) -> Unit)? = null,
    onLaunchBlock: () -> Unit
) 
    val exceptionHandler = CoroutineExceptionHandler  _, throwable ->
        onError?.invoke(throwable)
    
    this.launch(exceptionHandler) 
        onLaunchBlock.invoke()
    
使用如下:
GlobalScope.safeLaunch(onError =  throwable ->
   println(throwable.message ?: "UnKnow Error")
)  
    println("执行在协程中...")
    delay(1000L)
    val num = 999/0
    println("执行完毕...")

协程的拦截器

拦截器的使用

挂起点恢复执行的位置都可以在需要的时候添加拦截器来实现 AOP 操作。拦截器也是协程 下文的 类实现,定义拦截器只需要实现拦截器的接口,并添加到对应的协程的上下文即可,如下代码所示。 拦截器的关键拦截函数是  interceptContinuation, 可以根据需要返回一个新的  Continuation 实例。我们在  LogContinuation 的  resume With 中打印日志,接下来把它设置到上下文中,程序运行时就会有相应的日志输出,如代码清单 3-21 所示。

拦截器的 Key 是一个固定的值 Continuationlnterceptor, 程执行时会通过这个 Key 拿到拦截器并实现对 Continuation 的拦截。

拦截器的执行细节            在前面的讨论中,我们曾经提到过 个“马甲 "  SafeContinuation, 其内部有个叫作  delegate 的成员,我们之前称之为协程体,之所以可以这么讲,主要是因为之前没有在协程中添加拦截器。而添加了拦截器之后,  delegate 其实就是截器拦截之后的  Continuation 实例了 例如在代码清单 3-20 中, delegate 其实就是拦截之后的 LogContinuation 的实例。

从图 3-3 中可以清楚地 到,协程体在挂起点处先被拦截器,再被 SafeContinuation 保护了起来。想要让协程体真正恢复执行,先要过这两个过程,这也为协程支持更加复杂的调度逻辑提供了基础。除了打印日志,拦截器的作用还有很多,最常见的就是控制线程的切换,相关内容请考后续调度器实现的内容。

补充细节:

  • CoroutineContext重载了+号运算符,所以才能用+号进行运算。
  • CoroutineContext进行+运算后会生成一个 CombinedContext(也是CoroutineContext的子类), CombinedContext持有2个成员一个 left表示+号左边的对象,一个 element表示+号右边的对象,也就是按照从左往右的顺序进行结合
  • CombinedContext有点像一个单向链表的结构,头结点在最右边,尾结点在最左边。当根据key从context中进行get操作时,是从最右边开始向左查询的,如果查询到符合key的类型时就直接返回,否则就顺着链表一直往左边查询。因此如果进行+运算的是多个CoroutineName(或多个相同ContinuationInterceptor类对象)时,后面+的会覆盖前面+的对象(key相同),因为在get查询时是从最右边找第一个符合的就不找了。
  • CoroutineContext在重载+号运算符时,对拦截器进行了“特殊照顾”,即总是将最左边的拦截器先取出来,然后等处理完之后最后再拼到最右边,这样是为了提高get时的查询效率,因为get是从最右边开始查询的。所以 如果自定义了拦截器的key,起作用的永远是第一个

参考:

   《深入理解Kotlin协程》- 2020年-机械工业出版社-霍丙乾

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

深入理解Kotlin协程协程的分类与线程的区别

Kotlin 协程协程异常处理 ③ ( 协程异常处理器 CoroutineExceptionHandler 捕获异常 | 验证 CoroutineScope 协程的异常捕捉示例 )

Kotlin 协程协程异常处理 ③ ( 协程异常处理器 CoroutineExceptionHandler 捕获异常 | 验证 CoroutineScope 协程的异常捕捉示例 )

深入理解Kotlin协程协程作用域启动模式调度器异常和取消使用篇

深入理解Kotlin协程协程中的Channel和Flow & 协程中的线程安全问题

深入理解Kotlin协程使用Job控制协程的生命周期