一、协程基础
按照执行单位从大到小的粒度区分,最早的执行单位就是进程(或者linux内核中所说的task)、之后为了资源共享,又有了线程的概念。线程在内核中成为基础的执行单位。线程这个概念对内核来说也是可见的,也就是说内核为了支持线程和进程的机制做过相关的处理,但是在linux下,这个处理的大部分工作在用户态的glibc完成,其中包含了线程本地存储(Thread Local Sotrage),轻量级锁(Low Level Lock)等常见功能。之后再有协程(coroutine)的概念,这个概念在之前关注过,甚至遇到过,只是当时这个实现并没有这么个正式的"协程"的叫法,就好想说你自然而然的想到了一个排序算法,虽然之后才知道它有一个学名叫"冒泡排序"。这里又想到了《设计模式》作者所说的意思,这些实现方法在很多项目中都有一些这种实现方法,这本书的意义只是把这些实现只是给它们进行了分类和总结,并给出一个官方的说法。这些工作看起来意义不大,但事实上在工程中却真实具有重要的意义。这个第一重要的意义就是大家在沟通的过程中可以通过简单的一个“单件模式”就知道具体的意义,而不用通过更加详细的冗余描述来描述这些实现的细节。再通俗的说,中国的很多成语具有同样的意义,一个复杂的语意环境,很多时候只有通过一个简单的成语才可以恰如其分的描述,而设计模式中描述的大部分模式名称为软件的沟通和交流提供了一个规范的描述。
协程的实现完全对内核透明,它的实现在用户态完成,从这种实现机制上来看,一个直观的结论就是它更加轻量级和高效,因为使用内核态服务通常意味着系统调用,而系统调用的代价通常都比较高。在异步框架下,这种协程的机制还有对异步服务使用者透明的优势。具体说就是在异步函数中,把请求发送出去之后,通过协程的swap功能直接跳转,等响应回来之后再从切出去的位置继续执行,这样当异步函数返回的时候,相当于响应已经到达,这样调用者看到的是同步调用而系统使用的是异步调用。
二、测试例子
下面是在makecontext函数man手册自带的例子修改的基础代码
tsecer@harry: cat cort.cpp
#include <ucontext.h>
#include <stdio.h>
#include <stdlib.h>
#define LOOP 3
#define COCOUNT 10
#define handle_error(msg)
do { perror(msg); exit(EXIT_FAILURE); } while (0)
static ucontext_t uctx_main;
typedef struct tagCoroutine
{
char m_abStack[0x1000];
ucontext_t m_stContext;
}TCoroutine;
TCoroutine g_astCoroutine[COCOUNT];
static void
worker(int iID)
{
printf("worker %d: started
", iID);
for (int i = 0; i < LOOP; i++)
{
printf("worker %d: looping %d
", iID, i);
if (swapcontext(&g_astCoroutine[iID].m_stContext, &uctx_main) == -1)
{
handle_error("swapcontext");
}
printf("worker %d: continue %d
", iID, i);
}
printf("worker %d: returning
", iID);
}
int
main(int argc, char *argv[])
{
getcontext(&uctx_main);
for (int i = 0; i < COCOUNT; i++)
{
g_astCoroutine[i].m_stContext.uc_stack.ss_sp = g_astCoroutine[i].m_abStack;
g_astCoroutine[i].m_stContext.uc_stack.ss_size = sizeof(g_astCoroutine[i].m_abStack);
g_astCoroutine[i].m_stContext.uc_link = &uctx_main;
//getcontext(&g_astCoroutine[i].m_stContext);
makecontext(&g_astCoroutine[i].m_stContext, (void (*)())worker, 1, i);
//从汇编代码上看,uctx_main放在edi中,g_astCoroutine[i].m_stContext放在rsi
swapcontext(&uctx_main, &g_astCoroutine[i].m_stContext);
}
int iSel;
while (EOF != (iSel = getchar()))
{
if (iSel >= '1' && iSel < '1' + COCOUNT)
{
swapcontext(&uctx_main, &g_astCoroutine[iSel - '1'].m_stContext);
}
else if (iSel != '
' && iSel != '
')
{
printf("invalid selector %d
", iSel);
}
}
printf("main: exiting
");
return 0;
}
tsecer@harry:
注意这个代码中,其中在循环中注释掉了
getcontext(&g_astCoroutine[i].m_stContext);
语句。因为直观上理解,在执行了makecontext之后,这个函数并不需要getcontext设置的上下文,我们也永远不会返回到getcontext保存的上下文,所以这个语句是多于的。所以在我的第一次实现中并没有加这个语句,但是执行的时候发现进程直接跪了。
三、为什么需要调用getcontext
tsecer@harry: ./a.out
Segmentation fault (core dumped)
tsecer@harry: gdb ./a.out
GNU gdb (GDB) Fedora 7.6.50.20130731-16.fc20
……
(gdb) disas
Dump of assembler code for function swapcontext:
0x0000003367a45860 <+0>: mov %rbx,0x80(%rdi)
0x0000003367a45867 <+7>: mov %rbp,0x78(%rdi)
0x0000003367a4586b <+11>: mov %r12,0x48(%rdi)
0x0000003367a4586f <+15>: mov %r13,0x50(%rdi)
0x0000003367a45873 <+19>: mov %r14,0x58(%rdi)
0x0000003367a45877 <+23>: mov %r15,0x60(%rdi)
0x0000003367a4587b <+27>: mov %rdi,0x68(%rdi)
0x0000003367a4587f <+31>: mov %rsi,0x70(%rdi)
0x0000003367a45883 <+35>: mov %rdx,0x88(%rdi)
0x0000003367a4588a <+42>: mov %rcx,0x98(%rdi)
0x0000003367a45891 <+49>: mov %r8,0x28(%rdi)
0x0000003367a45895 <+53>: mov %r9,0x30(%rdi)
0x0000003367a45899 <+57>: mov (%rsp),%rcx
0x0000003367a4589d <+61>: mov %rcx,0xa8(%rdi)
0x0000003367a458a4 <+68>: lea 0x8(%rsp),%rcx
0x0000003367a458a9 <+73>: mov %rcx,0xa0(%rdi)
0x0000003367a458b0 <+80>: lea 0x1a8(%rdi),%rcx
0x0000003367a458b7 <+87>: mov %rcx,0xe0(%rdi)
0x0000003367a458be <+94>: fnstenv (%rcx)
0x0000003367a458c0 <+96>: stmxcsr 0x1c0(%rdi)
0x0000003367a458c7 <+103>: mov %rsi,%r12
0x0000003367a458ca <+106>: lea 0x128(%rdi),%rdx
---Type <return> to continue, or q <return> to quit---
0x0000003367a458d1 <+113>: lea 0x128(%rsi),%rsi
0x0000003367a458d8 <+120>: mov $0x2,%edi
0x0000003367a458dd <+125>: mov $0x8,%r10d
0x0000003367a458e3 <+131>: mov $0xe,%eax
0x0000003367a458e8 <+136>: syscall
0x0000003367a458ea <+138>: cmp $0xfffffffffffff001,%rax
0x0000003367a458f0 <+144>: jae 0x3367a45950 <swapcontext+240>
0x0000003367a458f2 <+146>: mov %r12,%rsi
0x0000003367a458f5 <+149>: mov 0xe0(%rsi),%rcx
=> 0x0000003367a458fc <+156>: fldenv (%rcx)
看下i386_64中对于地方对应的汇编代码
glibc-2.11sysdepsunixsysvlinuxx86_64swapcontext.S
leaq oFPREGSMEM(%rdi), %rcx
movq %rcx, oFPREGS(%rdi)
/* Save the floating-point environment. */
fnstenv (%rcx)
stmxcsr oMXCSR(%rdi)
这里的汇编代码从context中的oFPREGS位置取出指针,然后将当前的浮点寄存器保存在该地址中。也就是说context中有一个指针成员,
/* Structure to describe FPU registers. */
typedef struct _libc_fpstate *fpregset_t;
/* Context to describe whole processor state. */
typedef struct
{
gregset_t gregs;
/* Due to Linux's history we have to use a pointer here. The SysV/i386
ABI requires a struct with the values. */
fpregset_t fpregs;
unsigned long int oldmask;
unsigned long int cr2;
} mcontext_t;
这个地方的注释中说明了"由于Linux的历史原因,这个地方必须使用指针",而这个指针就是在getcontext中初始化的,如果没有调用这个函数,在swapcontext中使用这个指针的时候就会出现内存访问异常。
/* We have separate floating-point register content memory on the
stack. We use the __fpregs_mem block in the context. Set the
links up correctly. */
leaq oFPREGSMEM(%rdi), %rcx
movq %rcx, oFPREGS(%rdi)
四、延展的一些问题
上面例子的意思是想创建COCOUNT个协程,然后主线程读取用户输入的协程ID,并使用这个ID来唤醒协程继续执行,每个协程执行LOOP次之后退出。下面是效果:
tsecer@harry: ./a.out
worker 0: started
worker 0: looping 0
worker 1: started
worker 1: looping 0
worker 2: started
worker 2: looping 0
worker 3: started
worker 3: looping 0
worker 4: started
worker 4: looping 0
worker 5: started
worker 5: looping 0
worker 6: started
worker 6: looping 0
worker 7: started
worker 7: looping 0
worker 8: started
worker 8: looping 0
worker 9: started
worker 9: looping 0
1
worker 0: continue 0
worker 0: looping 1
1
worker 0: continue 1
worker 0: looping 2
1
worker 0: continue 2
worker 0: returning
1
worker 0: continue 3
worker 0: returning
Segmentation fault (core dumped)
tsecer@harry:
在第四次执行的时候coredump了,这个是意料之中的,因为执行了3次之后,此时协程函数已经返回了(从"worker 0: returning"这个输出可以验证),所以每个协程结束之后要设置标志位,不能在swapcontext这个协程了。
worker 0: continue 3
worker 0: returning
Program received signal SIGSEGV, Segmentation fault.
0x000000000060d540 in uctx_main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.18-11.fc20.x86_64 libgcc-4.8.2-1.fc20.x86_64 libstdc++-4.8.2-1.fc20.x86_64
(gdb) bt
#0 0x000000000060d540 in uctx_main ()
#1 0x0000003367a479ce in __start_context () from /lib64/libc.so.6
Backtrace stopped: frame did not save the PC
(gdb)
(gdb) disas
Dump of assembler code for function _ZL9uctx_main:
=> 0x000000000060d540 <+0>: add %al,(%rax)
(gdb) info reg
rax 0x14 20
rbx 0x602090 6299792
rcx 0x7ffffff7 2147483639
rdx 0x0 0
rsi 0x7ffff7ffb000 140737354117120
rdi 0x1 1
rbp 0x7fffffffe050 0x7fffffffe050
rsp 0x602090 0x602090 <g_astCoroutine+4080>
r8 0x8 8
r9 0x0 0
r10 0x0 0
r11 0x246 582
r12 0x400710 4196112
r13 0x7fffffffe130 140737488347440
r14 0x0 0
r15 0x0 0
rip 0x60d540 0x60d540 <uctx_main>
……
(gdb) info addr uctx_main
Symbol "uctx_main" is static storage at address 0x60d540.
(gdb) p &g_astCoroutine[0]
$1 = (tagCoroutine *) 0x6010a0 <g_astCoroutine>
(gdb)
这里函数竟然执行到了一个数据段的地址,其中rip的地址0x60d540是uctx_main的起始地址,正如赵本山老师说的“这个世界太疯狂了,老鼠都给猫当伴娘了”。
五、协程返回之后再次swap to该协程会怎样
事情要从makecontext说起,
/* Setup context ucp. */
/* Address to jump to. */
ucp->uc_mcontext.gregs[REG_RIP] = (long int) func;
/* Setup rbx.*/将link在堆栈中的地址放入RBX寄存器中,根据x86_64的ABI规范,被调用函数需要保证RBX的值在调用前后不变。
ucp->uc_mcontext.gregs[REG_RBX] = (long int) &sp[idx_uc_link];
ucp->uc_mcontext.gregs[REG_RSP] = (long int) sp;
//堆栈中栈顶放入__start_context函数的地址,也就是函数返回之后跳转到__start_context函数中
/* Setup stack. */
sp[0] = (unsigned long int) &__start_context;
sp[idx_uc_link] = (unsigned long int) ucp->uc_link;
……
for (i = 0; i < argc; ++i)
switch (i)
{
case 0:
ucp->uc_mcontext.gregs[REG_RDI] = va_arg (ap, long int);
break;
case 1:
ucp->uc_mcontext.gregs[REG_RSI] = va_arg (ap, long int);
break;
case 2:
ucp->uc_mcontext.gregs[REG_RDX] = va_arg (ap, long int);
break;
case 3:
ucp->uc_mcontext.gregs[REG_RCX] = va_arg (ap, long int);
break;
case 4:
ucp->uc_mcontext.gregs[REG_R8] = va_arg (ap, long int);
break;
case 5:
ucp->uc_mcontext.gregs[REG_R9] = va_arg (ap, long int);
break;
default:
/* Put value on stack. */
sp[i - 5] = va_arg (ap, unsigned long int);
break;
}
这里说明了x86_64中,前6个参数依次放入寄存器REG_RDI、REG_RSI、REG_RDX、REG_RCX、REG_R8、REG_R9中,其它的函数参数放入堆栈中。在__start_context函数中,从堆栈中弹出link的值到rdi,然后调用__setcontext返回(即rdi中保存setcontext的参数),这个和makecontext函数中描述的寄存器使用规范一致。
ENTRY(__start_context)
/* This removes the parameters passed to the function given to
'makecontext' from the stack. RBX contains the address
on the stack pointer for the next context. */
movq %rbx, %rsp
popq %rdi /* This is the next context. */
cfi_adjust_cfa_offset(-8)
testq %rdi, %rdi
je 2f /* If it is zero exit. */
call JUMPTARGET(__setcontext)
在3次调用之后协程执行了这个代码,此时堆栈上保存的__start_context依然存在,在__start_context函数popq %rdi;和call JUMPTARGET(__setcontext)指令之后,协程堆栈中用来保存link的位置被call指令的下一条指令的位置覆盖;然后进入__setcontext函数,在该函数的开始执行的指令是
ENTRY(__setcontext)
/* Save argument since syscall will destroy it. */
pushq %rdi
这个pushd修改的是协程堆栈中link的第一个quad位置出的内容(pushd的值是将要切换的目的协程,这里为worker线程返回的主线程uctx_main的位置),这个位置就是之前存放__start_context函数地址的位置,也就是makecontext函数中sp[0] = (unsigned long int) &__start_context;语句中的sp[0]变量的值。这样当第四次调用返回的时候,从堆栈中弹出的返回地址也就是uctx_main的地址,也就是从堆栈上看到的调用链
(gdb) bt
#0 0x000000000060d540 in uctx_main ()
#1 0x0000003367a479ce in __start_context () from /lib64/libc.so.6
Backtrace stopped: frame did not save the PC
(gdb)
这里的现象其实是在协程返回之后,协程对应的context保存的依然是协程最后一次swap出去时的上下文,所以第四次调用的时候依然从循环内继续执行,只是由于不满足for循环的条件而再次退出协程,并且由于堆栈中内容被修改而导致异常。所以要区分context中保存的内容和协程堆栈中保存内容的区别。
六、结论
这里的结论简单而直观:1、执行swapcontext之前必须调用getcontext初始化context;2、协程退出之后不要再swap到该协程。只是这里结合例子和实现原理进一步讨论了原因。