CoroutineDemo
CoroutineDemo copied to clipboard
共享栈式协程实现的 Demo
CoroutineDemo
共享栈式协程实现的 Demo
简述
大约在半年以前,我曾经了解过协程的相关实现,也看过腾讯后台开源的协程库libco
,对其中实现协程相关的汇编有很深的印象(libco
适配的是 x86 平台)。接受了这样的思想,我在自己的毕业设计中,写出一套单片机汇编实现的协程。也自此之后,就有种念头在 iOS 端实现相关的代码。手机端使用的 CPU 跟单片机相差甚远,虽然我用的单片机也是 ARM 平台的,但和手机端 CPU 相比两者差距确很大,内核微架构差距太大,无从入手。后续突然想起可以使用setjmp
和longjmp
这两个函数间跳转函数,我为何不反汇编它,根据其汇编,来实现协程呢?
最后,我实现了这个想法,先把 Demo 放上来:协程 Demo。读者可以根据 Demo 和本篇文章一起了解相关内容。
协程的意义
协程早在 60 年代提出的一种概念,但在后续的发展中,这种理念得不到发展。原因在于 C 语言大行其道,在C 中很忌讳软件开发中,这种无限制的跳转。类似像goto
语句,几乎编程教科书中都对goto
语句进行了评判。但就我个人觉得,其实没有必要,像goto
语句,有其强大之处,在 C 中,函数有很多地方都会走向结束并释放资源,而goto
语句提供了一个统一地方去释放资源,这也能看到goto
的作用,因此至今 linux 内核中还有几万个goto
语句。协程也是如此,但协程更胆大妄为的跳转,早早的跟不上主流。而编程技术发展到今天,很多脚本语言都开始支持了协程,在 JS,lua 和 go 语言都有明显的使用,协程也在实际应用中进一步使用,例如腾讯微信后台协程库libco
。
协程允许了一个函数可以跳转到另一个函数当中,执行另一个函数代码,前一个函数的栈是保存下来的,一定时机后也会从其他函数中跳转回来,恢复栈,继续跑下面的代码。这不是普通函数间的调用,协程是直接跳转到另一个栈的函数中去,让 CPU 跑那个函数,那个函数的栈和之前函数的栈根本无任何联系。如何实现,关键就在于栈的保存和当时函数将要跳转的时候,CPU 相关寄存器内容的保存,而setjmp
和longjmp
便实现了 CPU 相关寄存器内容的保存,我反汇编也得以实现这一想法(其实直接采用setjmp
之类也可以实现协程,但后续扩展协程之间交互等,还是需要自己处理汇编来实现)。共享栈式表示在运行中,函数的栈只有一个,在跳转中,前一个函数栈的内容会被保存在堆中,然后要执行的函数栈的内容会被复制到栈中,保持运行。这其中就涉及到很多问题,我在其中也遇到了很多麻烦。这个 Demo 中并不像libco
中使用epoll
之类的函数实现 I/O 模型,我这里只是实现了协程间的跳转和相关上下文的切换。
协程先天性的优势在于处理高 I/O 任务的高效,比线程还轻量级的上下文切换,耗费极小的 CPU 性能,函数栈的保存致使它处理 I/O 任务像是在处理同步任务一样,不必考虑异步编程带来的回调地狱。而它的不足在于它无法处理高 CPU 任务,因为协程任务并不是并发执行的,没有像线程那样的时间片轮转机制。当一条线程执行高计算量的任务时,必然会影响到其他协程任务的执行时间。使用协程也会较占据内存空间,因为协程栈的内容是必须保存在内存中,当成千上万条协程执行时,内存会显的比较有压力,但实际上采用共享栈模式以后,协程的内存耗费量已经大规模下降,至少是可以接受的。libco
也已经达到千万级别的协程支持了。
ARM 相关寄存器保存的实现
具体内容在 Demo 的Coroutine.s
中实现了。
如果要阅读相关汇编代码,可以先了解一下 ARM64 的寄存器,具体可看ARMv8-寄存器,要写 ARM64 汇编,必须要了解ARM v8指令集(手册)。
.text
.align 4
.globl _pushCoroutineEnv
.globl _popCoroutineEnv
.globl _getSP
.globl _getFP
_pushCoroutineEnv:
stp x21, x30, [x0]
mov x21, x0
bl openSVC
mov x0, x21
ldp x21, x30, [x0]
mov x1, sp
stp x19, x20, [x0]
stp x21, x22, [x0, #0x10]
stp x23, x24, [x0, #0x20]
stp x25, x26, [x0, #0x30]
stp x27, x28, [x0, #0x40]
stp x29, x30, [x0, #0x50]
stp x29, x1, [x0, #0x60]
stp d8, d9, [x0, #0x70]
stp d12, d13, [x0, #0x90]
stp d14, d15, [x0, #0xa0]
mov x0, #0x0
ret
_popCoroutineEnv:
sub sp, sp, #0x10
mov x21, x0
ldr x0, [x21, #0xb0]
str x0, [sp, #0x8]
add x1, sp, #0x8
orr w0, wzr, #0x3
mov x2, #0x0
bl openSVC
mov x0, x21
add sp, sp, #0x10
ldp x19, x20, [x0]
ldp x21, x22, [x0, #0x10]
ldp x23, x24, [x0, #0x20]
ldp x25, x26, [x0, #0x30]
ldp x27, x28, [x0, #0x40]
ldp x29, x30, [x0, #0x50]
ldp x29, x2, [x0, #0x60]
ldp d8, d9, [x0, #0x70]
ldp d10, d11, [x0, #0x80]
ldp d12, d13, [x0, #0x90]
ldp d14, d15, [x0, #0xa0]
mov sp, x2
ret
_getSP:
mov x0, sp
ret
_getFP:
mov x0, x29
ret
openSVC:
mov x16, #0x30
svc #0x80
stp x29, x30, [sp, #-0x10]!
mov x29, sp
mov sp, x29
ldp x29, x30, [sp], #0x10
ret
在Coroutine.s
实现的内容大体像上面那样(后续版本可能会有迭代,不一定跟上面相似),简单介绍其几个函数的作用:
_pushCoroutineEnv: 保存调用此函数时,为了后续执行,把 ARM 相关寄存器保存到内存中。
_popCoroutineEnv: 从内存保存过的 ARM 相关寄存器的内容从新赋值到对应的寄存器内,要注意的是,此时LR
(即x30
寄存器)寄存器已经改变,所以当执行到ret
语句时,函数的执行地址会跳转到新的LR
所保存的地址上去,也其实就是_pushCoroutineEnv
的下一语句中。_pushCoroutineEnv
和_popCoroutineEnv
是两两相对的。
_getSP: 获取到栈底寄存器的内容,为后续栈内容的拷贝使用。
_getFP: 获取到栈帧寄存器内容,主要是为了创建新的协程任务,让新的协程任务的栈可以依靠到触发函数的栈中。
openSVC: 开启 ARM 芯片的 SVC 模式,也就是超级用户模式, ARM 芯片有五种模式,在不同模式有不同的作用。只有开启了 SVC 模式,我们的代码才能访问到一些特定的寄存器,不在此模式访问了那些寄存器,会出现硬件错误。这是 ARM 芯片硬件实现的权限管理,避免非内核访问到不该访问的内容。所以每次保存寄存器内容和恢复寄存器内容必须要开启 SVC 模式。
后续如果要增加协程同步等功能的时候,还会修改这些相关的汇编代码,0.1 版本的协程 Demo 只实现了最基础的功能,连 I/O 模型都没有,所以代码量也并不会很多。
Demo 中相关 API 的介绍
关键函数有 4 个:
typedef void (*coroutineTask)(void);
void coroutine_switch(void);
void coroutine_release(void);
void coroutine_start(coroutineTask entryTask);
void coroutine_create(coroutineTask task);
coroutine_start: 开启协程,并启动一个入口entryTask
。注意当执行到coroutine_start
函数后面下一语句时,这时协程已经结束了,协程环境也被释放了。
coroutine_create: 创建一个协程,注意,在未使用coroutine_start
前是无法创建协程的,相关环境并未创建好,因此,coroutine_create
会在entryTask
或者其他协程里面使用,只有协程里面才能创建另一个协程。
coroutine_release: 当一个协程要结束时,必须调用coroutine_release
函数,来释放此条协程的环境,不然,会跳到此条协程第一条代码语句继续执行。
coroutine_switch: 协程切换,当这条协程需要等待 I/O 的时候,可以切换到另一条协程中,让 CPU 继续执行另一条协程的代码,具体的跳转机制是链表实现的,开发者不必考虑具体会切换到哪一条协程,都是照链表的顺序执行下去的。
相关 API 的解析
这里就不贴代码,具体可看文件Coroutine.c
。
coroutine_start
- 初始化一下
pthread
相关的东西,确保每条线程之间的协程环境不会杂在一起,这里就体现出面对对象的重要性了,如果使用面对对象根本不会有这种问题,但这里我一开始并没有这样的打算,因为 C API 显的更简洁。 - 获取到栈顶和栈底寄存器,必须在这个函数获取,因为这是所有协程的开始点。
- 创建空白协程和入口,空白协程用来检测所有协程是否结束任务,如果结束,释放相关资源,跳回线程中继续执行线程代码。
- 开启空白协程,执行协程代码。
coroutine_create
- 获取到协程起始点的栈顶寄存器和栈帧寄存器。
- 将栈顶寄存器,栈帧寄存器和
LR
寄存器(task 的地址)相关内容放在链表中。
coroutine_release
在链表中把这个协程的释放标志位打开。
coroutine_switch
这个函数是关键。
- 获取到当前执行的协程,将它的栈和相关寄存器的内容更新到链表中。
- 从链表中获取到下一协程,如果此协程是要被释放,则释放此协程,再去找寻下一协程,直到找到可执行协程,然后,将可执行协程的栈的内容和相关寄存器内容赋值到栈和寄存器中。
- 如此便会执行下一可执行协程代码。
空白协程
检测链表中是否只有自己一个协程,如果是,释放协程环境,否,则切换到下一协程。
总结
为了实现相关逻辑,实际上也遇到了一些问题,但也让我加深了对 ARM 芯片和栈等的了解。
比如说,要将堆上的内容复制到栈上去,使用memcpy
函数是会出问题的,因为memcpy
也会使用到栈,这样在复制的时候,会把memcpy
的栈干掉,致使出现问题。后续的解决方案是自己从新实现了一个memcpy
类似的函数,将要使用的变量放在静态区域,因为栈和堆肯定不会在同一内存区域,不会内存冲突问题,这个函数也好写。但带来的问题是,必须对静态区域加互斥锁,不然在不同线程肯定会出问题,这就造成了性能损耗,当然最好的方法是用汇编实现memcpy
函数,将相关变量放在寄存器内。
有想法就要实现,看起来还是很完美的。