【学习笔记】Kotlin协程 系列一:一次跟踪的过程

前情摘要

很久很久之前,就说要看协程。

于是书也买了,看也看了,忘也忘了。

最后的最后,还是来跟一下源码,比较实在。

看书笔记

没猜错,就是《深入理解 Kotlin 协程》

协程的概念

  • 函数或者一段程序,能够被挂起,并且能够在挂起点被恢复。
  • 挂起和恢复,都是程序逻辑自己控制,而非操作系统。

综上:协程,就是程序自己去处理挂起和恢复,来实现程序的控制流程,是一种协作式调度。这里要与线程进行区分,线程之间是抢占式的调度。

协程 VS 线程

  • 协程:通过主动挂起-出让运行权,从而实现协作。本质上讨论的是程序的控制流程机制。
  • 线程:通过抢占式的调度,不存在协作。线程挂起和线程执行,都取决于操作系统的调度。

协程分类

按照栈的有无 以及 调度权转移的对称性,进行分类。

而协程的核心,主要是程序自己去处理挂起和恢复。

1、栈的有无

  • 有栈协程:每一个协程都有自己的调用栈,类似线程的栈内存。
    • 那么此时,协程和线程的区别主要体现在调度执行上,线程是由操作系统来控制,而协程是由程序逻辑自己控制。
    • 特点1:可以在任意函数调用层级的任意位置去挂起,转移调度权。
    • 特点2:由于为协程开辟了一块栈内存,因此内存开销也会增加。
  • 无栈协程:协程没有自己的调用栈,通过状态机或者闭包等语法来实现挂起点的状态,从而实现挂起和恢复。
    • 特点1:内存开销比有栈协程小,优势。
  • Kotlin协程,一般被认为是一种无栈协程的实现,因为其控制流的转移,是依靠“对 协程体 本身编译生成的状态机的状态流转”来实现的,且变量的保存也是通过闭包语法来实现。
  • 但是,Kotlin也拥有有栈协程的特点,即Kotlin协程可以在挂起函数的任意调用层次挂起。 通过 suspend 函数支持嵌套调用的方式,实现了 Kotlin协程 支持在任意挂起函数的调用层次中挂起。

2、调度权转移的对称性

  • 对称协程:调度权可以在任意协程之间转移。协程之间都是互相独立且平等的。
  • 非对称协程:协程出让调度权,该调度权会转移给之前调用它的协程;协程之间存在调用与被调用的关系。
  • Kotlin 的挂起函数,是非对称协程的例子,同样可以通过封装实现对称协程。

源码跟踪

下面的流程会比较枯燥,但是也算是拨开云雾见青天的流程,纯粹是记录。

  • 实现:在协程体suspend{}中调用挂起函数,在挂起函数中通过启动一个子线程来模拟异步操作实现挂起,最后在子线程中恢复协程。
  • ① 构造一个协程体:suspend{} 协程体会被编译成一个内部类。详见下一节【关于协程体的描述】。
    • 继承链:SuspendLambda:ContinuationImpl:BaseContinuationImpl:Continuation.
    • 接口实现:Function1
    • 我们对于协程体内部的实现,是通过调用了一个挂起函数 suspendMethodA()。
  • ② 通过 (suspend () -> T).createCoroutine(completion: Continuation):Continuation 获得一个 Continuation 对象。最终就是通过该对象来启动协程。
    • 入参:接受一个 Continuation 对象作为"完成回调"。
    • 当协程体所对应的 Continuation 执行完成之后,"完成回调"所对应的 Continuation 会被执行。这里的执行完成,可能是一个 result,也可能是一个异常。
    • 由字节码可知,我们代码中配置的"完成回调"所对应的 Continuation,同样会被编译成一个内部类。该内部类直接实现了 Continuation 接口。

  • ③ 构造了两个 Continuation 实例对象,通过 ContinuationKt.createCoroutine() 得到一个 Continuation。
    • 两个入参,分别对应协程体和"完成回调"所对应的 Continuation 实例。
    • 跟踪字节码可发现,此时会得到一个 SafeContinuation 实例对象。也就是说,最终通过 SafeContinuation.resume() 启动协程。
  • ④ 对得到的 Continuation 对象,调用其 resume()/resumeWith() 启动协程。
    • 调用链路:SafeContinuation.resume()/resumeWith() -> BaseContinuationImpl.resumeWith()。

CoroutineSingletons 枚举

这里先简单讲一下三个枚举值

internal enum class CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED }

  • COROUTINE_SUSPENDED:说明执行被暂停,不会立即返回任何结果。
  • UNDECIDED:SafeContinuation默认情况下的初始状态。
  • RESUMED:说明执行被恢复,在 resumeWith() 方法被调用时,如果当前状态为挂起标记 COROUTINE_SUSPENDED,那么就会通过 CAS 将状态改为恢复 RESUMED。

SafeContinuation.resumeWith()

  • resumeWith():通过 ContinuationKt.createCoroutine() 得到一个 Continuation,其得到的 SafeContinuation 实例所对应的 result 的初始值为 COROUTINE_SUSPENDED,因此此时会走该分支。
    • 操作1:通过 CAS 操作,将私有静态变量 RESULT 修改为 RESUMED。如果修改成功,则进行第二个操作。
    • 操作2:调用代理对象 delegate.resumeWith() 方法然后结束返回。
  • delegate.resumeWith():由编译后的字节码文件可知,此处的代理对象为调用该挂起函数的协程体【suspend{} 函数】所对应的 Continuation 实现类对象。
    • 协程体所对应的 Continuation 实现类,其继承链为 SuspendLambda:ContinuationImpl:BaseContinuationImpl:Continuation.
    • 这里可以追溯到 BaseContinuationImpl.resumeWith() 的实现。

BaseContinuationImpl.resumeWith()

  • 由图可知,此时会调用抽象方法 invokeSuspend() 。
    • 这里就往回走,查看 协程体所对应的 Continuation 实现类 对 invokeSuspend() 的重写处理。
    • 此处,为状态机的第一次调用。
  • 处理流程
    • ① 如果 invokeSuspend() 抽象方法的返回值为挂起标记 COROUTINE_SUSPENDED,则直接return,循环结束,方法执行也结束。
    • ② 如果 invokeSuspend() 抽象方法的返回值不是挂起标记,则会根据方法返回值构造对应的 Result 实例。然后继续往下走
      • 如果完成回调所对应的 Continuation 是 BaseContinuationImpl 的子类,则只会将该执行结果 Result 记录为下一次循环调用invokeSuspend()方法的入参对象 param。
      • 如果完成回调所对应的 Continuation 不是 BaseContinuationImpl 的子类,那么就会直接将该执行结果 Result 作为方法参数,通过 Continuation.resumeWith() 方法传递给完成回调所对应的 Continuation,以进行恢复调用。

协程体所对应的 Continuation 实现类

  • 背景:协程体会被编译成一个内部类,该内部类继承了 SuspendLambda,跟踪链路发现其为 Continuation 的子类之一。
    • SuspendLambda:ContinuationImpl:BaseContinuationImpl:Continuation.
  • 由此可知,协程体本身,就是一个 Continuation 对象。
  • 这里主要关注 invokeSuspend() 的处理。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cRc3UmWm-1653467142579)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1ecefdb103374464bda0234821f4ace5~tplv-k3u1fbpfcp-zoom-in-crop-mark:1956:0:0:0.image)]

invokeSuspend()
  • 第一次调用 invokeSuspend() 时,label 为0,走 label 为 0 的流程。
    • 按照方法体的执行顺序可知,此时会先 log() 打印日志之后,将 label 置为1,然后紧接着去调用挂起函数 suspendMethodA()。
    • 根据挂起函数的返回值,进行返回。因此可以知道,控制整个协程的程序逻辑,取决于挂起函数的返回值。
  • 第二次调用 invokeSuspend() 时,走 label 为 1 的流程。
    • 此时在检查完异常之后,就会将方法参数 result 直接返回 return 出去。
  • 可知,这里采用的是一个状态机的模式。
  • 由于 invokeSuspend() 方法可知,当调用挂起函数,可能会得到一个挂起标记 COROUTINE_SUSPENDED。如果得到的是挂起标记,此时会直接 return,方法执行结束。
    • 正是通过这个挂起标记 COROUTINE_SUSPENDED,外部协程就可以知道该函数需要挂起并等待异步操作的执行完成。详见 挂起标记的描述。
    • 反之,如果挂起函数的执行结果返回的不是挂起标记,那么此时返回值为挂起函数的返回值,同步返回。

挂起函数的内部处理

  • 我们通过内联函数 suspendCoroutine(){},获取当前所在的协程体【suspend{} 函数】所对应的 Continuation 实例作为方法参数,并暂停当前运行的 coroutine 。
  • 在这里,我们对挂起函数的实现,是通过构造并启动一个子线程来模拟异步执行/异步调用,并且在子线程中恢复协程的执行。

  • 由编译之后的字节码可知
    • 会根据入参,构造一个新的 SafeContinuation 实例对象。
      • 这个 SafeContinuation 对应的是挂起函数。
      • 这里的入参 Continuation 对象,实际上是协程体所对应的 Continuation 对象,即协程体被编译之后生成的 Continuation 实现类对象。
    • block(safe) 其实就是我们的执行代码。比如示例中,构造并启动一个子线程,然后在子线程中对协程体进行恢复调用。
      • ThreadsKt.thread&default():内部实现是通过构造并启动一个线程,线程执行时候会执行内部类 Test1suspendMethodAsuspendMethodAsuspendMethodA2$1 实例的 invoke() 方法。invoke()方法的方法体,就是我们在 thread{} 里面的添加的执行操作。
    • safe.getOrThrow() :执行挂起函数所对应的 SafeContinuation 实例对象的 getOrThrow() 方法,并直接返回该方法的返回值。
      • 也就是说,协程体所对应的 Continuation 实现类的 invokeSuspend() 方法,对挂起函数的调用所得到的返回值,实际上就是挂起函数所对应的 Continuation 实现类对象 SafeContinuation . getOrThrow() 方法的返回值。
    • 这里需要注意,如果 block(safe) 执行的过程中就直接调用了 Continuation.resumeWith() 恢复调用,那么第三步 safe.getOrThrow() 执行时会直接获取到执行结果,此时不会被挂起。而如果 block(safe) 执行的过程中没有进行恢复调用,那么第三步 safe.getOrThrow() 执行时会得到一个挂起标记。【详见1详见2
  • 这里有两个需要关注的地方:
    • 关注1:线程内部的执行体,我们通过调用方法参数 Continuation.resume(XXX)->Continuation.resumeWith(Result.success(XXX)) 操作来恢复协程。此时调用的是挂起函数所对应的 SafeContinuation 实例对象的 resumeWith() 方法来进行恢复。
    • 关注2:由编译后的字节码文件可知,在构造并启动线程之后,会继续调用第一步所创建的 SafeContinuation 对象的 getOrThrow() 方法进行下一步的处理,这个 SafeContinuation 实例对应的是挂起函数。
SafeContinuation.getOrThrow()

  • 默认情况下,SafeContinuation 实例所对应的 result 的初始值是 UNDECIDED。
  • 此时会对 result 进行判断。
    • 如果 result 仍是初始状态 UNDECIDED,表示该 Continuation 没有被挂起或者恢复,此时就会通过 CAS 操作将私有静态变量 RESULT 修改为挂起标记 COROUTINE_SUSPENDED。
      • 如果操作成功,直接返回挂起标记。那么此时,上层调用,也就是协程体所对应的 Continuation 实现类的 invokeSuspend() 方法中对挂起函数的调用,就会得到一个挂起标记。由上述可知,当得到的是一个挂起标记,那么会直接 return,方法执行结束。
      • 如果操作失败,也就是说在执行期间有别的异步操作的处理已经将私有静态变量 RESULT 修改(埋下一个伏笔),那么会走下一步流程:直接根据 result 进行下一步处理。
    • 如果 result 不是初始状态 UNDECIDED,那么就会直接根据 result 进行下一步处理。
      • 如果 result 为 RESUMED,返回挂起标记;
      • 如果 result 为 Result.Failure,则抛异常;
      • 否则,直接返回 result。
  • 这里可以关注一下,私有静态变量 RESULT 的修改,有两个地方。
    • 修改位置1:SafeContinuation.getOrThrow(),当 result 为初始状态 UNDECIDED 时,就会尝试进行修改,置为挂起标记 COROUTINE_SUSPENDED。
    • 修改位置2:SafeContinuation.resumeWith(),① 当 result 为初始状态 UNDECIDED 时,就会尝试进行修改,置为挂起标记 COROUTINE_SUSPENDED。② 当 result 为挂起标记 COROUTINE_SUSPENDED 时,就会尝试进行修改,置为恢复标记 RESUMED。
异步执行的场景假设
  • 假设1:异步操作已经执行完毕,也就是示例中线程执行并对协程执行了恢复调用 SafeContinuation.resumeWith()。由于 resumeWith() 会对私有静态变量进行修改,因此可知,此时挂起函数中 SafeContinuation.getOrThrow() 对私有静态变量 RESULT 的修改会失败。
    • 追溯到 SafeContinuation.resumeWith() 的执行,会通过 CAS 操作将私有静态变量 RESULT 修改为 RESUMED。如果修改成功,则调用代理对象 delegate.resumeWith() 方法然后结束返回。此处的 delegate 代理对象,为协程体所对应的 Continuation 实现类对象,调用其所对应的 Continuation.resumeWith() 方法进行恢复。
    • 继续追溯到 BaseContinuationImpl.resumeWith() ,可知此时会再一次执行协程体所对应的 Continuation 实现类的 invokeSuspend() 方法,且此时走的是 label 为 1 的流程。那么此时,就会将线程所模拟的异步操作的执行结果,直接进行返回。
    • 又由 BaseContinuationImpl.resumeWith() 的操作处理可知,此时 invokeSuspend() 方法返回的不是一个挂起标记,那么就会继续往下执行。而由于挂起函数所构造的 Continuation 实例对象,不是 BaseContinuationImpl 的实现类,而是 SafeContinuation【看这里】,并且其所对应的完成回调 completion:Continuation 就是我们构造并传递的完成回调实现类对象,因此可知,此时会直接走else分支,并执行完成回调所对应的 Continuation.resumeWith() 方法并将结果 Resule.Success()/Failure() 进行返回。
  • 假设2:异步操作还没执行完毕。也就是示例中的线程还未执行完毕,因此也没有对协程进行恢复。那么此时挂起函数中 SafeContinuation.getOrThrow() 对私有静态变量 RESULT 的修改会成功,将其修改为挂起标记 COROUTINE_SUSPENDED 并返回。

重点1:挂起点

  • 挂起函数的调用处,就叫做挂起点。比如我们在协程体【suspend{} 函数】中调用了挂起函数 suspendMethodA()。
  • 协程需要调度的位置,其实就是挂起点的位置。

  • 当协程执行到挂起点的位置时,如果产生了异步调用,那么协程就会在这个位置被挂起,即在该挂起点挂起。
    • 异步调用-情况1:挂起点所对应的挂起函数内部,切换了线程,并且在该线程内部调用 Continuation.resume() 来进行恢复。
      • 比如我们的示例里面,在挂起函数中创建并启动了一个子线程去执行操作,并且在子线程中进行恢复调用。
    • 异步调用-情况2:挂起点所对应的挂起函数内部,通过某种事件循环机制,将 Continuation 对象的恢复调用 resume(),转移到了新的线程调用栈上去执行。
      • 比如通过 Handler.post() 来实现这样的操作,将 Continuation.resume() 的调用切换到执行线程上去执行。
      • 实际上,此过程中不一定会发生线程切换。
    • 异步调用-情况3:挂起点所对应的挂起函数内部,将 Continuation 实例对象保存起来,且在后续某个时机再执行恢复调用 Continuation.resume()。此时,函数调用栈发生了变化。
      • 实际上,此过程中也同样不一定会发生线程切换。
  • 综上,也就是说,协程的挂起和恢复并不一定是线程发生了切换,只要协程的挂起和恢复不在同一个函数调用栈上执行,那么就是挂起点挂起的充分条件挂起函数不一定会真的挂起,挂起函数只是提供了挂起的环境条件
  • 当且仅当挂起点真正被挂起,才会调度执行。否则大家都是同步调用,那么也不用进行恢复。
  • 所谓的挂起:指的是当程序的执行流程发生了异步调用,也就是说如果挂起函数中出现了异步操作/异步调用,那么当前协程就会被挂起,等待被恢复执行;否则则不会被挂起。
  • 所谓的恢复:指的是如果协程被挂起,那么直到对应的 Continuation.resume() 函数被调用,协程才会恢复。
  • 所谓的异步调用:取决于 挂起函数的调用对应的 Continuation.resume() 函数的调用,是否在同一个函数调用栈上。
  • 这里需要关注的是:实现协程的调度执行,就是协程的调度器。而实际上,调度器是拦截器的实现类【之后会说】

协程 VS 线程:调度执行

对比简述
线程Java线程会被映射成内核线程,而内核线程的调度执行,就是操作系统处理的。操作系统在切换线程执行的时候,也会对线程进行挂起和恢复,取决于操作系统为线程分配CPU资源。
协程Kotlin协程的挂起和恢复,取决于开发者的程序逻辑。这意味着协程的调度,不是交给操作系统。

重点2:挂起函数的原理

  • 挂起函数的原理:CPS 变换-Continuation-Passing-Style Transformation。
    • 挂起函数其实就是普通函数的参数多了一个 Continuation 实例作为入参。
    • 通过传递 Continuation 来控制异步调用的流程。
  • Kotlin 协程,正是基于 Continuation 来实现挂起和恢复的。
    • Kotlin 协程挂起时,将挂起点的信息保存在 Continuation 实例对象中。Continuation 携带了协程继续执行所需要的上下文信息。
    • Kotlin 协程恢复执行的时候,只需要执行该Continuation的恢复调用resume()/resumeWith()方法,并且把需要的result或者异常传入即可。
  • 挂起函数只能在协程体内 或者 其他挂起函数内 被调用。
    • 协程体:由于协程体【suspend{} 函数】本身就是一个 Continuation 对象,因此挂起函数可以在协程体中运行。
    • 挂起函数:由于挂起函数的入参中会持有一个 Continuation 对象,因此在挂起函数中嵌套调用别的挂起函数,实际上就是将 Continuation 对象在挂起函数之间进行传递,同时保证了Kotlin协程之间的调用与被调用的关系,维护了整个链路。
  • 综上可知,正因为协程体和挂起函数中,都有一个隐含的 Continuation 实例,因此编译器能够对这个 Continuation 实例进行正确的传递,从而让我们的异步代码看起来像同步代码一样。
  • 特点1:协程可以通过调用挂起函数 suspend 来实现挂起。
  • 特点2:协程可以通过 Continuation 的恢复调用来实现恢复。
  • 特点3:协程可以通过绑定一个上下文来设置一些数据从而丰富协程的能力。

重点:待续

  • 状态机
  • 非阻塞式挂起
  • CoroutineContext
  • 拦截器
  • 调度器
  • 异常处理
  • 等等等等等等等等等等等等等等等等

文末

我总结了一些Android核心知识点,以及一些最新的大厂面试题、知识脑图和视频资料解析。

需要的小伙伴点击文末小卡片直接可以领取哦!我免费分享给你,以后的路也希望我们能一起走下去。(谢谢大家一直以来的支持,需要的自己领取)

Android学习PDF+架构视频+面试文档+源码笔记

部分资料一览:

  • 330页PDF Android学习核心笔记(内含8大板块)

  • Android学习的系统对应视频

  • Android进阶的系统对应学习资料

  • Android BAT大厂面试题(有解析)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值