【学习笔记】Kotlin协程 系列二:拦截器、调度器

998 阅读6分钟

重点3:协程上下文-CoroutineContext

  • 协程的上下文,是一个集合。元素类型为 Element:CoroutineContext。
  • 协程的上下文实现,实际上就是一个单链表,且 Key 相同的上下文会被后者覆盖掉。
    • 类似 List,index 就是 Key
    • 类似 Map,Key-Value 一对一,覆盖。

重写了操作符 plus

  • 这里真的是有趣,后面有机会再。。。Flag 🚩

实现①:CoroutineName

  • 作用:为协程指定名称。

使用方式

inline val coroutineContext

  • 具体实现:就是通过调用 CoroutineContext.get() 方法获取指定 Key 所对应的元素。

实现②:CoroutineExceptionHandler

  • 作用:启动协程时安装一个统一的异常处理器,用于处理未捕获的异常。
  • 这部分后面再搞一个和取消cancel一起说,因为还会牵扯到 CoroutineScope。。。Flag 🚩

实现③:ContinuationInterceptor

  • 功能1:拦截协程异步回调时的恢复调用。
  • 功能2:操纵协程的线程调度,即控制线程切换。
  • 可以通过添加拦截器,对协程的挂起点的恢复调用 resume/resumeWith() 实现一些AOP操作。

重点4:拦截器-ContinuationInterceptor

使用方式1:自定义拦截器

  • 为上下文添加一个拦截器 ContinuationInterceptor:那么每一次对 Continuation.resume()/resumeWith() 的调用都会被拦截和处理。
  • 由上述可知,示例中我们一共触发了两次resume()。
    • 位置1:主线程中通过 resume(Unit) 启动协程,也就是黄色箭头标记处。
    • 位置2:挂起函数中进行恢复调用 resume(6),也就是蓝色箭头标记处。

使用方式2:调度执行

  • 这里后面继续补充,要不然一篇的内容就太大了 Flag 🚩

工作机制

  • 对原有的 Continuation 实例,进行修改包装,进而实现协程的调度。
  • 系列一的第三步,忽略了一个小细节。
  • 通过 ContinuationKt.createCoroutine() 方法构造 Continuation 实例时,方法内部实际上构造并返回的是一个 SafeContinuation 对象。
  • SafeContinuation 的入参:
    • 参数1:delegate: Continuation
    • 参数2:initialResult: Any?
  • 关于 delegate,由系列一可知,这里其实是一个代理对象,最终的操作都是通过它来执行的。
  • 由于截图可知,这里其实调用了一个函数 .intercepted(),目的(不一定对,后续会验证)就是为了去获取被拦截器拦截过后的 Continuation 对象。

  • 转换成字节码:可知这里调用了 IntrinsicsKt.intercepted() 方法进行处理。

  • 这里看回去 ContinuationImpl.intercepted() 方法的处理。

  • 由此可知,这里其实是为了从 Continuation 对象的上下文中,获取类型为拦截器的元素,并且通过调用拦截器的 ContinuationInterceptor.interceptContinuation() 方法,入参传递原始的 Continuation 对象,通过该方法得到一个新的包装对象 Continuation。反之,如果上下文中没有包含拦截器的元素,那么直接返回该原始的 Continuation 对象。
  • 这里看回去拦截器的实现:【地址】,一般情况下我们继承拦截器 ContinuationInterceptor ,需要重写实现 interceptContinuation() 方法。通过重写,返回一个新的包装类对象 XXXContinuation,通过该包装类,在里面去实现拦截处理。比如示例中我们对 resumeWith() 进行了拦截和打印。

插播一下:自定义 Continuation

利用了 by 委托特性

补充系列一部分:重点1-挂起点

状态机

  • 每一个挂起点,都会被表示为状态机的一个状态 state;而"状态"则是由编译后生成的内部类中的局部变量 label 来表示。
  • 在 invokeSuspend() 方法中可以看到,正是通过 label,从而知道当前应该执行状态机中的哪一个状态所对应的case分支。
  • 可见,挂起函数 与 Continuation 一起构成了一个有限状态机(FSM,即 Finite-State Machine),来控制协程代码的执行。

非阻塞式挂起

  • 由 invokeSuspend() 方法可知,此时如果得到一个挂起标记,则表示挂起点需要被挂起,此时会直接返回挂起标记给外部。
  • 又由 BaseContinuationImpl.resumeWith() 可知,如果 invokeSuspend() 的调用返回了一个挂起标记,则会直接 return ,方法执行结束。
  • 通过这种「结束方法调用」的方式,让协程暂时不在这个线程上面执行,让线程可以去处理其它的任务(包括执行其它的协程),这也就是为什么协程的挂起不会阻塞当前的线程,这也是「非阻塞式挂起」的由来。

挂起点挂起后的恢复

小结:协程恢复的实质是对 Continuation 进行回调。

  • 其实就是层层往上地调用 Continuation 的 invokeSuspend() 方法,从过程来看有点像递归调用,但是 BaseContinuationImpl.resumeWith() 的实现却和递归不太一样。
  • 它的实现是在while(true)循环中,对 Continuation 调用一次 invokeSuspend() 方法,然后记录它的返回结果。这个时候会对下一个执行的 Continuation 对象的类型进行做区分,也就是 completion:Continuation 完成回调所对应的 Continuation 对象的类型进行判断。
    • ① 如果 completion:Continuation 是 BaseContinuationImpl 类型,则会将当次 Continuation.invokeSuspend() 方法调用的返回结果记录并作为 completion:Continuation.invokeSuspend() 方法调用时的入参。
    • ② 如果 completion:Continuation 不是 BaseContinuationImpl 类型,那么就会直接将当次 Continuation.invokeSuspend() 方法调用的返回结果作为 completion:Continuation.resumeWith() 方法调用时的入参直接进行恢复调用。
  • 针对第一种情况,其实就是协程中挂起点的嵌套调用。简单来讲,就是在调用一个 Continuation1.invokeSuspend() 方法,待这个方法执行结束后,再调用下一个 Continuation2.invokeSuspend() 方法。
    • 这样做的一个原因是避免调用栈过深,在BaseContinuationImpl.resumeWith() 方法里面也有相关的注释说明:this loop unrolls recursion in current.resumeWith(param) to make saner and shorter stack traces on resume.
    • 这里需要区分,通过 while(true) 循环来进行嵌套调用,并不会新增栈帧,而是在同一个栈帧中goto回调。

补充系列一部分:重点2-挂起函数

挂起函数的恢复调用

  • 恢复调用的次数为 1+n。
    • n:就是协程体内挂起点的个数,也就是那些可能被挂起的执行异步逻辑的挂起函数的个数。
    • 1:协程启动时会调用一次,通过 resumeWith() 来开启协程的执行,执行协程体从"开始"到"下一个挂起点"之间的逻辑。
  • 挂起函数不一定会挂起!
    • 当异步任务已经执行完成,结果已存在,那么这种情况下,挂起函数会直接同步返回,不会被挂起。
    • 当异步任务的结果尚未就绪,那么挂起函数就需要被挂起,并且在异步任务执行完毕之后通过 Continuation.resumeWith() 恢复调用进行返回。