小伙伴关心的问题:如何理解协程(什么是协程),本文通过数据整理汇集了如何理解协程(什么是协程)相关信息,下面一起看看。

如何理解协程(什么是协程)

协程(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的事件,然后再次检测文件描述符的读写状态。

当读写事件再次触发之后,就会被再次调度运行,直到整个函数运行完毕。

更多如何理解协程(什么是协程)相关信息请关注本站,本文仅仅做为展示!