协程(coroutine)与subroutine同样作为程序执行单元的抽象被一些语言当作基 础实现,两者的抽象方式大致区别在于:
- 多个执行单元之间的关系:
对于subroutine来说,存在一个调用与被调用的关系,比如在a-subroutine里调用 b-subroutine, 那么a-subroutine就是调用者,b-subroutine是被调用者,它们共 享一个线程的堆栈.
而多个coroutine之间的关系是对等的,即便在a-coroutine里创建了 b-coroutine,他们之间也不会有任何层级关系.
subroutine作为一种通用的抽象比较容易实现,而要实现coroutine至少需要两个 条件:
-
要有一个全局的调度器并且每个coroutine得有一个堆栈空间.
-
调度器用来在多个对等的coroutine之间做切换操作,每个coroutine的堆栈 用于存储各自的上下文内容.
- 执行单元的入口与出口:
在一个典型的subroutine实现里,执行单元的入口和出口只能有一个, 这是共享 调用栈带来的局限性, 比如(x86-64平台)我们在a-subroutine里调用 b-subroutine,那么会先把前6个参数依次写入寄存器:rdi,rsi,rdx,rcx,r8,r9,6 个以上的参数从右至左压栈,rsp不断上移指向栈顶,然后把b-subroutine调用之 后的那条指令地址压栈,rsp上移,然后rbp压栈,把rsp指向rbp的栈地址,最后把 b-subroutine里的局部变量依次压栈,rsp继续上移.这就是调用时的入口过程, 当执行是b-subroutine时,就得依次出栈,然后退出,执行a-subroutine里的下一 条指令,这是出口过程.很明显,这里b-subroutine只有一个入口和一个出口,因为 必须要等b-subroutine执行完成后(出栈)才能继续执行a-subroutine.
而一个典型的coroutine实现,执行单元可以有多个入口和出口, 因为栈不共享, 一个通用的实现模式是用堆来表示coroutine的调用栈, 当我们在a-coroutine里 创建b-coroutine, 在堆上分配一块空间用于表示b-coroutine的堆栈, 在执行 b-coroutine时,我们可以在任意点把当前的执行信息写回这块在堆上分配的空 间,然后把rsp指向a-coroutine的栈顶,rbp指向a-coroutine的栈frame,rip指向 a-coroutine的需要继续执行的指令的地址, 这也就是所谓的用户态的上下文切 换,当下一次需要继续执行b-coroutine的时候,保存当前coroutine的上下文, 恢复b-coroutine的上下文就行了.
上面描述的上下文切换是在用户态进行的,unix-like的环境下, glibc库通常都 会有一个描述上下文结构的定义ucontext_t在ucontext.h文件里,并有四个函 数:getcontext,setcontext,makecontext,swapcontext分别用于在用户态保存上 下文,恢复上下文,创建上下文和保存且恢复上下文,使用它们可以实现一个基本 的协程系统, 比如libtask就是一个运行在unix平台上的基础协程库, 能在用户 态实现多个执行流轮换执行,libtask对glibc的 getcontext,setcontext,makecontext,swapcontext做了简单的封装,因为这四个 函数是libtask实现的基础,所以要研究libtask前,先得了解这4个函数的作用与 实现机制.
这里有一个关键的数据结构,即user level context:
typedef struct ucontext
{
unsigned long int uc_flags;
// 另一个执行流的上下文地址,在x86x64平台下也就是rbx寄存器里的内容
struct ucontext *uc_link;
//用于此上下文结构的堆栈,存储在堆区
stack_t uc_stack;
// mcontext_t结构体用于存储完整的进程状态信息
mcontext_t uc_mcontext;
// 需要block的信号掩码
__sigset_t uc_sigmask;
// fpu寄存器结构
struct _libc_fpstate __fpregs_mem;
} ucontext_t;
ucontext结构体里的uc_stack是在堆上分配的,并做为堆栈用于此上下文, makecontext函数将会设置uc_stack与相应寄存器里的值uc_stack的结构大致 是这样的:
---------------------------------------
| 下一个上下文的地址 |
---------------------------------------
| 参数 7-n(假如回调函数的参数大于7个) |
---------------------------------------
| 返回地址 | %rsp -> ---------------------------------------
另外寄存器里的内容:
%rdi,%rsi,%rdx,%rcx,%r8,%r9: 分别存储参数1-6
%rbx : 下一个上下文的地址
%rsp : 指向栈顶
当我们需要创建一个用户态上下文的时候, 需要调用makecontext函数,此函数接 受一个ucontext_t类型的指针(ucp), 一个函数指针(切换到此上下文寄存器esp 所指向的地址, 多个函数参数的指针地址(都是int类型,所以在64位环境下需要 用两个参数描述一个待执行函数参数的指针地址))
x86x64环境下makecontext的源码:
__makecontext (ucontext_t *ucp, void (*func) (void), int argc, ...)
{
extern void __start_context (void);
greg_t *sp;
unsigned int idx_uc_link;
va_list ap;
int i;
/* Generate room on stack for parameter if needed and uc_link. */
//把栈顶的地址赋给sp变量
sp = (greg_t *) ((uintptr_t) ucp->uc_stack.ss_sp
+ ucp->uc_stack.ss_size);
// 判断回调函数的参数是否大于6,如果大于6,那么需要把第7-n个参数地址压栈
// rsp往上移
sp -= (argc > 6 ? argc - 6 : 0) + 1;
/* Align stack and make space for trampoline address. */
// 栈对齐并且rsp往上移8位
sp = (greg_t *) ((((uintptr_t) sp) & -16L) - 8);
// 用于定位下一个上下文的地址的索引
idx_uc_link = (argc > 6 ? argc - 6 : 0) + 1;
/* Setup context ucp. */
/* Address to jump to. */
// 下面几行代码把回调函数的地址写入RIP
// 把下一个上下文的地址写入RBX
// 把sp(栈顶)的地址写入RSP
ucp->uc_mcontext.gregs[REG_RIP] = (uintptr_t) func;
/* Setup rbx.*/
ucp->uc_mcontext.gregs[REG_RBX] = (uintptr_t) &sp[idx_uc_link];
ucp->uc_mcontext.gregs[REG_RSP] = (uintptr_t) sp;
/* Setup stack. */
sp[0] = (uintptr_t) &__start_context;
sp[idx_uc_link] = (uintptr_t) ucp->uc_link;
// 下面把参数写入context, 与linux处理参数机制一致,
// 当参数少于7时,对应寄存器:rdi, rsi, rdx, rcx, r8, r9
// 当参数大于7,前6个参数仍然写入rdi, rsi, rdx, rcx, r8, r9, 之后的参数从后至前压栈
va_start (ap, argc);
/* Handle arguments.
The standard says the parameters must all be int values. This is
an historic accident and would be done differently today. For
x86-64 all integer values are passed as 64-bit values and
therefore extending the API to copy 64-bit values instead of
32-bit ints makes sense. It does not break existing
functionality and it does not violate the standard which says
that passing non-int values means undefined behavior. */
for (i = 0; i < argc; ++i)
switch (i)
{
case 0:
ucp->uc_mcontext.gregs[REG_RDI] = va_arg (ap, greg_t);
break;
case 1:
ucp->uc_mcontext.gregs[REG_RSI] = va_arg (ap, greg_t);
break;
case 2:
ucp->uc_mcontext.gregs[REG_RDX] = va_arg (ap, greg_t);
break;
case 3:
ucp->uc_mcontext.gregs[REG_RCX] = va_arg (ap, greg_t);
break;
case 4:
ucp->uc_mcontext.gregs[REG_R8] = va_arg (ap, greg_t);
break;
case 5:
ucp->uc_mcontext.gregs[REG_R9] = va_arg (ap, greg_t);
break;
default:
/* Put value on stack. */
sp[i - 5] = va_arg (ap, greg_t);
break;
}
va_end (ap);
}
当需要切换上下文时,需要调用swapcontext, swapcontext接受两个参数,
-
当前的上下文(u_context).
-
新的上下文(u_context).
x86-64下源码如下:
ENTRY(__swapcontext)
/* Save the preserved registers, the registers used for passing args,
and the return address. */
// 这里oRBX, oRBP...都是一些定义好的宏,
// 扩展一下比如oRBX是:offsetof(ucontext_t, gregs[REG_##RBP])
// 指的是RBP所在ucontext_t这个结构体里的偏移量, 所以
// movq %rbx, oRBX(%rid) => movq %rbx, <RBX的偏移量>(%rid)
// 这里所做的工作是把当前寄存器中的内容写回堆栈(调用makecontext前在堆上分配的空间)
// 并且把当前上下文的signalmask写回堆栈, 然后把新的上下文所在的栈地址写入各寄存器
// 进栈顺序依次是rbx, rbp(栈基址), %r12, %r13, %r14, %15, %rdi(第2个参数), %rsi(第2个参数),
// %rdx(第3个参数), %rcx(第4个参数),%8(第5个参数), %9(第6个参数), %rip(栈顶(原rsp寄存器)),
// %rsp(栈顶+8(排除掉返回地址))
movq %rbx, oRBX(%rdi)
movq %rbp, oRBP(%rdi)
movq %r12, oR12(%rdi)
movq %r13, oR13(%rdi)
movq %r14, oR14(%rdi)
movq %r15, oR15(%rdi)
movq %rdi, oRDI(%rdi)
movq %rsi, oRSI(%rdi)
movq %rdx, oRDX(%rdi)
movq %rcx, oRCX(%rdi)
movq %r8, oR8(%rdi)
movq %r9, oR9(%rdi)
movq (%rsp), %rcx
movq %rcx, oRIP(%rdi)
leaq 8(%rsp), %rcx /* Exclude the return address. */
movq %rcx, oRSP(%rdi)
/* 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)
/* Save the floating-point environment. */
fnstenv (%rcx)
stmxcsr oMXCSR(%rdi)
/* The syscall destroys some registers, save them. */
// 这里保存%rsi的内容进%r12寄存器,因为下面会执行系统调用
movq %rsi, %r12
/* Save the current signal mask and install the new one with
rt_sigprocmask (SIG_BLOCK, newset, oldset,_NSIG/8). */
leaq oSIGMASK(%rdi), %rdx
leaq oSIGMASK(%rsi), %rsi
movl $SIG_SETMASK, %edi
movl $_NSIG8,%r10d
movl $__NR_rt_sigprocmask, %eax
syscall
cmpq $-4095, %rax /* Check %rax for error. */
jae SYSCALL_ERROR_LABEL /* Jump to error handler if error. */
/* Restore destroyed registers. */
// 恢复rsi寄存器, rsi里目前存储的是新的上下文结构体所在的地址
movq %r12, %rsi
/* Restore the floating-point context. Not the registers, only the
rest. */
movq oFPREGS(%rsi), %rcx
fldenv (%rcx)
ldmxcsr oMXCSR(%rsi)
// 下面依次把%rsi(新的上下文)内容写入寄存器
/* Load the new stack pointer and the preserved registers. */
movq oRSP(%rsi), %rsp
movq oRBX(%rsi), %rbx
movq oRBP(%rsi), %rbp
movq oR12(%rsi), %r12
movq oR13(%rsi), %r13
movq oR14(%rsi), %r14
movq oR15(%rsi), %r15
/* The following ret should return to the address set with
getcontext. Therefore push the address on the stack. */
movq oRIP(%rsi), %rcx
pushq %rcx
/* Setup registers used for passing args. */
// 按顺序(rsi还需要使用故除外)依次写入参数: rdi, rdx, rcx, r8,r9
movq oRDI(%rsi), %rdi
movq oRDX(%rsi), %rdx
movq oRCX(%rsi), %rcx
movq oR8(%rsi), %r8
movq oR9(%rsi), %r9
/* Setup finally %rsi. */
// 把第二个参数写入rsi
movq oRSI(%rsi), %rsi
/* Clear rax to indicate success. */
xorl %eax, %eax
ret
PSEUDO_END(__swapcontext)
从swapcontext的实现可以看出swapcontext所做的事很简单,保存,恢复.把当前 上下文按顺序保存到rdi的偏移,新的上下文(rsi指向的地址)覆盖老的上下文.
swapcontext实际上是getcontext/setcontext的结合体,比如参照ai64的实现:
int
__swapcontext (ucontext_t *oucp, const ucontext_t *ucp)
{
struct rv rv = __getcontext (oucp);
if (rv.first_return)
__setcontext (ucp);
return 0;
}
之前说到glibc的这4个函数是libtask的基础, 其实更准确的说libtask是其较浅 的封装,我们先来看看libtask的核心结构:task的实现:
struct Task
{
char name[256];// offset known to acid
char state[256];
Task *next;
Task *prev;
Task *allnext;
Task *allprev;
Context context;
uvlong alarmtime;
uint id;
uchar *stk; /*stack start location*/
uint stksize;
int exiting;
int alltaskslot;
int system;
int ready;
void (*startfn)(void*);
void *startarg;
void *udata;
};
// 这个结构体里context就是ucontext_t:
struct Context {
ucontext_t uc;
}
ucontext_t里存储了上下文内容, 也就是swapcontext函数里两个参数的类型stk 指向在堆上分配的空间,被做为栈赋值给ucontext_t的ss_sp字段, 这是 makecontext函数要求的,在调用makecontext函数前,必须为参数ucp分配一块地 址作为上下文的栈空间,libtask会执行初始化工作:
t->context.uc.uc_stack.ss_sp = t->stk+8;
t->context.uc.uc_stack.ss_size = t->stksize-64;
...
在调用swapcontext前,可以通过比较当前context的地址是否大于stk的地址来判 断栈空间是否够用:
void needstack(int n)
{
Task *t;
t = taskrunning;
if((char*)&t <= (char*)t->stk
|| (char*)&t - (char*)t->stk < 256+n){
fprint(2, "task stack overflow: &t=%p tstk=%p n=%d\n", &t, t->stk, 256+n);
abort();
}
}
这个实现有点tricky, 画副图说明:
所以当 (char)&t <= (char)t->task时,说明栈空间不够了.
如果栈空间足够,那么就可以调用swapcontext了:
static void
contextswitch(Context *from, Context *to)
{
if(swapcontext(&from->uc, &to->uc) < 0){
fprint(2, "swapcontext failed: %r\n");
assert(0);
}
}
contextswitch函数是由taskscheduler函数驱动的,taskscheduler是一个task 全局调度器, 运行在进程的整个生命周期中,调度器结束,进程关闭:
static void
taskscheduler(void)
{
int i;
Task *t;
taskdebug("scheduler enter");
// 进入主循环
for(;;){
if(taskcount == 0)
//非system task数量为0, 退出进程
exit(taskexitval);
t = taskrunqueue.head;
if(t == nil){
//没有可执行的task, 退出进程
fprint(2, "no runnable tasks! %d tasks stalled\n", taskcount);
exit(1);
}
// 从全局taskrunqueue里删除当前准备执行的task,
// 通过把t赋值给taskrunning,把t设置为准备执行的task
// 然后调用contextswitch切换上下文
deltask(&taskrunqueue, t);
t->ready = 0;
taskrunning = t;
tasknswitch++;
taskdebug("run %d (%s)", t->id, t->name);
contextswitch(&taskschedcontext, &t->context);
// taskrunning复位,
// 判断task的exiting字段是否为1,如果为1并且task不是system task,
// 那么么全局task计数器taskcount减1
taskrunning = nil;
if(t->exiting){
if(!t->system)
taskcount--;
i = t->alltaskslot;
alltask[i] = alltask[--nalltask];
alltask[i]->alltaskslot = i;
free(t);
}
}
}
另外contextswitch也可以手工调用,使用taskyield函数:
int
taskyield(void)
{
int n;
n = tasknswitch;
taskready(taskrunning);
taskstate("yield");
taskswitch();
return tasknswitch - n - 1;
}
这样更具灵活性,因为有时候task需要主动让出CPU,这是一个通用的模式:在即将 执行堵塞系统调用前主动让出CPU.
github上有一个使用epoll的修改版本,另外如果想要利用多核, 还是需要使用线程,在每个线程里跑多个task,不过如果要实现这个工作量还比较大.