这篇文章是我在 2022【T沙龙】技术分享时所讲内容的文字版本,修改删减了演讲时的冗余言语,现免费开放给大家阅读,希望能给买不到票参加分享的 开源实验室 读者带来帮助。
<div align="center"></div>
然后首先有几个问题,第一个:协程是怎么来的?
<div align="center"></div>
<div align="center"></div>
这个问题其实要追溯到很久以前的一个事情上。long long ago。。。。
<div align="center"></div>
当那个时候我们计算机就是个铁块,就一个电脑可能只能去运行一个程序。比方说举个例子,像我们炒菜,一个厨师他去炒菜,那么他在同一时间的时候只能去炒一个菜。
一开始大家想要同一时间执行多个代码任务,于是就有了并发。从程序员的角度可以看成是多个独立的逻辑流,内部可以是多 CPU 并行,也可以是单 CPU 时间分片。
<div align="center"></div>
进程就是这样抽象出来个一个概念,搭配虚拟内存、进程表之类,用来管理独立的程序运行、切换。
但是一并发就有上下文切换的问题,干了一半跑去处理另一件事,我这做了一半的东西怎么保存。
就好像我们刚刚那个厨师炒菜的例子,那这个时候就像相当于是同一个厨师,他可以一次炒两个菜。那么这个菜他可能刚刚炒了一半,我放了油,放了盐这些调味品了以后,突然跟我说要我再继续去炒下一个菜,当前这个菜先放到一边。
<div align="center"></div>
后来硬件提升了,一电脑上有了好几个 CPU 就可以一人跑一进程,就是所谓的并行。
但是一并行,进程数一高,大部分系统资源就得用于进程切换的状态保存。后来搞出线程的概念,大致意思就是这个地方阻塞了,但我还有其他地方的逻辑流可以计算,不用特别麻烦的切换页表、刷新 TLB,只要把寄存器刷新一遍就行。
<div align="center"></div>
但是线程是一个非常麻烦的东西,因为他是操作系统调度的,你可能正在run,不知道什么情况,突然你的CPU就被交给另一个线程了,你就得靠边站,等着下一次翻牌子了。
你没办法决定线程在何时运行,你甚至不能正常停止一个线程。做 Java 的同学应该都知道,线程的停止只能是 run 的内容执行完或者出异常时,当然还有一种会造成死锁的方法就是直接调用stop()。
我们看图,箭头最右侧竖线表示线程已经结束了。由于你在主线程无法知道子线程什么时候开始,什么时候结束,甚至有可能主线程都结束了,子线程还在跑。
<div align="center"></div>
就是因为这种情况,后来引入了一个概念,叫用户态线程。
如果你嫌操作系统调度线程有不确定性,不知道什么时候开始什么时候切走,其实是可以自己在进程里面手写代码去管理逻辑调度,这就是用户态线程。
而用户态线程是不可剥夺的,也就是说不会像内核态线程那样,run 着 run 着 CPU 就没了。通常控制权是程序员交给用户的,而用户最了解何时需要放弃执行权,这么做减少了系统切换次数,实现了最高的 CPU 利用率。
但是有一个问题,毕竟是模拟的操作系统调度线程,操作系统内核不知道多线程的存在,如果一个用户态线程发生了阻塞,就会造成整个进程阻塞,所以进程需要自己拥有调度线程的能力。
<div align="center"></div>
而如果用户态线程将控制权交给进程,让进程调度自己,这就是协程。
<div align="center"></div>
接下来是第二个问题:协程真的能提升我们的程序的性能吗?
这也就说到了一个经常会提到的点,就是协程能够提升我们代码的性能。但事实上协程提升的这个性能并不是我们开发的过程中的那种代码的效率,而是协程真正能够提升我们程序的性能的原因,是因为:
<div align="center"></div>
大家看到这张图红色的这个位置,它在有一个任务正在被阻塞着,然后它去执行另一个任务。其实这个时候就是一个协程的状态了,它阻塞了一个协程,同时阻塞状态的时候它却调用了另一个协程。那么这个时候实际上阻塞的只是一个协程而已。然后我们的线程依旧是一个顺序执行的状态,也就是它减少了一次线程的切换。
<div align="center"></div>
这也就是为什么我们说协程能够提升性能,其实它真正提升性能的原因是让我们的线程不停地跑,让线程一直去跑,减少了线程切换给我们程序带来的一个线程切换的开销,从而达到提升了我们整个程序的性能的原因。
<div align="center"></div>
那么除了这一点,协程还有一些其他的优点。
- 比方说协程的可控制:我们前面讲,线程的结束都没办法由我们程序员通过代码去直接控制它。但是协程是可以的,因为协程其实在我们代码中它的本质就是一个函数,然后函数挂起了以后,我们继续去调用下一个函数,做了一次函数的切换,所以它本质是可以被控制的。
- 协程的轻量级:网上有很多文章都会讲到这一点,就是协程的轻量级,它占用的资源非常小。回到我们刚刚讲的协程,其实它的本质就是一个函数,如果说只是一个函数的运行的话,那么他当然占用的资源是很小的。
- 是协程的安全性。我们大家应该都做过多线程开发。多线程开发的时候有一个非常大的问题,就是我们得要考虑数据的线程安全性。而协程其实它的本身是没有并发概念的,就像我们前面介绍的那张图,如果说一个协程发生了阻塞的话,它其实只仍然是在当前线程里面切换到了下一个协程去继续执行它,协程与协程之间的任务都是一个线性的,所以它其实是不具备并发能力的。而如果说真正我们在写代码的时候依然是需要考虑并发现程安全问题,是因为我们现代的编程语言都是有多线程概念的,就算我们没有再发起线程,我们还有一个主线程在继续执行的,所以我们的协程都是运行在线程里面的,那么一定程度上它也具备了多线程的一个并发执行的能力,造成我们不得不应对多线程的数据安全问题。
- 第四点就是语法糖的优势。对于不同的语言来讲,都有不同的语法糖。几乎所有的具备协程能力的编程语言。比方说像 Python、go、C# 这样的语言。他们对于协程专门做了一层语法糖的封装,可以帮助我们更方便地去书写一个线性的代码。
<div align="center"></div>
那么再回到我们今天的一个主题,就是,如果我们自己去设计一个安卓协程库的话我们要怎么去设计呢?
<div align="center"></div>
首先一个完整协程库,它所具备的功能应该是启动挂起这两个能力。然后还有线性的能力,比方说我们当前有一个协程,它需要被优先的执行。我们需要能够对这个协程做一个校验,然后同时把前一个协程挂起,最后当前的协程执行完了以后,再继续恢复之前先挂起的那个协程,再继续执行。
这样的这样的一些基本能力我们肯定是得要实现的。而如果要实现这样一些基本的能力的话,我们完整的一个思路肯定是分三步。
<div align="center"></div>
第一步协程肯定是运行在线程里面的,所以我们基于 C 语言去构建一个 C 的线程, C 里面有一个 < pthread.h > 的库,它是可以直接帮我们构建在 C 层的线程的, C++ 也可以直接去调用这个库完成一个线程的创建。
<div align="center"></div>
构建完线程了以后,就开始构建我们的协程,然后将构建出来的协程交给 C 线程去调度。按顺序去一个一个的执行协程。
<div align="center"></div>
第三步就是保证我们协程的一个线性状态。就是当一个协程我们调用它 delay 方法了以后,应该是可以把当前的协程挂起,然后继续去执行接下来的协程的。
另外,对于另一个协程调了他的 join() 方法的话,它应该是可以优先地插入到当前正在执行的这个协程里面,同时打断也就是挂起当前正在执行的协程,然后优先去执行我们插入的这个协程。
当插入的这个协程执行完了以后,然后再恢复之前被挂起的协程,让他继续去执行。
<div align="center"></div>
Talking is chip, show me the code。
接下来我们来看代码,这个库其实我之前已经写了一个 demo 是放在了 github 上面,大家可以通过下面的这个链接或者直接扫就能看到这样的一个库。看完记得点个star:
https://github.com/kymjs/AndroidCoroutine
<div align="center"></div>
首先第一步是我们要通过在 Java 层定义一个线程的实现,也就是一个线程它所要具备的一些能力。因为我们最终的目标是需要给安卓去使用它,所以我们肯定也是需要通过 JNI 调到 C 层方法的。
<div align="center"></div>
首先我们左上角的这个 JNI 入口,这里我们主要看到最下面的一行就是调了 OSThread 的 start() 方法。然后 OSThread 其实对应了我们刚刚在 Java 层声明的那个线程,然后调了他的 start 方法,我们就在 C 层模拟了一个 C++ 的线程,然后去调度 Java 层的线程。
而中间的这一块也就是我们现在看到第二张代码图,这里其实就是一个完整的在 C 层启动一个线程的模板,大家知道一下就行了。
<div align="center"></div>
其实前面的线程不是重点,重要的是我们协程的实现。
首先协程在 Java 层,它的定义有两个比较重要的点:第一个是协程需要有一个 async() 方法,我们这段代码的第29行表示我们要执行的这一个协程的协程体就是我们这个协程它的起始状态相有点类似于线程的 start() 方法,大家可以把它理解为线程的 star 方法。
不过 async() 方法执行完了以后,它允许你有一个回调在 await() 里面。
大家都知道 kotlin 里 async() 方法会返回来一个对象,然后这个对象又有一个 await() 方法可以继续执行,这是 kotlin 通过它的一个语言特性,给我们做了一个语法糖封装的。当然在这里我基于 C++ 示例,暂时先不管语法糖的能力。
所以我把 await() 方法定义成了一个回调,也就是这里第 20 行列出来的 onAwait() 方法,也是当 async() 执行完了以后,它是允许响应一个回调结果的,也就是这里的一个结果参数 result。
那么在协程的定义的时候,还有一个比较重要的就是我们要把协程跟一个线程做关联,也就是第 8 行。这里第 8 行有一个线程 threadid 就是它会最终在构造方法里面默认初始化。
构造方案我没有截出来,在第 8-20 行之间,每一次我们协程创建的时候,我默认会关联到当前的线程。如果你不希望用当前的线程去调度它,或者你想自己定义一个或复用已有的线程的话,你只需要把那个线程的 ID 传过来就可以了,然后他就会在这个线程里面去调度当前的协程。
<div align="center"></div>
然后协程的实现跟刚刚线程类似,我们主要看 init() 方法和 async() 方法里面。其实在我们所有协程最早的构造方法里面的时候,它会调用这个 init() 方法。然后 init() 方法其实就是把当前的协程跟要调度它的线程做一个关联。
我们看到这里,也就第 51 行。 threads 就是个 map, 我构建了一个全局的 map 去用来存放我们的线程,把这个 map 作为一个线程池,然后通过线程 ID 作为 Key 来取到对应要调度它的线程去执行。然后 53 行就对应的是把我们当前的这个协程交给这个线程去调度了。
在我们整个方法里面,协程是被定义了一个生命周期状态的。
也就是看到我们左下角这里有总共 5 个状态,从 0-4。
0 就是一个协程被最初构建好的时候,它就是默认状态。
然后有 suspend 当前这个协程要被挂起了。
然后还有 need resume 表示它已经挂起了。
然后 3 就表示这个协程已经被恢复了,他这个协程可以继续的被执行了。
所以 123 其实是可以循环转换的,是从 1 到 2 再到3,然后再到 1 也是可以的,就这样循环转换。
4 finished 比较好理解,我们当前这个协程已经被执行完了,执行完了以后这个协程应该是要被回收,要被结束了的,就是 finished 状态。
<div align="center"></div>
后面我们从这张图上面来看一下我们自己协程的运行原理。首先有两个入口,一个是 init() 第二个是 async()。
我从左边开始讲从 init 开始,最早的时候也就是我们 new 了一个 Java 层的那个 coroutine 对象的时候,它会在构造方法里面调用它的 init 方法。
然后通过 JNI 调到 C 层里面,然后在 JNI 里面会去调用 C 的 start 方法,然后这个 start() 方法会去通过调协程的调度器,然后去在当前的线程里面做一个阻塞态的循环,去不停地通过协程的队列去不停地取协程,取出来了以后,然后开始调度这个协程,让这个协程开始执行。
如果说这个协程是一个待恢复的状态的话,那么就恢复它,然后继续执行。
然后另一边是我们协程的加入assync() 方法,也是一样,从 Java 层调到 C 层,然后它有一个关键点,就 attach() 方法, attach() 会把当前的这个协程和需要调度它的线程做一个关联,把我们当前的这个协程加入到那个调度的线程的队列里面去,然后那个线程就开始调度。这样的一个协程其实就是一个非常经典的生产者和消费者的模型。
<div align="center"></div>
然后我们接下来引入下一个概念,就是挂起点。因为我们前面讲了协程是可以随时把一个协程给挂起,挂起完了以后我们继续去执行下一个协程。我们刚才讲的时候,其实是为了让大家理解比较清楚,没有跟大家讲挂起点的这样一个概念。其实协程并不能随便地被挂起,它有一个概念就是挂起点的概念,协程只能在挂起点被挂起和恢复。
<div align="center"></div>
这张图是从 kotin 的官网截的一张图,就是他用来讲协程的,大家有兴趣可以去看一下 kotlin 协程的实现。其实他把整个协程的原理讲得非常的清晰,就是在 kotlin 官方的那个文档上面。
我们这里看一下,从 start 到 end 是一整个协程完整的生命周期。然后中间有三个 suspension point。这三个点表示了协程的三个挂起点,协程只能在这三个点里面被挂起,以及被恢复。
然后在第一个挂起点到第二个挂起点之间这样的一个段落,是协程的一个执行体,执行体是不能被中断的。
那回到我们这个例子里面,协程的挂起点是在哪里呢?
<div align="center"></div>
我们先还是回到刚才的运行原理图上,看到黄色这一部分,假设我们要对当前的这个协程,我给它调用了一个 delay() 方法,就是要把自己挂起一段时间。那么我们在协程的调度器里,它在执行到这个协程的时候,发现这个协程如果是要被挂起。
<div align="center"></div>
我们重点看到左半部分,右半部分现在不需要考虑。然后我们把这个图给拉长,假设当前的这个协程是一个需要被挂起的协程的话,那么我们会先把这个协程从队列里面给取出来。
严格来说这个其实不是一个队列,但你可以把它理解为是一个双向队列,就是我们从协程头里面去取出来一个协程,然后把把它暂存起来。
<div align="center"></div>
因为它已经被挂起了,所以我们先把这个挂起的协程给暂存起来,然后同时继续从队列里面去取下一个协程。取到下一个协程了以后,我们再去调度这个。然后把这个协程调度完了以后,我们再把刚刚挂起的那个协程继续给放回当前的队列头,让它放在那里了以后,等到我们当前这个协程执行完了以后,然后再去继续调度下一个那个已经被挂起的协程,然后再去恢复它,让他继续地执行。
<div align="center"></div>
然后我们看到刚刚讲的在我们这个库里面的协程的挂起点。其实在我们这个库里面,协程的挂起点只有一个,是因为实现的原因,后面我会给大家讲。
我先讲一下为什么只有一个挂起点,因为我们是基于 C 层去做的。我们先看一下协程的整个逻辑。
在我们这个库里面,协程整个完整的生命周期,是从你调用了协程的 async() 开始,然后通过 Java 的 run() 方法,也就是 Java 层的 coroutine 对象里面的 run() 方法。
然后在 suspend await 这一步是在 C 层里面。
只能有这一个挂起点最主要的原因就是我们在调用在 C 层调用 Java 的时候,我们只能按方法去调用,没办法按语句去调用。如果我们要想在像 kotlin 那个样子,按照语句级别的增加挂起点的话,我们可能需要有两种方式。
第一种是给我们的所有的 Java 代码加入 hook, 也相当于是给每一个语句执行的执行前和执行后去调用一下我们 C 层的代码。
还有一种方式就是我们直接去改 JVM 的代码,因为我们都知道 Java 的 class 它最终在执行的时候都是靠 JVM 一行一行的去解释执行的。所以如果我们从 JVM 层面去改掉它的解释执行的方式,把每条语句结束作为一个挂起点其实也是能实现的。
<div align="center"></div>
然后在 C 层我们使用的协程库其实有很多可以选择。比方说像 C++ 20 官方已经内置了协程的能力,但是它内置的那一份协程能力,只提供了一系列协程的基础实现,没有去做成封装成可用的 API 你让你能够调用。所以我猜测可能 C++ 会在马上的 C++ 23 版本里面会对它做一个封装,然后真正给到程序员,让开发者可以去用它的完善的一个调度函数体去直接调度一个协程。
然后第二个其实是 C 库里面提供的一个协程的库,就是它很小,只有两个方法你就可以实现一个协程的能力,就是 setjmp() 和 longjmp() 然后我们这个库也是直接用的,因为这是成本最低的方法。
然后下还有剩下两个是在 github 上面比较出名的。第一个是风云写的一个库叫 Ucontext 这个库它实际上是通过 C 去嵌入了汇编,然后通过汇编去调度,把当前的调用栈给存入寄存器里面,然后再切到下一个调用栈去执行的。但是他有一个问题,他只帮你实现了 x86 和 x64 的 CPU 架构,所以它不能用在移动端上。
然后第四个 libco 它是微信的一个开源库,也是可以用来实现协程能力的库,但是它也是只是用在服务端的,也只提供了 PC 的能力,它的整个实现方案也是参考了那个 Ucontext,后面包括还有一些 libcoevent 也是基于 Ucontext 跟 libco 做的一系列的封装去实现的。
<div align="center"></div>
看一下在我们库里面,setjmp库的使用,最主要的就是 39 行和 68 行。
setjmp() 有一个参数是一个指针,就是用来保存协程当前的运行上下文信息,恢复这个协程的时候也是通过这个指针恢复。
longjmp() 就是恢复时调用的,第一个参数就是指针位置,第二个是设置setjmp的返回值,在 C 里面,int 是可以做条件判断的非零即真,代码里传个 1 相当于 setjmp 会返回 true。
<div align="center"></div>
最后还有坑需要填的,其实说是坑,其实也就是todo项:
- 第一个,我们目前是没有类似于 kotlin 那样的一个回调语法的,所以我们现在 await 是用的典型的就是回调语法去做的, onAwait() 方法去实现的。后面其实我们是可以基于 kotlin 的 suspend 关键字,就是如果大家了解的话应该知道,suspend 其实也是 kotlinc 在编译期的时候做的一次语法翻译,他帮你在每一个方法编译以后,给这个方法在最后一个参数上面多插入了一个参数,这个参数其实就是一个用作回调的 callback。
- 第二个就是刚刚跟大家讲到了为什么只有一个挂起点的原因。因为 JVM 层的语法级别去实现,挂起点的这样能力开发起来太难了。就正常来说,对于一个普通的开发者是没办法去做到的。而如果用 hook,又会造成频繁 JNI 调用,影响性能。
- 然后第三个点是一个 IO 中断,前面跟大家讲在某一个协程发生阻塞的时候,我们应该是能够调度另一个协程去,然后把当前这个正在 IO 的协程给挂起的。这一点其实在 C 层是可以通过信号量去完成的。
如果大家对于以上几个 todo 项感兴趣,也可以扫右上角的二维码,加入微信群,我们一起把遗留的几个点完善掉。
<div align="center"></div>