关于linux下协程的通用实现及libtask库源码解析

协程(coroutine)与subroutine同样作为程序执行单元的抽象被一些语言当作基 础实现,两者的抽象方式大致区别在于:

  1. 多个执行单元之间的关系:

对于subroutine来说,存在一个调用与被调用的关系,比如在a-subroutine里调用 b-subroutine, 那么a-subroutine就是调用者,b-subroutine是被调用者,它们共 享一个线程的堆栈.

而多个coroutine之间的关系是对等的,即便在a-coroutine里创建了 b-coroutine,他们之间也不会有任何层级关系.

subroutine作为一种通用的抽象比较容易实现,而要实现coroutine至少需要两个 条件:

  • 要有一个全局的调度器并且每个coroutine得有一个堆栈空间.

  • 调度器用来在多个对等的coroutine之间做切换操作,每个coroutine的堆栈 用于存储各自的上下文内容.

  1. 执行单元的入口与出口:

在一个典型的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接受两个参数,

  1. 当前的上下文(u_context).

  2. 新的上下文(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,不过如果要实现这个工作量还比较大.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

weixin_abctee123

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值