接下来跟大家分享一下我在了解 Kotlin 协程实现的过程中理解的一些概念,如果你发现哪些地方我说错了的话,欢迎提出你的理解。
1. Kotlin 协程原理概述
Kotlin 协程
的大致的执行流程如上图所示,这个流程是各种类型的协程执行时都大致遵循的流程,不是一个严格精确的执行流程。
下面先来看下协程执行过程中的一些关键类的介绍。
1. 协程代码块
当我们调用 launch()
或 withContext()
等协程构建器函数时,对应的协程代码块会被 Kotlin 编译器
转换为一个匿名内部类
,这个匿名内部类继承了 SuspendLambda
,实现了 Function2
接口。
SuspendLambda
是挂起 Lambda 表示式
的运行时类型,它重写了父类 BaseContinuationImpl
的 invokeSuspend()
方法和 create()
方法,invokeSuspend() 方法中的代码就是协程代码块中的代码,而 create() 方法则是用来实例化代码块对应的匿名类。
2. 任务
任务负责执行协程代码块
,任务指的是 DispatchedTask
的子类,比如 DispatchedContinuation
、AwaitContinuaton
以及 CancellableContinuationImpl
。
协程分发器会把 SuspendLambda 封装为任务 DispatchedContinuation
。
CancellableContinuationImpl
是在内联函数 suspendCancellableCoroutine()
中创建的,Retrofit
对 suspend 函数
的支持就是通过这个内联函数 ,CancellableContinuationImpl 中有一个简单的决策状态机
,它的状态能从 UNDECIDED
(未定) 迁移到 SUSPENDED
(挂起) ,也能从 UNDECIDED
(未定) 迁移到 RESUMED
(恢复),但是 SUSPENDED 和 RESUMED 这两个状态之间无法相互迁移。
AwaitContinuation
是 CancellableContinuationImpl
的子类。当我们调用 CoroutineScope
的 async()
扩展函数后,这个函数会返回一个 Deferred
对象,这个对象的具体实现是 DeferredCoroutine
,当我们调用 Deferred
的 await()
方法等待协程的执行结果时,DeferredCoroutine
就会创建一个 AwaitContinuaton
。
3. 协程
协程主要负责维护工作节点,包括传播取消、完成和异常等事件给工作结点和父协程
,工作结点的类型是 JobNode
,它是对任务和子协程的封装。
当我们调用协程构建器函数
启动创建协程时,这些函数的内部会创建协程 ,比如 runBlocking() 中会创建BlockingCoroutine
,launch() 中会创建 StandaloneCoroutine
,这些协程都是 JobSupport
的子类,JobSupport
实现了 Job
接口,也就是可以把协程理解为一个工作项
,工作项中可以包含子工作项或子任务。
4. 协程分发器
协程分发器负责执行任务
,协程启动后会通过协程分发器
分发(执行)任务,比如用来执行阻塞任务的 BlockingEventLoop
和默认协程调度器 DefaultScheduler
都是协程分发器 CoroutineDispatcher
的子类。
CoroutineDispatcher实现了 CoroutineContext
接口,也就是协程分发器是一个协程上下文
,在协程上下文会通过不同的 Key
保存上下文中的元素 Element,Key 和Element 是 CoroutineContext 中定义的接口, ContinuationInterceptor
和 CoroutineDispatcher
都声明了实现了 Key
接口的伴生对象,而 DefaultScheduler 和 BlockingEventLoop 则实现了 CoroutineContext 中的 Element
接口。
runBlocking()
函数使用的分发器BlockingEventLoop
中有 3 个队列,分别是无限制(优先)任务队列
、延迟任务队列
以及普通任务队列
。
而默认协程调度器 DefaultScheduler
中有 2 个全局队列和 1 个局部队列。全局队列分别是全局阻塞任务队列
和全局 CPU 任务队列
,它们的类型都是 GlobalQueue
。DefaultScheduler 实现了 Executor
接口,也就是它是一个线程池
,在它的工作者线程 Worker
类中,有一个局部任务队列
,这个队列的类型为WorkQueue
,当 Worker 运行时,会优先从局部任务队列中获取任务执行。
CoroutineDispatcher 实现了 ContinuationInterceptor
接口,启动协程需要通过协程启动选项 CoroutineStart 启动,当 CoroutineStart
通过 startCoroutineCancellable()
等方法创建任务时,会让协程分发器拦截这个任务,所谓拦截就是把接收到的 Continuation
封装为 DispatchedContinuation
。
5. 挂起点后的延续操作 Continuation
Continuation
是 Kotlin 协程中非常重要的一个概念,它表示一个挂起点之后的延续操作
。
可以把它理解为一个步骤
,而挂起点就是这些步骤之间的边界
。Continuation 中声明了一个协程上下文 context
常量和一个用于执行延续操作的 resumeWith()
方法。
如上图所示,协程代码块会根据挂起点分为一段段的Continuation
,Continuation 是以一个接一个 resume 的方式连续执行的,不同的 label 代表代码块的不同段落。
Continuation
的实现类有很多,比如 DispatchdContinuation
、SuspendLambda
以及各个协程,也就是它们都是一个挂起点之后的延续操作。协程的 resumeWith()
方般是在协程代码块中的任务都完成后,最后调用的。
任务会执行协程构建器函数
对应的代码块的代码,Kotlin 编译器
会把这些代码转化为继承了 SuspendLambda
的匿名内部类,在匿名内部类的 invokeSuspend()
方法中,就包含了协程代码块的代码,当 DispatchedContinuation
执行时,invokeSuspend()
就会以 completion
的形式被 DispatchedContinuation 调用,也就是completion 表示当前任务完成后,后续需要执行的操作。
上面这段用 Retrofit 发起网络请求的代码的执行时序图如下。
上面这段代码中用 runBlocking()
执行请求,runBlocking 使用的协程分发器是 BlockingEvent当 BlockingEventLoop
运行 DispatchedContinuation
时,DispatchedContinuation 会执行代码块 SuspendLambda
的代码,协程代码块中如果有调用 suspendCanellableCoroutine()
的话,上面这段代码是通过 Retrofit 发起请求的,这就会间接调用到 CanecllableContinuationImpl
的 getResult()
方法挂起协程,等待耗时操作执行完成后返回结果。
当OkHttp 返回响应后,就会在 onResponse()
回调中调用 CancellableContinuationImpl
的 resume()
方法把执行结果作为自己的状态。
CancellableContinuationImpl
的 resume()
方法会把自己作为任务分发到协程分发器
中,然后协程分发器就会调用到 DispatchedTask
的 run()
方法,DispatchedTask 会获取子类的状态,也就是耗时操作的执行结果,拿到这个状态后,就会再次调用 SuspendLambda 的 invokeSuspend()
方法,也就是 SuspendLambda 的 invokeSuspend()
方法被调用了两次,第一次返回的是挂起标志 COROUTINE_SUSPEND
,返回这个标志就意味着这次任务已经完成,等下一个任务启动后再继续执行。
第二次调用 invokeSuspend()
返回的是耗时操作执行结果,这时会调用 BlockingCoroutine
的 resumeWith()
,并把结果值作为 BlockingCoroutine
的状态值 。
6. 通过状态机决定恢复操作
协程挂起的实现是通过状态机让代码分段执行,协程代码块中的代码执行到挂起点后就不会继续往下执行,直到被恢复(resume) 。对于执行协程的线程来说,当协程执行到挂起点后,就认为这个任务已经执行完成了,直到耗时操作的结果回来,再恢复(resume)新的任务的执行,这时由于代码块对应的匿名内部类内部的状态(label)已经迁移到下一个状态了,所以协程恢复执行的时候会执行下一段代码。
如上图所示,协程代码块对应的匿名内部类的 invokeSuspend()
方法会根据不同的 label
值执行不同的操作,label
是 SuspendLambda
的成员变量
,默认值为 0
。
假如协程代码块中执行了两个任务(调用了两次 suspendCancellableCoroutine
) ,当 label 为 0 时,就会执行任务 1
,假如任务 1
返回了挂起标志
COROUTINE_SUSPENDED,那 SuspendLambda
就不会继续往下执行,也就是不会执行任务 2
。
当耗时操作结束后(如 OkHttp 响应回来),会调用 CancellableContinuationImpl
的 resume()
方法,resume()
方法会再次触发 SuspendLambda
的 invokeSuspend()
方法,这时由于 label
为 1
,那么就不会执行任务 1
,而是执行任务 2
,然后会通过 Result
的 throwOnFailure()
方法检查任务执行结果是否为异常,如果是异常就会抛出异常
。
除了 suspendCancellableCoroutine()
函数中会调用 CancellableContinuationImpl
的 getResult()
方法会把协程挂起以外,withContext()
方法也会调用 DispatchedCoroutine
的 getResult()
方法把父协程挂起。
7. 小结
以上图中左侧的代码为例,这段代码中使用了 viewModel
的 viewModelScope
启动了一个用 Retrofit
进行网络请求的协程,在这个协程代码块中,调用了 Service
接口的挂起函数 body()
。
对于 body()
的调用,Retrofit 会通过 suspendCancellableCoroutine()
创建一个 CancellalbeContinuationImpl
,对于 launch()
代码块,Kotlin 编译器
会把它转换为 SuspendLambda
,然后协程分发器会把 SuspendLambda
封装为 DispatchedContinuation
,CancellableContinuationImpl 和 DispatcheContinuation 都是 Task
的子类,也就是它们两个都是任务
。
DispatchedContinuation
持有了 SuspendLambda ,而 CancellableContinuationImpl 则持有了另外一个 DispatchedContinuation 实例,该实例也持有了协程代码块对应的 SuspendLambda 。DispatchedContinuation 和 CancellableContinuationImpl 在 resume 的时候都会调用 SuspendLambda 的 invokeSuspend()
方法执行协程代码块中的代码。
假如这个 ViewModel
是 Activity
的,那么在 Activity 退出的时候,ViewModel 的 clear()
方法就会被调用,clear() 会调用 CloseableCoroutineContext
的 cancel()
扩展函数,cancel() 会通过协程上下文获取到该上下文中的协程,然后会调用协程StandaloneCoroutine
的cancel()
方法取消协程的执行。StandaloneCoroutine 的父类 JobSupport
中有一个状态机,这个状态机的其中一个状态为多结点状态
,这时状态的类型为 NodeList
,也就是一个工作结点列表,通过这个工作结点列表,协程就可以把在协程代码块中启动的任务给取消掉。
2. Kotlin 协程简介
1. 什么是协程?
从广义上来说,协程
(Coroutine)是一种并发设计模式
,我们可以用它来简化异步执行的代码,Kotlin 协程是在 1.3
版本时引入的,是基于其他语言已有的概念开发的。
2. 协程有哪些特点?
协程的 4 个特点是:轻量、内存泄漏更少、内置取消支持以及 Jetpack 集成。
-
轻量
一个线程中可以包含多个协程,协程支持挂起
,不会让正在运行协程的线程阻塞,与阻塞线程相比,挂起协程的操作更轻量 -
内存泄漏更少
协程使用了结构化并发机制,可以在一个作用域内执行多个操作,可以一次性全部取消掉,这样就不用像 RxJava 一样要自己把 Disposable 放在 CompositeDisposable 里
-
内置取消支持
当我们取消
一个协程时,取消操作会在运行中的整个协程层次结构内传播
,也就是父协程取消后,子协程也会被取消 -
Jetpack 集成
Jetpack
中的ViewModel
、Lifecycle
和LiveData
都提供了对应的协程作用域
另外 Kotlin 协程框架中的挂起函数有另外一个好处,就是可以在编译时就让方法的调用方知道这是一个耗时的操作,需要确定这个操作要放在哪个线程执行,这样就不用像 Android 框架对主线程网络请求的禁止方式一样,在运行时才抛出异常。
3. 什么是结构化并发?
多线程并发是全局的,而结构化并发
中每个并发都有自己的作用域
。
结束线程时,如果想要同时结束这个线程中的子线程,可以通过自定义的共享标记位来结束。
如果想要等待所有子线程都执行完了,再结束父线程,可以使用 CountDownLatch或其他线程协作工具 。
不论是共享标记位
还是 CountDownLatch
,都是需要我们编写额外代码
才能实现的,线程之间默认是无关
的,线程执行的上下文是整个进程,这就是非结构化并发
。
但是在我们实际的开发中,经常会出现某个任务是另一个任务的子任务,而且它还有可能有自己的子任务,这时我们就不得不编写一些能实现同时取消子任务的额外代码。
但是如果并发是结构化的,每个并发操作都有自己的作用域,并且父作用域内新建的作用域都属于它的子作用域,父作用域的生命周期会持续到所有子作用域执行完,当主动结束父作用域时,能自动结束它的各个子作用域,这就是结构化并发
。
4. 协程、线程和进程之间有什么区别和联系?
进程是系统资源分配的最小单位,线程是 CPU 调度的最小单位,一个进程中可以有个多个线程,一个线程中可以有多个协程。
-
进程
进程是系统资源分配的最小单位,拥有独立的地址空间
、内存空间
、网络
和文件资源
等,不同进程之间的资源是独立的,进程之间可以通过进程间通信机制
交互,比如管道
、共享内存
和信号量
等方式 -
线程
线程是 CPU 调度的基本单位,除了拥有运行时的程序计数器
、寄存器
和栈
以外,本身不拥有系统资源,进程中的线程会共享进程的资源 -
协程
协程可以看成是运行在线程上的代码块
,协程提供的挂起
操作会让协程暂停执行,不会导致线程阻塞,一个线程内部可以创建多个协程
5. 协程挂起示例
假如把一个线程看作是一个人,把做饭
的过程看成是一个线程
要完成的事情,把菜
、饭
、汤
相关的任务
放进不同的协程
中,那么把洗好的米放进电饭煲
,就意味着与饭相关的协程可以被挂起
,把准备好的煲汤材料放进锅里煮后,那么与汤
相关的协程就可以被挂起,这时线程就可以执行与菜相关的任务。
当菜做完并且装盘后,这时如果电饭煲响了,饭煮好了,就可以把饭盛到碗完里了,也就是负责做饭相关的事情的协程恢复
执行了。
如果没有协程,就意味着我们要使用 3 个线程分别做这 3 个不同类型的事情,也就是要 3 个人,如果我们是开小饭店的话,3 个人还凑合,但是如果我们在家里自己做饭的话,3 个人一起做饭就有点多了(不包含土豪家庭)。
上面这个协程使用场景的代码如下。
上面这段代码声明了一个单线程的协程分发器
,也就是代码中的 3 个协程只会在一个线程中运行。
并且因为协程执行的线程与测试执行的线程不是同一个线程,为了避免测试线程执行完成后,协程还未结束的问题,所以测试代码中加了 CountDownLatch
,等协程执行的线程运行完成后再让测试线程继续执行。
上面这个单元测试执行后,控制台会打印如下文本。
看完了协程的介绍后,我们来简单看下 Retrofit 是怎么支持挂起函数的。
3. Retrofit 是怎么支持挂起函数的?
我们一般使用协程都是因为要使用 Retrofit
或 Room
等框架执行网络 IO 或数据库 IO ,而不是自己把协程挂起。这两个框架都支持用挂起函数获取网络响应数据和数据库的数据,下面我们就来看下 Retrofit 是怎么实现在执行网络请求时挂起协程的。
1. KotlinSuspendTest
再看一下前面提到的上面这段单元测试代码,body()
测试方法是在 runBlocking()
代码块中通过 Service 的 body()
方法模拟请求网络数据。
如上图所示,当在 KotlinSuspendTest
中调用 Retrofit 的 create()
方法创建 API 服务时,Retrofit 会通过 ServiceMethod
解析 API注解的内容。
ServiceMethod
会通过请求工厂 RequestFactory
来解析 API 注解的内容,请求工厂则会通过 HttpServiceMethod
调用 Call 接口的 await()
扩展函数,await() 函数会调用 Kotlin 协程框架中的 suspendCancellableCoroutine()
方法挂起协程。
挂起协程后,再把响应回调通过 OkHttpCall
传给 OkHttp
中的 RealCall
,把请求添加到请求队列,当从 OkHttp 中获得响应时,再调用 CancellableContinuationImpl
实例的 resume()
方法恢复协程的执行。
关于 suspendCancellableCoroutine()
函数的实现在后面会讲。
2. KotlinExtentions#await
Retrofit 中的 KotlinExtentions
的 awiat()
方法的代码如下。
如果我们想在某个耗时操作执行时挂起协程,并在获得结果时恢复协程,我们也可以使用 suspendCancellableCoroutine()
这个方法,当在获取到操作执行结果后,再调用 Continuation
实例的 resume()
即可。
比如为了避免读写 SharedPreferences
时的 ANR
问题,就可以使用这个方法,把 SharedPreferences 读写放在 suspendCancellableCoroutine()
的代码块参数中。
4. runBlocking() 原理概述
前面有讲通过 runBlocking()
结合 Retrofit
发起网络请求的一个简单的程序执行时序图,下面来看下这个过程更细化一些的调用时序。
接下来讲解的 Kotlin 源码的版本为 1.6.10
。
上面这段代码的调用时序如下,runBlocking()
的执行过程可分为协程启动过程
、任务分发过程
、协程挂起过程
以及协程恢复过程
。
1. 协程启动过程
当我们通过 runBlocking()
阻塞地获取协程执行结果时,如果当前线程的事件循环 EventLoop
为空,runBlocking()
方法中就会创建一个新的阻塞式事件循环 BlockingEventLoop
。
BlockingEventLoop 是 EventLoop
的子类,也是 CoroutineDispatcher
的子类,也就是它是一个事件循环
,同时也是一个协程分发器
。获取到或创建完事件循环后,runBlocking()
中就会创建一个阻塞式协程 BlockingCoroutine
,并调用它的 start()
启动协程,start() 方法会通过启动选项 CoroutineStart 调用 SuspendLambda 的 startCoroutineCancellable() 扩展函数,把 SuspendLambda 封装为一个 DispatchedContinuation
,并调用它的 resumeCancellableWith()
方法。
2. 任务分发过程
在 DispatchedContinuation 的 resumeCancellableWith()
方法中,会调用 BlockingEventLoop
的 dispatch()
方法把自己加入到事件循环的任务队列,然后回到 runBlocking() 方法中,在 runBlocking() 方法的最后,