前两天阿里巴巴开源了coobjc,没几天就已经2千多star了,我也看了看源码,主要关注的是协程的实现,周末折腾了两整天参照Go的前身libtask和风神的coroutine实现了一部分,也看了一些文章,稍微整理一下。
协程
Coroutines are computer-program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.
Process -> Thread -> Coroutine
协程(Coroutine)编译器级的,进程(Process)和线程(Thread)操作系统级的
进程(Process)和线程(Thread)是os通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到os分配的cpu时间到达后就会被os强制挂起,开发者无法精确的控制它们。
协程(Coroutine)是一种轻量级的用户态线程,实现的是非抢占式的调度,即由当前协程切换到其他协程由当前协程来控制。目前的协程框架一般都是设计成 1:N 模式。所谓 1:N 就是一个线程作为一个容器里面放置多个协程。那么谁来适时的切换这些协程?答案是有协程自己主动让出 CPU,也就是每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,或者它调用主动让出 CPU 的 API 之类,触发下一次调度。
优缺点
优点:
- 协程更加轻量,创建成本更小,降低了内存消耗
协程本身可以做在用户态,每个协程的体积比线程要小得多,因此一个进程可以容纳数量相当可观的协程
- 协作式的用户态调度器,减少了 CPU 上下文切换的开销,提高了 CPU 缓存命中率
协作式调度相比抢占式调度的优势在于上下文切换开销更少、更容易把缓存跑热。和多线程比,线程数量越多,协程的性能优势就越明显。进程 / 线程的切换需要在内核完成,而协程不需要,协程通过用户态栈实现,更加轻量,速度更快。在重 I/O 的程序里有很大的优势。比如爬虫里,开几百个线程会明显拖慢速度,但是开协程不会。
但协程也放弃了原生线程的优先级概念,如果存在一个较长时间的计算任务,由于内核调度器总是优先 IO 任务,使之尽快得到响应,就将影响到 IO 任务的响应延时。假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。
此外,单线程的协程方案并不能从根本上避免阻塞,比如文件操作、内存缺页,这都属于影响到延时的因素。
- 减少同步加锁,整体上提高了性能
协程方案基于事件循环方案,减少了同步加锁的频率。但若存在竞争,并不能保证临界区,因此该上锁的地方仍需要加上协程锁。
- 可以按照同步思维写异步代码,即用同步的逻辑,写由协程调度的回调
需要注意的是,协程的确可以减少 callback 的使用但是不能完全替换 callback。基于事件驱动的编程里面反而不能发挥协程的作用而用 callback 更适合。
缺点:
- 在协程执行中不能有阻塞操作,否则整个线程被阻塞(协程是语言级别的,线程,进程属于操作系统级别)
- 需要特别关注全局变量、对象引用的使用
- 协程可以处理 IO 密集型程序的效率问题,但是处理 CPU 密集型不是它的长处。
假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。
适用场景
- 高性能计算,牺牲公平性换取吞吐。协程最早来自高性能计算领域的成功案例,协作式调度相比抢占式调度而言,可以在牺牲公平性时换取吞吐
- IO Bound 的任务
在 IO 密集型的程序中由于 IO 操作远远小于 CPU 的操作,所以往往需要 CPU 去等 IO 操作。同步 IO 下系统需要切换线程,让操作系统可以再 IO 过程中执行其他的东西。这样虽然代码是符合人类的思维习惯但是由于大量的线程切换带来了大量的性能的浪费。
所以人们发明了异步 IO。就是当数据到达的时候触发我的回调。来减少线程切换带来性能损失。但是这样的坏处也是很大的,最大的问题就是破坏掉了人类这种线性的思维模式,你必须把一个逻辑上线性的过程切分成若干个片段,每个片段的起点和终点就是异步事件的完成和开始。固然经过一些训练你可以适应这种思维模式,但你还是要付出额外的心智负担。与人类的思维模式相对应,大多数流行的编程语言都是命令式的,程序本身呈现出一个大致的线性结构。异步回调在破坏点思维连贯性的同时也破坏掉了程序的连贯性,让你在阅读程序的时候花费更多的精力。这些因素对于一个软件项目来说都是额外的维护成本,所以大多数公司并不是很青睐 node.js 或者 RxJava 之类的异步回调框架,尽管这些框架能提升程序的并发能力。
但是协程可以很好解决这个问题。比如把一个 IO 操作 写成一个协程。当触发 IO 操作的时候就自动让出 CPU 给其他协程。要知道协程的切换很轻的。协程通过这种对异步 IO 的封装既保留了性能也保证了代码的容易编写和可读性。
- Generator 式的流式计算
消除 Callback Hell(回调地狱),使用同步模型降低开发成本的同时保留更灵活控制流的好处,比如同时发三个请求;这时节约地使用栈,可以充分地发挥 "轻量" 的优势。
ucontext
协程一般有两类实现,一种是 stackless,一种是 stackful。
structure
struct ucontext {
/*
* Keep the order of the first two fields. Also,
* keep them the first two fields in the structure.
* This way we can have a union with struct
* sigcontext and ucontext_t. This allows us to
* support them both at the same time.
* note: the union is not defined, though.
*/
sigset_t uc_sigmask; //这个上下文要阻塞的信号
mcontext_tt uc_mcontextt; //保存的上下文的特定机器表示,包括调用线程的特定寄存器等
struct __ucontext *uc_link; //指向当前的上下文结束时要恢复到的上下文
stack_t uc_stack; //该上下文中使用的栈
int __spare__[8];
};
getcontext
int getcontext(ucontext_t *ucp)
该函数初始化ucp
所指向的结构体ucontext_t
(用来保存前执行状态上下文),填充当前有效的上下文
setcontext
int setcontext(const ucontext_t *ucp)
函数恢复用户上下文为ucp
所指向的上下文。成功调用不会返回。ucp
所指向的上下文应该是getcontext()或者makecontext()产生的。
如果上下文是getcontext()产生的,切换到该上下文,程序的执行在getcontext()后继续执行。
如果上下文被makecontext()产生的,切换到该上下文,程序的执行切换到makecontext()调用所指定的第二个参数的函数上。当该函数返回时,我们继续传入makecontext()中的第一个参数的上下文中uc_link
所指向的上下文。如果是NULL
,程序结束。
成功时,getcontext()返回0,setcontext()不返回。错误时,都返回-1并且赋值合适的errno。
makecontext
void makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...)
函数修改ucp
所指向的上下文,ucp
是被getcontext()所初始化的上下文。当这个上下文采用swapcontext()或者setcontext()被恢复,程序的执行会切换到func
的调用,通过makecontext()调用的argc
传递func
的参数。
在makecontext()产生一个调用前,应用程序必须确保上下文的栈分配已经被修改。应用程序应该确保argc
的值跟传入func
的一样(参数都是int
值4字节);否则会发生未定义行为。
当makecontext()修改过的上下文返回时,uc_link
用来决定上下文是否要被恢复。应用程序需要在调用makecontext()前初始化uc_link
。
swapcontext
int swapcontext(ucontext_t *restrict oucp, const ucontext_t *restrict ucp)
函数保存当前的上下文到oucp
所指向的数据结构,并且设置到ucp
所指向的上下文。
成功完成,swapcontext()返回0。否则返回-1,并赋值合适的errno。
swapcontext()函数可能会因为下面的原因失败:
ENOMEM ucp参数没有足够的栈空间去完成操作
ucontext协程的实际使用
将getcontext,makecontext,swapcontext封装成一个类似于lua的协同式协程,需要代码中主动yield释放出CPU。
协程的栈采用malloc进行堆分配,分配后的空间在64位系统中和栈的使用一致,地址递减使用,uc_stack.uc_size设置的大小好像并没有多少实际作用,使用中一旦超过已分配的堆大小,会继续向地址小的方向的堆去使用,这个时候就会造成堆内存的越界使用,更改之前在堆上分配的数据,造成各种不可预测的行为,coredump后也找不到实际原因。
对使用协程函数的栈大小的预估,协程函数中调用其他所有的api的中的局部变量的开销都会分配到申请给协程使用的内存上,会有一些不可预知的变量,比如调用第三方API,第三方API中有非常大的变量,实际使用过程中开始时可以采用mmap分配内存,对分配的内存设置GUARD_PAGE进行mprotect保护,对于内存溢出,准确判断位置,适当调整需要分配的栈大小。
风神的coroutine
风神的coroutine是基于ucontext封装的
schedule 调度器
struct schedule {
char stack[STACK_SIZE]; // 原来schedule里面就已经存有了stack
ucontext_t main; // ucontext_t你可以看做是记录上下文信息的一个结构
int nco; // 协程的数目
int cap; // 容量
int running; // 正在运行的coroutine的id
struct coroutine **co; // 这里是一个二维的指针
};
coroutine
struct coroutine {
coroutine_func func; // 运行的函数
void *ud; // 参数
ucontext_t ctx; // 用于记录上下文信息的一个结构
struct schedule * sch; // 指向schedule
ptrdiff_t cap; // 堆栈的容量
ptrdiff_t size; // 用于表示堆栈的大小
int status;
char *stack; // 指向栈地址么?
};
coroutine_new
int coroutine_new(struct schedule *S, coroutine_func func, void *ud)
创建一个协程,该协程的会加入到schedule的协程序列中,func为其执行的函数,ud为func的执行函数。返回创建的线程在schedule中的编号
coroutine_yield
void coroutine_yield(struct schedule * S)
挂起调度器schedule中当前正在执行的协程,切换到主函数。
coroutine_resume
void coroutine_resume(struct schedule * S, int id) {
恢复运行调度器schedule中编号为id的协程
coroutine_close
void coroutine_close(struct schedule *S)
关闭schedule中所有的协程
Coroutine及其实现
协程(Coroutine)-ES中关于Generator/async/await的学习思考
ucontext-人人都可以实现的简单协程库
协程 及 libco 介绍
我所理解的ucontext族函数
ucontext簇函数学习
进程与线程4_协程
构建C协程之ucontext篇
协程:posix::ucontext用户级线程实现原理分析
ucontext-人人都可以实现的简单协程库