今天看啥  ›  专栏  ›  西门早柿

协程实现原理

西门早柿  · 简书  ·  · 2021-02-12 23:00

用户空间切换

linux 的系统调用提供了在用户空间进行上下文切换的能力。go 语言中用户空间的上下文切换用的是汇编实现,怀疑可能是为了跨平台及提高效率而为之。后面用 linux 提供的系统调用来实现一个简单的用户空间上下文切换,反汇编它,看与 go 语言的汇编实现有什么异同。下面首先来看想关的四个系统调用。毕竟是系统调用,会带来用户态和内核态之间的切换开销,这可能也是 go 用汇编实现的原因之一。

struct ucontext

先来看一下关键的数据结构:

#include <ucontext.h>
typedef struct ucontext {
        struct ucontext *uc_link;
        sigset_t         uc_sigmask;
        stack_t          uc_stack;
        mcontext_t       uc_mcontext;
        ...
    } ucontext_t;

其中 uc_link 是当前上下文结束,程序继续执行的上下文。 uc_sigmask 是该上下文的信号屏蔽掩码。uc_stack 是该上下文使用的栈。 uc_mcontext 是机器相关的上下文保存内容,主要包括调用线程的寄存器。

getcontext
int getcontext(ucontext_t *ucp);

用当前活跃的用户上下文初始化 ucp 指向的结构体。 getcontext 这个函数是汇编实现的,在 getcontext.S 这个文件里。

#include "offsets.h"

/*  int _Ux86_getcontext (ucontext_t *ucp)

  Saves the machine context in UCP necessary for libunwind.
  Unlike the libc implementation, we don't save the signal mask
  and hence avoid the cost of a system call per unwind.

*/

/*    .global _Ux86_getcontext
    .type _Ux86_getcontext, @function
_Ux86_getcontext:*/
    .global getcontext
    .type getcontext, @function
getcontext:
    .cfi_startproc
    mov    4(%esp),%eax  /* ucontext_t* */

    /* EAX is not preserved. */
    movl    $0, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EAX_OFF)(%eax)

    movl    %ebx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EBX_OFF)(%eax)
    movl    %ecx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_ECX_OFF)(%eax)
    movl    %edx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EDX_OFF)(%eax)
    movl    %edi, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EDI_OFF)(%eax)
    movl    %esi, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_ESI_OFF)(%eax)
    movl    %ebp, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EBP_OFF)(%eax)

    movl    (%esp), %ecx
    movl    %ecx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_EIP_OFF)(%eax)

    leal    4(%esp), %ecx        /* Exclude the return address.  */
    movl    %ecx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_ESP_OFF)(%eax)

    /* glibc getcontext saves FS, but not GS */
    xorl    %ecx, %ecx
    movw    %fs, %cx
    movl    %ecx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_FS_OFF)(%eax)

    leal    LINUX_UC_FPREGS_MEM_OFF(%eax), %ecx
    movl    %ecx, (LINUX_UC_MCONTEXT_OFF+LINUX_SC_FPSTATE_OFF)(%eax)
    fnstenv    (%ecx)
    fldenv    (%ecx)

    xor    %eax, %eax
    ret
    .cfi_endproc
    /*.size    _Ux86_getcontext, . - _Ux86_getcontext*/
    .size    getcontext, . - getcontext

    /* We do not need executable stack.  */
    .section        .note.GNU-stack,"",@progbits

与 go 中的汇编实现还是有点相像的。做的事情其实是差不多的,只不过这是通过系统调用实现的。主要就是把各寄存器的值保存到内存结构体中。

setcontext
#include <ucontext.h>
int
setcontext(const ucontext_t *ucp);

setcontext 将之前保存的 ucp 指针指向的 context 恢复到当前线程的上下文中,即恢复各种寄存器。
其中 ucp 指向的 context 要不来自 getcontext 要不来自 makecontext。如果来自 getcontext,则跟什么都没发生过一样。如果来自 makecontext,则执行 makecontext 里指定的函数,如果执行完毕则继续执行 uc_link 指向的 context,如果 uc_link 是 null,程序结束。
setcontext 也是汇编实现的,基本上就是 getcontext 的逆操作。

makecontext switchcontext
#include <ucontext.h>

     void
     makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);

     int
     swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

makecontext 修改 ucp 指向的 context。the caller must allocate a new stack for this context and assign its address to ucp->uc_stack, and define a successor context and assign its address to ucp->uc_link. Also the func.

When this context is later activated (using setcontext(3) or swapcontext()) the function func is called, and passed the series of integer (int) arguments that follow argc; the caller must specify the number of these arguments in argc. When this function returns, the successor context is activated. If the successor context pointer is NULL, the thread exits.

The swapcontext() function saves the current context in the structure pointed to by oucp, and then activates the context pointed to by ucp.

其中 makecontext 是 c 实现的,因为不涉及寄存器的操作,switchcontext 是汇编实现的,可以看做是 getcontext 和 setcontext 的结合。

一个例子
#include <ucontext.h>
#include <stdlib.h>
#include <stdio.h>

ucontext_t ctx[3];
ucontext_t* running;

int c = 80;

void shedule() {
    swapcontext(running, &ctx[0]);
}

void foo1(int a, int b) {
    printf("foo1 %d %d\n", a, b);
    printf("c: %d\n", c);
    printf("foo1 yield....\n");
    shedule();
    printf("foo1 resume and exit\n");
}

void foo2() {
    printf("foo2...\n");
    printf("foo2 yield....\n");
    shedule();
    printf("foo2 resume and exit\n");
}

int main() {
    char st1[8192];
    char st2[8192];

    // shedule g foo1
    getcontext(&ctx[1]);
    ctx[1].uc_stack.ss_sp = st1;
    ctx[1].uc_stack.ss_size = sizeof(st1);
    ctx[1].uc_link = &ctx[0];
    makecontext(&ctx[1], foo1, 2, 7, 8);
    running = &ctx[1];
    swapcontext(&ctx[0], &ctx[1]);

    // shedule g foo2
    getcontext(&ctx[2]);
    ctx[2].uc_stack.ss_sp = st2;
    ctx[2].uc_stack.ss_size = sizeof(st2);
    ctx[2].uc_link = &ctx[0];
    makecontext(&ctx[2], foo2, 0);
    running = &ctx[2];
    swapcontext(&ctx[0], &ctx[2]);

    // shedule g foo2
    swapcontext(&ctx[0], &ctx[2]);
    // shedule g foo1
    swapcontext(&ctx[0], &ctx[1]);

    // m over
    printf("m over...\n");

    return 0;
}

上面的小程序简单的揭示了协程实现的底层原理。在 go 协程实现中,有三个概念 G,P,M。G 是用户级协程,M 是系统级线程。每个 M 只有绑定了 P,才可以运行 G。P 的个数是有限的,通常与系统的 CPU 核心数相同,用来限制并发数。三者具体的关系将在另一篇文章中描述。
在上面的程序中,M 对应的就是 main 主线程。而 foo1 和 foo2 分别对应两个 G。M 先是调度运行了 foo1,在 foo1 里调用 schedule 函数主动让出 cpu ,schedule 有点类似 python 中的 yield。foo1 让出 cpu 之后,M 继续进行调度,继续调度 foo2 ,在 foo2 里同样利用 schedule 让出 cpu。回到 M 进行调度,M 调度 foo2, foo2 从上次切换之后的位置继续运行直到退出,回到 M。然后调度 foo1,同样退出。M 退出。
在实际的应用中,G 通常会组成一个队列。M 循环从队列里取出 G 进行运行,中途切换出去的 G 也会放会队列中,等待 M 下次调度。




原文地址:访问原文地址
快照地址: 访问文章快照