本
文
摘
要
协程(coroutine)的来龙去脉之前说过了,就是变异步为同步,增加代码的可读性,降低编程的出错率。
它借鉴了Linux内核的进程切换思路,把用户态的函数也当作一个单独的上下文,让它可以像进程一样来回切换。
区别只是进程的切换是在内核里,而协程的切换是在用户态。
所以说,协程就是个用户态的“线程”。
在用户态切换,比起在内核里切换还是要简单一点的,因为用户态不需要切换内存页表。
在内核里,每一个进程都有单独的页表,以实现进程的虚拟内存到物理内存的映射。
在用户态,协程可以访问的内存空间与进程是一样的,就不需要在切换时关注页表的问题了,而只需要关注函数运行到哪里了,栈是什么状态。
函数运行到哪里了,指的是指令指针寄存器RIP的值。
栈是什么状态,指的是栈顶寄存器RSP的值与栈底寄存器RBP的值。
这两项再加上其他寄存器的值,就是函数的上下文了。
这些信息随着函数调用一直变化,要想切换出去之后还能切换回来,就必须在切换之前把它们保存到堆内存。
为了省事,可以先把其他寄存器压栈,然后把新的栈顶rsp到栈底rbp之间的所有内容都保存到堆上,切换回来时再把这些寄存器首先出栈(后进先出)。
协程的内存布局
在协程函数切换出去之后(yield),栈要回到哪里?
要回到协程函数被“调度”运行之前的状态,也就是epoll主框架函数在调用协程函数之前的位置:即上图的棕线的位置。
对协程函数的调用效果,必须跟以下的空调用是一样的:
主函数main:call coroutine
协程函数coroutine:ret
如果是正常的函数调用,被调函数返回之后,接着运行的是主调函数的下一行代码。
那么协程调用,返回之后接着运行的也是主调函数的下一行代码。
(call调用时会把返回地址压栈,ret会把返回地址出栈,所以调用前后的栈是一样的)
只有这样,epoll主框架在处理完(文件描述符)fd5的事件之后,才能继续处理fd6的,就跟真正的异步代码一样。
例如:
for (i = 0; i < ret; i++) { // ret是触发的epoll事件个数
event* e = events[i].ptr;
e->handler(e); // 处理第i个fd的事件,之后运行的是i++
// __a *** _co_task_run(e); 协程调用
}
如果e->handler()是协程函数的话,不能这么直接调用,而是要用“调度器函数”调用它。
调度器需要为它安排好函数栈,如果半截里切换出去的话,调度器还要给它保存好栈的上下文,以保证它还可以被切换回来。
但是不管怎么调用,返回之后运行的都必须是主框架的i++,让for循环可以继续处理下一个事件。
这个实现并不难,只要把栈顶rsp挪回原来的位置,然后把保存在它那里的返回地址用ret指令弹出就行了。
调度器是主框架的被调函数,协程是调度器的被调函数,它们的栈地址都比主框架的栈更低(栈从高往低增长,更低的栈空间保留有更多的信息)。
挪回rsp之后,被调函数的栈空间就像正常的函数返回一样失效了,所以在这之前要把它保存到堆上。
协程第一次被调用时也是由__a *** _co_task_run()调度,如下图的汇编:
1,首先需要保存各个寄存器,13-24行,
rax是不需要保存的,它是函数的返回值,默认就会被修改。
当然也可以把所有的寄存器全都保存一遍,小心无大错,万一返回之后的值变了,程序说不定就挂了
26行,取出协程运行前的栈地址(栈底)rsp0,如果是首次运行它的值是0(38行检测)。
27行,然后把当前rsp的值保存到这里,它表示协程函数的栈底,返回时要退回到这里。
28行,获取要运行的协程函数(指针),它是下图的协程结构体的rip成员变量。
30行,把当前rsp保存到rbp,从而可以在协程返回之后恢复rsp的值:也就是恢复调用前的栈的状态,这样才可以正常返回主框架。
协程的结构体
33-36行,是为协程函数分配栈空间(它在更低的地址上),然后把之前保存的栈信息复制过去。
如果不是首次运行,栈信息就是之前切换出去的运行信息。
如果是首次运行,就是用户传递的实参信息。
rep mov *** 指令可以复制一串数据:rdi是目的位置,rsi是源位置,rcx是复制的字节数。
因为栈往低地址增长,所以33行为协程函数分配内存要用减法(sub)。
38-39行,判断是否首次运行,不是的话直接跳转到上次运行的代码位置就行。
进程的代码段是只读的,而且在整个进程运行期间,代码段的位置是不会变的。
所以上次运行到哪里,就直接跳到哪里,前提是它的栈信息必须跟上次一样,然后就能接着运行了。
41-44行,是首次运行时先把实参放到ABI寄存器里。
这里我只写了4个,所以只能传递4个整数实参:不能多,也不能少,更不能是浮点数。
想传递6个的话,就在44行以下再加上这么两行:
pop %r8
pop %r9
因为浮点数在x64上是通过浮点寄存器xmm0, xmm1, ... 传递的,当参数是整数和浮点数混着时,传递起来比较麻烦。
在协程函数里,就没必要给作者出这种难题了,对吧
46行,终于来到了这里,调用协程的函数指针。
这个call只要返回了,说明协程函数运行完了。
如果协程半截里切换出去,它是通过调用__a *** _co_task_yield()切换的,不会回到这里。
不管是首次运行还是再次运行,协程运行完之后,都会来到下图的53行。
在53行之后,会恢复最初的栈顶寄存器rsp,并且把它作为返回值传递给主框架函数。
还记得吗?
我们在第30行,在为协程分配栈内存之前,把rsp保存在了rbp里的。
现在,我们恢复它。
54行:给主调函数返回一个值,就把这个值放到rax里,C语言的ABI以rax为返回值。
56-69行,调用协程之前怎么保存的寄存器,调用协程之后就怎么恢复它,顺序相反。
接下来是挂起协程的yield()函数:
78-90行,协程挂起之前,也需要把寄存器保存在栈上,然后一起保存到堆上。
91行,保存下次恢复的运行位置,
这个位置,都会选择一个固定的代码位置,从Linux内核开始就是这么做的。
既然Linus大牛就是这么做的,我们当然要萧规曹随。
不需要关注函数下次该运行哪行C代码,反正它既然是在这里挂起来的,就让它在这里返回就行。
只要汇编代码保证栈的状态是对的,那返回去就是对的。
92行,保存栈顶的位置,rsp寄存器。
94-100行,保存栈的信息。
这里要调用一个C函数__save_stack():它的内容就是分配堆空间,并且复制栈的信息到堆上。
调用时,栈和寄存器的信息有可能改变,如果接下来还要用的话,就先压栈保存。
101行,把当前栈顶rsp作为返回值,存到rax里。
102行,恢复之前被调度运行时的栈位置,它存在rcx里,即协程结构体的rsp0成员。
这个信息的保存是在第27行。
在协程被运行之前保存的,在协程要休眠之后,也要再回到那里。
104行,跳到出口点,把寄存器依次出栈,然后返回。
这时当前协程被休眠,主框架会继续处理其他fd的事件,然后再次检测文件描述符的读写状态。
当读写事件再次触发之后,就会被再次调度运行,直到整个函数运行完毕。