实现协程最核心的部分就是栈切换了,其他的和非阻塞io的编程方式没什么区别。
栈切换,libc中有一个实现,swapcontext,但是已经被标准移除了,未来是否可用不得而知,自己实现需要写汇编代码,这是一个很困难的任务,因为既要熟悉不同cpu指令集又要熟悉不同平台的标准,好在从boost library的协程实现中找到了已经写好了的栈切换汇编代码,利用这些汇编代码可以在c语言中实现栈切换。
这段代码是在s_task协程库中发现的,s_task很好,还可以和libuv结合,如果没有特殊要求,可以直接使用了,但是如果想根据自己工作中的业务逻辑做定制,还是需要掌握原理,并且不清楚原理,可能不能用最恰当的方式使用,实现最好的设计,出了问题也不知道该怎么查和用什么办法查。
附上s_task 和 boost library的地址,感兴趣的可以去研究一下。
https://github.com/xhawk18/s_task
https://github.com/boostorg/context
栈切换代码在源码的asm目录中,实际上在c语言中对应两个函数,
typedef void* fcontext_t; typedef struct { fcontext_t fctx; void* data; } transfer_t; extern transfer_t jump_fcontext( fcontext_t const to, void * vp); extern fcontext_t make_fcontext( void * sp, size_t size, void (* fn)( transfer_t) );
这两个函数是什么意思,怎么用,看了s_task中的代码,但是开始的时候还是没看懂,于是想从汇编的角度入手,最终通过x86_64的汇编代码(make_x86_64_sysv_elf_gas.S jump_x86_64_sysv_elf_gas.S)弄清楚了这两个函数的用法,结论在本文最末尾,如果只想看结果,可以跳到最后面。
直接看代码注释吧。
make_x86_64_sysv_elf_gas.S
1 /* 2 Copyright Oliver Kowalke 2009. 3 Distributed under the Boost Software License, Version 1.0. 4 (See accompanying file LICENSE_1_0.txt or copy at 5 http://www.boost.org/LICENSE_1_0.txt) 6 */ 7 8 // 栈空间图,栈顶在低地址,注意和jump_x86_64_sysv_elf_gas.S对比着看,里面代表的是内存(栈,运行现场)中的数据 9 // 不代表寄存器,途中标的寄存器的意思是这些寄存器在保存现场的时候会保存到对应的内存地址中 10 /**************************************************************************************** 11 * * 12 * ---------------------------------------------------------------------------------- * 13 * | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | * 14 * ---------------------------------------------------------------------------------- * 15 * | 0x0 | 0x4 | 0x8 | 0xc | 0x10 | 0x14 | 0x18 | 0x1c | * 16 * ---------------------------------------------------------------------------------- * 17 * | fc_mxcsr|fc_x87_cw| R12 | R13 | R14 | * 18 * ---------------------------------------------------------------------------------- * 19 * ---------------------------------------------------------------------------------- * 20 * | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | * 21 * ---------------------------------------------------------------------------------- * 22 * | 0x20 | 0x24 | 0x28 | 0x2c | 0x30 | 0x34 | 0x38 | 0x3c | * 23 * ---------------------------------------------------------------------------------- * 24 * | R15 | RBX | RBP | RIP | * 25 * ---------------------------------------------------------------------------------- * 26 * * 27 ****************************************************************************************/ 28 29 .file "make_x86_64_sysv_elf_gas.S" 30 .text 31 .globl make_fcontext 32 .type make_fcontext,@function 33 .align 16 34 make_fcontext: 35 // make_fcontext的第一个参数保存到rax中,rax现在开始代表了这个运行环境的栈顶, 36 // rax也是本函数的返回值, 37 /* first arg of make_fcontext() == top of context-stack */ 38 movq %rdi, %rax 39 40 // 16对齐, 规定的 41 /* shift address in RAX to lower 16 byte boundary */ 42 andq $-16, %rax 43 44 // -0x40就是将栈顶指针(栈顶寄存器,这里不是rsp,是rax,rsp是当前运行环境使用的)移动到图中的0位置,即分配栈空间 45 // 递减栈是向下分配的 46 /* reserve space for context-data on context-stack */ 47 /* on context-function entry: (RSP -0x8) % 16 == 0 */ 48 leaq -0x40(%rax), %rax 49 50 // size那个参数这里没有使用,第三个参数是fn,要执行的函数 51 // 这里把fn的地址放到了0x28的位置,恢复到寄存器就是rbx 52 /* third arg of make_fcontext() == address of context-function */ 53 /* stored in RBX */ 54 movq %rdx, 0x28(%rax) 55 56 // 这两个寄存器不清楚 57 /* save MMX control- and status-word */ 58 stmxcsr (%rax) 59 /* save x87 control-word */ 60 fnstcw 0x4(%rax) 61 62 // 从英文注释看,实现的是将trampoline的地址保存在0x38的位置,即rip保存的地方,返回之后接着运行的地址 63 // 所以这个地方实际上是设置了跳转过来之后执行的指令的位置,jump_fcontext认为自己跳到了 64 // 一个暂停过的地方,即保存过现场的地方,恢复现场继续执行,而这里是首次运行,所以要模拟这种场景 65 // 也就是说启动一个任务之后会先运行trampoline那里。 66 /* compute abs address of label trampoline */ 67 leaq trampoline(%rip), %rcx 68 /* save address of trampoline as return-address for context-function */ 69 /* will be entered after calling jump_fcontext() first time */ 70 movq %rcx, 0x38(%rax) 71 72 // 这里是把finish处的地址放到0x30中, 是一个技巧,见trampoline处 73 /* compute abs address of label finish */ 74 leaq finish(%rip), %rcx 75 /* save address of finish as return-address for context-function */ 76 /* will be entered after context-function returns */ 77 movq %rcx, 0x30(%rax) 78 79 // make_fcontext函数返回 80 ret /* return pointer to context-data */ 81 82 trampoline: 83 /* store return address on stack */ 84 /* fix stack alignment */ 85 // 从jump_x86_64_sysv_elf_gas.S中可以看出,跳到这里之前已经从栈空间中恢复了rbp,也就是说 86 // 现在rbp保存的是finish的地址,因为rbp是从0x30恢复的,0x30前面已经保存了finish的地址 87 // 现在的栈顶rsp呢,是在图中0x40的位置,见jump_x86_64_sysv_elf_gas.S中的 leaq 0x40(%rsp), %rsp 88 // 现在push rbp是把fish的地址push到了0x38的位置,也就是本应该保存返回地址rip的地方, 89 // 也就是说fn运行结束返回到make_fcontext的finish处继续运行,就好像是make_fcontext调用 90 // 的fn一样,当然只是好像而已。可见fn返回了整个进程就退出了,这是make_fcontext指定的。 91 push %rbp 92 93 // rbx是设置的fn的地址,跳到fn运行,首次运行fn任务才会走到这里,中间暂停了 94 // 是恢复从暂停的地方的下一个指令开始执行。 95 /* jump to context-function */ 96 jmp *%rbx 97 98 finish: 99 // 这里的代码就是退出进程了 100 /* exit code is zero */ 101 xorq %rdi, %rdi 102 /* exit application */ 103 call _exit@PLT 104 hlt 105 .size make_fcontext,.-make_fcontext 106 107 /* Mark that we don't need executable stack. */ 108 .section .note.GNU-stack,"",%progbits
jump_x86_64_sysv_elf_gas.S
1 /* 2 Copyright Oliver Kowalke 2009. 3 Distributed under the Boost Software License, Version 1.0. 4 (See accompanying file LICENSE_1_0.txt or copy at 5 http://www.boost.org/LICENSE_1_0.txt) 6 */ 7 8 // 栈空间图,栈顶在低地址,注意和make_x86_64_sysv_elf_gas.S对比着看,里面代表的是内存(栈,运行现场)中的数据 9 // 不代表寄存器,途中标的寄存器的意思是这些寄存器在保存现场的时候会保存到对应的内存地址中 10 /**************************************************************************************** 11 * * 12 * ---------------------------------------------------------------------------------- * 13 * | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | * 14 * ---------------------------------------------------------------------------------- * 15 * | 0x0 | 0x4 | 0x8 | 0xc | 0x10 | 0x14 | 0x18 | 0x1c | * 16 * ---------------------------------------------------------------------------------- * 17 * | fc_mxcsr|fc_x87_cw| R12 | R13 | R14 | * 18 * ---------------------------------------------------------------------------------- * 19 * ---------------------------------------------------------------------------------- * 20 * | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | * 21 * ---------------------------------------------------------------------------------- * 22 * | 0x20 | 0x24 | 0x28 | 0x2c | 0x30 | 0x34 | 0x38 | 0x3c | * 23 * ---------------------------------------------------------------------------------- * 24 * | R15 | RBX | RBP | RIP | * 25 * ---------------------------------------------------------------------------------- * 26 * * 27 ****************************************************************************************/ 28 29 .file "jump_x86_64_sysv_elf_gas.S" 30 .text 31 .globl jump_fcontext 32 .type jump_fcontext,@function 33 .align 16 34 jump_fcontext: 35 // 保存当前的运行现场,caller-saved registers调用者保存,这里只保存callee-saved registers 36 leaq -0x38(%rsp), %rsp /* prepare stack */ 37 38 #if !defined(BOOST_USE_TSX) 39 stmxcsr (%rsp) /* save MMX control- and status-word */ 40 fnstcw 0x4(%rsp) /* save x87 control-word */ 41 #endif 42 43 movq %r12, 0x8(%rsp) /* save R12 */ 44 movq %r13, 0x10(%rsp) /* save R13 */ 45 movq %r14, 0x18(%rsp) /* save R14 */ 46 movq %r15, 0x20(%rsp) /* save R15 */ 47 movq %rbx, 0x28(%rsp) /* save RBX */ 48 movq %rbp, 0x30(%rsp) /* save RBP */ 49 // 这里为什么没有使用0x38位置的8个字节,因为这里面存的是返回地址,即本函数返回的时候 50 // 要继续运行的指令的地址,在调用jump_fcontext的时候已经保存了,所以这里不用保存 51 // 看make_fcontext可以看到,那里使用了0x38,因为那个fcontext不是通过调用jump_fcontext 52 // 生成的,而是人为制造的。 53 // 54 // 保存现场完毕 55 56 // 函数的调用者会在被调用函数退出的时候读取rax作为返回值 57 // transfer_t需要两个寄存器保存,另一个是rdx 58 // 但注意这里的返回值不是本次jump_fcontext调用的返回值, 59 // 而是另一个栈空间的jump_fcontext,本次调用是不返回的, 60 // 实际上所有的jump_fcontext都是不返回的,都是别的地方 61 // 跳过去,看起来好像是它返回 62 // 63 // 函数调用就好比是一维世界,从哪里进,再从哪里出,而jump_fcontext好比是二维的世界, 64 // 从“天上”跑了,再从“天上”掉下来。 65 /* store RSP (pointing to context-data) in RAX */ 66 movq %rsp, %rax 67 68 // 把jump_fcontext第一个参数给rsp,第一个参数即运行上下文,别的地方 69 // 保存现场或make_fcontext生成的栈顶地址,这里放到 70 // rsp中,即完成了栈空间的切换,下条命令开始就是在另一个 71 // 栈空间运行了,已经跳走完成, 跳之前做了什么呢,保存现场 72 // 并且把现场放到rax中,跳过去之后的返回值会返回跳之前的现场 73 /* restore RSP (pointing to context-data) from RDI */ 74 movq %rdi, %rsp 75 76 // 这里开始是一个新的栈空间了,也就是一个新的任务 77 // 分两种情况, 78 // 1. 如果是恢复一个任务的运行,那么rsp是它调用jump_fcontext切换走 79 // 的时候的现场,现在开始恢复,这时0x38位置是返回地址,也就是这个 80 // 任务在上一次调用jump_fcontext(暂停,切换到其他任务)的时候保存 81 // 的调用jump_fcontext后面的那个指令对应的地址,即从暂停的位置 82 // 继续执行。 83 // 2. 如果是新制造出来的任务,0x38保存的是make_fcontext中的trampoline 84 // 也就是会跳到那里执行。 85 // 86 // 目前只是放到了r8中,还没有开始执行,后面的过程是继续恢复现场。 87 movq 0x38(%rsp), %r8 /* restore return-address */ 88 89 #if !defined(BOOST_USE_TSX) 90 ldmxcsr (%rsp) /* restore MMX control- and status-word */ 91 fldcw 0x4(%rsp) /* restore x87 control-word */ 92 #endif 93 94 // 保存和恢复的过程是对称的,正是栈的特点 95 movq 0x8(%rsp), %r12 /* restore R12 */ 96 movq 0x10(%rsp), %r13 /* restore R13 */ 97 movq 0x18(%rsp), %r14 /* restore R14 */ 98 movq 0x20(%rsp), %r15 /* restore R15 */ 99 movq 0x28(%rsp), %rbx /* restore RBX */ 100 movq 0x30(%rsp), %rbp /* restore RBP */ 101 102 // 恢复另一个任务保存现场之前的rsp(栈顶) 103 leaq 0x40(%rsp), %rsp /* prepare stack */ 104 105 // 这里面有条件编译,似乎是和32位和64位相关,这里按照上面的分支来分析 106 /* return transfer_t from jump */ 107 #if !defined(_ILP32) 108 /* RAX == fctx, RDX == data */ 109 // rsi是jump_fcontext的第二个参数vp,这里赋值给rdx,rax rdx 110 // 里面存储的是返回值transfer_t的内容,rax是前面我们保存现场 111 // 获得的,也就是说是切换之前的现场,rdx呢,也是切换之前传的, 112 // 也就是说切换之后返回的内容都是切换之前的信息。 113 // 一般网上查到的都是说rax是返回值,但是这里返回结构体,里面 114 // 是两个8字节,到底是怎么传递的很少有地方说,我也是网上查到的 115 // 这种情况是用两个寄存器保存的,rdx也会用来保存返回值,有一个文档可以参考 116 // System V Application Binary Interface AMD64 Architecture Processor Supplement 117 movq %rsi, %rdx 118 #else 119 /* RAX == data:fctx */ 120 salq $32, %rsi 121 orq %rsi, %rax 122 #endif 123 /* pass transfer_t as first arg in context function */ 124 #if !defined(_ILP32) 125 /* RDI == fctx, RSI == data */ 126 #else 127 /* RDI == data:fctx */ 128 #endif 129 130 // rdi是函数调用的第一个参数,rsi是第二个参数 131 // 这里把rax放到rdi中,是为了把切换之前保存的现场作为第一个参数传给要执行的函数 132 // 这是给第一次运行准备的,fn的参数transfer_t的两个成员,一个是rdi,一个是rsi 133 // rsi就是切换之前调用的(即本次调用)那个jump_fcontext传递的第二个参数vp 134 // 135 // 如果不是第一次调用呢,会不会有影响,不会,rdi是caller-saved register,调用者 136 // 负责保存,如果即将切换到的那个任务的对应的函数需要rdi的值,它会自己保存的, 137 // 从jump_fcontext出来之后它会自己恢复,对它来讲就是调用了jump_fcontext这个函数, 138 // 然后过了一段时间返回了,中间的过程完全不知情。 天上走了,又从天上回来。 139 movq %rax, %rdi 140 141 // 前面提到了将返回地址放到r8中,现在跳过去开始执行了 142 /* indirect jump to context */ 143 jmp *%r8 144 .size jump_fcontext,.-jump_fcontext 145 146 /* Mark that we don't need executable stack. */ 147 .section .note.GNU-stack,"",%progbits
简单测试一下
1 #include <stdio.h> 2 3 char stack0[10240];//一个printf会占用1千多字节的栈空间!!!!! 4 char stack1[10240]; 5 6 typedef void* fcontext_t; 7 typedef struct { 8 fcontext_t fctx; 9 void* data; 10 } transfer_t; 11 12 extern transfer_t jump_fcontext(fcontext_t const to, void * vp); 13 extern fcontext_t make_fcontext(void * sp, size_t size, void (* fn)( transfer_t)); 14 15 fcontext_t fcmain, fc0, fc1; 16 17 void fn0(transfer_t t){ 18 printf("%s %d\n", __func__, __LINE__); 19 20 fcontext_t *p = t.data; 21 *p = t.fctx; 22 23 // 切换fn1 24 transfer_t ret = jump_fcontext(fc1, &fc0); 25 p = ret.data; 26 *p = ret.fctx; 27 printf("%s %d\n", __func__, __LINE__); 28 // 切换main 29 jump_fcontext(fcmain, &fc0); 30 printf("never back\n"); 31 } 32 33 void fn1(transfer_t t){ 34 printf("%p\n", t.fctx); 35 printf("%s %d\n", __func__, __LINE__); 36 fcontext_t *p = t.data; 37 *p = t.fctx; 38 // 切换fn0 39 jump_fcontext(fc0, &fc1); 40 printf("never back\n"); 41 } 42 43 int main(int argc, char **argv) { 44 45 fc0 = make_fcontext(stack0 + sizeof stack0, sizeof stack0, fn0); 46 fc1 = make_fcontext(stack1 + sizeof stack1, sizeof stack1, fn1); 47 48 printf("%s %d\n", __func__, __LINE__); 49 // 切换fn0 50 jump_fcontext(fc0, &fcmain); 51 printf("%s %d\n", __func__, __LINE__); 52 53 return 0; 54 }
% gcc -O t.c asm/make_gas.S asm/jump_gas.S
% ./a.out
main 48
fn0 18
0x5591d8183800
fn1 35
fn0 27
main 51
结论:
make_fcontext中的sp是栈顶指针,size是栈空间的大小,虽然cpu可能支持两种方向,但目前我还没有听过栈方向递增的系统,如果是递减栈,应该把栈顶指针设置成最高地址,比如申请了一段内存作为函数栈,大小1024,地址在void *p中,那么应该这样用
make_fcontext(p+1024, 1024, fn);
fn是协程任务启动的时候运行的函数,和线程创建的时候指定的函数类似。make_fcontext的返回值就是一个运行(栈空间)上下文,它将被用于jump_context中的to参数,这样可以启动这个任务。启动任务之后调用fn,fn是jump_context中调用的,函数参数传递的实际上是两部分,第一部分,fcontext_t类型,是栈切换之前的运行上下文,第二个参数是
jump_fcontext中传递的vp参数。jump_fcontext的返回值也分为两部分,第一部分是栈切换之前的运行上下文,第二部分是传递的参数vp(这个vp不是本任务jump_fcontext调用的vp,而是其他任务切换到本任务的时候调用jump_fcontext传递的vp),这是因为 jump_fcontext不是普通的函数,普通的函数在一个栈空间上执行,而jump_fcontext是跨越栈空间的,函数调用前,要保存现场,然后再恢复,jump_fcontext也要保存现场,但是恢复并不是在这个函数调用中恢复的,这个调用已经一去不复返了,只负责跳走,不负责回来,甚至可能永远也回不来了,与其说它回来了,不如说是别的地方跳过来了,jump_fcontext “返回” 的就是跳过来的那个人的信息,它的运行上下文(保存着它的运行现场,cpu寄存器,栈空间),还有它调用jump_fcontext跳过来时传递的参数vp。
一个任务只要执行,它的运行上下文就是在变化的,也就是说只要一个任务一运行,那么保存它的运行上下文的变量(fcontext_t)就失效了,也就是每运行一次就要更新一次,什么时候更新呢,就是本次运行暂停的时候更新,也就是调用jump_fcontext跳到其他地方的时候,也就是说目标任务要更新本任务的运行上下文变量,目标任务激活的时候表现为从它的jump_fcontext返回(实际上不是它返回,而是本务跳到那去执行),返回值中的fcontext_t就是跳之前的运行上下文,也就是本任务的最新的运行上下文,为了更新本任务的运行上下文变量,需要把本任务的运行上下文变量的地址传递给目标任务,这需要借助jump_fcontext的vp参数,它也会出现在目标任务返回的transfer_t中,这就是参数vp和返回值transfer_t的作用吧,当然借助vp还可以传递更多需要交互的信息。
每一个普通任务都是通过make_fcontext创造出来的,但主任务(main函数)不是,主任务切换到了其他任务执行,如果想切换回主任务,就必须获取主任务的运行上下文,fn函数本来只需要自己运行的数据就够了,而它的参数transfer_t还带一个fcontext,就是干这件事用的,它是最新的切换之前的运行上下文,谁启动的它,就更新谁,一个任务切换到一个曾经运行过的任务的时候一定是跳到jump_fcontext的下条指令,可以通过”返回”的transfer_t来实现更新,但是对于新任务,不会从jump_fcontext继续执行,不会得到”返回”的transfer_t,就需要利用参数传递的transfer_t更新。
主任务启动 -> task0 -> jump_fcontext , task0恢复执行,这时候返回的transfer_t不一定是主任务的运行上下文,因为它是切换task0任务,使task0再次运行的那个任务的运行上下文,那个任务未必是主任务,主任务只是第一次启动task0的时候的任务,并不一定是后续激活task0的任务。
如果fn函数运行完成,没有通过jump_fcontext切换任务,直接返回,那么结果是整个进程都退出了,因为它返回之后是返回到了make_fcontext的一段代码中,那段代码的操作是退出进程。从gdb里用bt可以看到,一个任务的调用者是make_fcontext,而不是启动它或再次激活它的时候对应的函数,这是因为make_fcontext里面设置这个函数的时候同时设置好了它的返回地址,所以如果不想退出进程,一个任务完成的时候需要跳到其他任务,然后释放保存本任务的栈空间资源,需要注意的是释放是要在别的地方释放(s_task库中有一个join任务的地方),而不能在跳转之前释放,因为跳转的时候要保存当前运行上下文到当前的栈空间,如果释放了再跳转会造成非法地址的错误。