技术文章协程是怎么切换的!什么是技术栈选型标准?

抖店动销抖店体验分提升抖店任何业务可添加微信:ad07668

协程(coroutine)的来龙去脉之前说过了,就是变异步为同步,增加代码的可读性,降低编程的出错率。它借鉴了Linux内核的进程切换思路,把用户态的函数也当作一个单独的上下文,让它可以像进程一样来回切换。区别只是进程的切换是在内核里,而协程的切换是在用户态。所以说,协程就是个用户态的“线程”。在用户态切换,比起在内核里切换还是要简单一点的,因为用户态不需要切

协程(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++

// __asm_co_task_run(e); 协程调用

}

如果e->handler()是协程函数的话,不能这么直接调用,而是要用“调度器函数”调用它。

调度器需要为它安排好函数栈,如果半截里切换出去的话,调度器还要给它保存好栈的上下文,以保证它还可以被切换回来

但是不管怎么调用,返回之后运行的都必须是主框架的i++,让for循环可以继续处理下一个事件。

这个实现并不难,只要把栈顶rsp挪回原来的位置,然后把保存在它那里的返回地址用ret指令弹出就行了。

调度器主框架被调函数协程调度器的被调函数,它们的栈地址都比主框架的栈更低(栈从高往低增长,更低的栈空间保留有更多的信息)。

挪回rsp之后,被调函数的栈空间就像正常的函数返回一样失效了,所以在这之前要把它保存到堆上。

协程第一次被调用时也是由__asm_co_task_run()调度,如下图的汇编

1,首先需要保存各个寄存器,13-24行,

rax是不需要保存的,它是函数的返回值,默认就会被修改。

当然也可以把所有的寄存器全都保存一遍,小心无大错,万一返回之后的值变了,程序说不定就挂了[大笑]

技术文章协程是怎么切换的!什么是技术栈选型标准?

26行,取出协程运行前的栈地址(栈底)rsp0,如果是首次运行它的值是0(38行检测)。

27行,然后把当前rsp的值保存到这里,它表示协程函数栈底,返回时要退回到这里。

28行,获取要运行的协程函数(指针),它是下图的协程结构体的rip成员变量。

30行,把当前rsp保存到rbp,从而可以在协程返回之后恢复rsp的值:也就是恢复调用前的栈的状态,这样才可以正常返回主框架。

技术文章协程是怎么切换的!什么是技术栈选型标准?

协程的结构体

33-36行,是为协程函数分配栈空间(它在更低的地址上),然后把之前保存的栈信息复制过去。

如果不是首次运行,栈信息就是之前切换出去的运行信息。

如果是首次运行,就是用户传递的实参信息。

rep movsb指令可以复制一串数据:rdi是目的位置,rsi是源位置,rcx是复制的字节数。

因为栈往低地址增长,所以33行为协程函数分配内存要用减法(sub)。

38-39行,判断是否首次运行,不是的话直接跳转上次运行代码位置就行。

进程的代码段只读的,而且在整个进程运行期间,代码段的位置是不会变的。

所以上次运行到哪里,就直接跳到哪里,前提是它的栈信息必须跟上次一样,然后就能接着运行了。

41-44行,是首次运行时先把实参放到ABI寄存器里。

技术文章协程是怎么切换的!什么是技术栈选型标准?

这里我只写了4个,所以只能传递4个整数实参:不能多,也不能少,更不能是浮点数

想传递6个的话,就在44行以下再加上这么两行:

pop %r8

pop %r9

因为浮点数在x64上是通过浮点寄存器xmm0, xmm1, … 传递的,当参数是整数和浮点数混着时,传递起来比较麻烦。

在协程函数里,就没必要给作者出这种难题了,对吧[呲牙]

46行终于来到了这里,调用协程的函数指针

这个call只要返回了,说明协程函数运行完了

如果协程半截里切换出去,它是通过调用__asm_co_task_yield()切换的,不会回到这里。

不管是首次运行还是再次运行,协程运行完之后,都会来到下图的53行

技术文章协程是怎么切换的!什么是技术栈选型标准?

在53行之后,会恢复最初的栈顶寄存器rsp,并且把它作为返回值传递给主框架函数。

还记得吗

我们在第30行,在为协程分配栈内存之前,把rsp保存在了rbp里的。

现在,我们恢复它。

54行:给主调函数返回一个值,就把这个值放到rax里,C语言ABIrax返回值

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

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

抖店动销抖店体验分提升抖店任何业务可添加微信:ad07668

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 470473069@qq.com 举报,一经查实,本站将立刻删除。