libco学习三

Libco 协程的生命周期

创建协程(Creating coroutines)

  前文已提到,libco 中创建协程是 co_create() 函数。函数声明如下:

1 int     co_create( stCoRoutine_t **co,const stCoRoutineAttr_t *attr,void *(*routine)(void*),void *arg );

  同 pthread_create 一样,该函数有四个参数:

     1、co: stCoRoutine_t** 类型的指针。输出参数,co_create 内部会为新协程分配⼀个 “协程控制块”,*co 将指向这个分配的协程控制块,传出参数

     2、attr: stCoRoutineAttr_t* 类型的指针。输⼊参数,用于指定要创建协程的属性,可 为 NULL。实际上仅有两个属性:栈⼤小、指向共享栈的指针(使用共享栈模式)

     3、routine: void* (*)(void *) 类型的函数指针,指向协程的任务函数,即启动这个协 程后要完成什么样的任务。routine 类型为函数指针。

     4、arg: void* 类型指针,传递给任务函数的参数,类似于 pthread 传递给线程的参数。 调用 co_create 将协程创建出来后,这时候它还没有启动,也即是说我们传递的 routine 函数还没有被调用。实质上,这个函数内部仅仅是分配并初始化 stCoRoutine_t 结构体、设置任务函数指针、分配一段“栈”内存,以及分配和初始化 coctx_t。为什么 这里的“栈”要加个引号呢?因为这里的栈内存,无论是使用预先分配的共享栈,还是 co_create 内部单独分配的栈,其实都是调用 malloc 从进程的堆内存分配出来的。对于协程而言,这就是“栈”,而对于底层的进程(线程)来说这只不过是普通的堆内存而已。

stCoRoutineAttr_t结构

 1 struct stCoRoutineAttr_t
 2 {
 3     int stack_size;    //栈大小
 4     stShareStack_t*  share_stack;    //共享栈
 5     stCoRoutineAttr_t()    //类似于构造函数
 6     {
 7         stack_size = 128 * 1024;
 8         share_stack = NULL;
 9     }
10 }__attribute__ ((packed));

co_create 函数源码

 1 int co_create( stCoRoutine_t **ppco,const stCoRoutineAttr_t *attr,pfn_co_routine_t pfn,void *arg )
 2 {
 3     if( !co_get_curr_thread_env() )//判断当前进程(线程)的 stCoRoutineEnv_t 结构也就是环境变量是否已分配
 4     {
 5         co_init_curr_thread_env();//如果未分配则分配一个
 6     }
 7     //创建协程控制块
 8     stCoRoutine_t *co = co_create_env( co_get_curr_thread_env(), attr, pfn,arg );
 9     *ppco = co;
10     return 0;
11 }

启动协程(Resume a coroutine)

在调用 co_create 创建协程返回成功后,便可以调用 co_resume 函数将它启动了。该函数声明如下:

1 void    co_resume( stCoRoutine_t *co );

  它的意义很明了,即启动 co 指针指向的协程。值得注意的是,为什么这个函数不叫 co_start 而是 co_resume 呢?前文已提到,libco 的协程是非对称协程,协程在让出 CPU 后要恢复执行的时候,还是要再次调用一下 co_resume 这个函数的去“启动”协程运行的。从语义上来讲,co_start 只有一次,而 co_resume 可以是暂停之后恢复启动,可以多次调用,就这么个区别。实际上,看早期关于协程的文献,讲到非对称协程,一般 也都用“resume”与“yield”这两个术语。协程要获得 CPU 执行权用“resume”,而让出 CPU 执行权用“yield”,这是两个是两个不同的(不对称的)过程,因此这种机制才被称为非对称协程(asymmetric coroutines)。

   所以讲到 resume 一个协程,我们一定得注意,这可能是第一次启动该协程,也可以是要准备重新运行挂起的协程。我们可以认为在 libco 里面协程只有两种状态,即 running 和 pending。当创建一个协程并调用 resume 之后便进入了 running 状态,之后协程可能通过 yield 让出 CPU,这就进入了 pending 状态。不断在这两个状态间循环往复, 直到协程退出(执行的任务函数 routine 返回),如图2所示(TBD 修改状态机)。

非对称协程状态转换图

  需要指出的是,不同于 go 语言,这里 co_resume() 启动一个协程的含义,不是“创建一个并发任务”。进入 co_resume() 函数后发生协程的上下文切换,协程的任务函数是立即就会被执行的,而且这个执行过程不是并发的(Concurrent)。为什么不是并发的 呢?因为 co_resume() 函数内部会调用 coctx_swap() 将当前协程挂起,然后就开始执行目标协程的代码了(具体过程见下文协程切换那一篇的分析)。本质上这个过程是串行的,在一个操作系统线程(进程)上发生的,甚至可以说在一颗 CPU 核上发生的(假 定没有发生 CPU migration)。让我们站到 Knuth 的角度,将 coroutine 当做一种特殊的 subroutine 来看,问题会显得更清楚:A 协程调用 co_resume(B) 启动了 B 协程,本质上是一种特殊的过程调用关系,A 调用 B 进入了 B 过程内部,这很显然是一种串行执行的关系。那么,既然 co_resume() 调用后进入了被调协程执行控制流,那么 co_resume() 函数本身何时返回?这就要等被调协程主动让出 CPU 了,也就是被调用协程执行yield()函数。

co_resume() 函数代码实现

 1 void co_resume( stCoRoutine_t *co )
 2 {
 3     stCoRoutineEnv_t *env = co->env;
 4     //当前协程控制块指针,
 5     stCoRoutine_t *lpCurrRoutine = env->pCallStack[ env->iCallStackSize - 1 ];
 6     if( !co->cStart )//当且仅当协程是第一次启动时才会执行到。
 7     {
 8         //当协程第一次启动的时候,它的上下文并没有被设置,初始化当前协程的上下文
 9         coctx_make( &co->ctx,(coctx_pfn_t)CoRoutineFunc,co,0 );
10         //协程上下文是否被初始化,0:位初始化,1:初始化
11         co->cStart = 1;
12     }
13     //保存调用关系,将该协程压入pCallStack数组,并增加iCallStackSize
14     env->pCallStack[ env->iCallStackSize++ ] = co;
15     /**
16      * 实现协程的切换,实际上协程的切换就是协程的上下文的切换
17      * lpCurrRoutine:当前运行协程的协程控制块
18      * co:将要运行的协程的协程控制块
19      */
20     co_swap( lpCurrRoutine, co );
21 }

  第 5、6 行 的 if 条件分支,当且仅当协程是第一次启动时才会执行到。首次启动协程过程有点特殊,需要调用 coctx_make() 为新协程准备 context(为了让 co_swap() 内能跳转到新启动协程的任务函数,),并将 cStart 标志变量置 1。第 4~7 行是首次启动协程才执行的特殊逻辑,那么 co_resume() 仅有 4 行代码而已。第 3 行取当前协程控制块指针,第 8 行将待启动的协程 co 压入 pCallStack 栈,然后第 9 行就调用 co_swap() 切换到 co 指向的新协程上去执 行了。co_swap() 不会就此返回,而是要这次 resume 的 co 协程主动 yield 让出 CPU 时才会返回到 co_resume() 中来

  值得指出的是,这里讲 co_swap() 不会就此返回,不是说这个函数就阻塞在这里等待 co 这个协程yield 让出 CPU。实际上,后面我们将会看到,co_swap() 内部已经切换 了 CPU 执行上下文,奔着 co 协程的代码路径去执行了。整个过程不是并发的,而是串行的,这一点我们已经反复强调过了。

协程的挂起(Yield to another coroutine)

  在非对称协程理论,yield 与 resume 是个相对的操作。A 协程 resume 启动了 B 协程,那么只有当 B 协程执行 yield 操作时才会返回到 A 协程。在上面剖析协程启动 函数 co_resume() 时,也提到了该函数内部 co_swap() 会执行被调协程的代码。只有被调协程 yield 让出 CPU,调用者协程的 co_swap() 函数才能返回到原点,即返回到原来 co_resume() 内的位置。 在前文解释 stCoRoutineEnv_t 结构 pCallStack 这个“调用栈”的时候,我们已经简要地提到了 yield 操作的内部逻辑。在被调协程要让出 CPU 时,会将它的 stCoRoutine_t 从 pCallStack 弹出,“栈指针”iCallStackSize 减 1,然后 co_swap() 切换 CPU 上下文到原来 被挂起的调用者协程恢复执行。这里“被挂起的调用者协程”(该携程还在栈中,栈是一种先进后出的数据结构,这里实际上使用的数组),即是调用者 co_resume() 中切换 CPU 上下文被挂起的那个协程。下面我们来看一下 co_yield_env() 函数代码:

void co_yield_env( stCoRoutineEnv_t *env )
{
    //获取调用的当前协程的协程的协程控制块
    stCoRoutine_t *last = env->pCallStack[ env->iCallStackSize - 2 ];
    //获取当前协程(也就是被调用协程)的协程控制块
    stCoRoutine_t *curr = env->pCallStack[ env->iCallStackSize - 1 ];

    //栈大小减少1
    env->iCallStackSize--;

    //切换到使用co_resume的协程,“被挂起的调用者协程”,即是调用者 co_resume()中切换 CPU 上下文被挂起的那个协程。
    co_swap( curr, last);
}

  co_yield_env() 函数仅有 4 行代码,事实上这个还可以写得更简洁些。注意到这个函数为什么叫 co_yield_env 而不是 co_yield 呢?这个也很简单。我们知道 co_resume 是有明确目的对象的,而且可以通过 resume 将 CPU 交给任意协程。但 yield 则不一样,你只能 yield 给当前协程的调用者。而当前协程的调用者,即最初 resume 当前协程的协程,是保存在 stCoRoutineEnv_t 的 pCallStack 中的。因此你只能 yield 给“env”,yield 给调用者协程;而不能随意 yield 给任意协程,CPU 不是你想让给谁就能让给谁的

void co_yield_ct()和void co_yield( stCoRoutine_t *co )函数底层还是通过co_yield_env() 函数实现的
1 void co_yield_ct()
2 {
3     co_yield_env( co_get_curr_thread_env() );
4 }
5 void co_yield( stCoRoutine_t *co )
6 {
7     co_yield_env( co->env );
8 }
co_get_curr_thread_env()函数
1 //获取协程运行的协程环境
2 stCoRoutineEnv_t *co_get_curr_thread_env()
3 {
4     //是一个全局性的变量,用来保存运行协程的协程环境
5     //static __thread stCoRoutineEnv_t* gCoEnvPerThread = NULL;
6     return gCoEnvPerThread;
7 }

   事实上,libco 提供了一个 co_yield(stCoRoutine_t*) 的函数。看起来你似乎可以将 CPU 让给任意协程。实际上并非如此,我们知道,同一个线程上所有协程是共享一个 stCoRoutineEnv_t 结构的,因此任意 协程的 co->env 指向的结构都相同。如果你调用 co_yield(co),就以为将 CPU 让给 co 协程了,那就错了。最终通过 co_yield_env() 还是会将 CPU 让给原来启动当前协程的调用者。可能有的读者会有疑问,同一个线程上所有协程共享 stCoRoutineEnv_t,那么我 co_yield() 给其他线程上的协程呢?对不起,如果你这么做,那么你的程序就挂了libco 的协程是不支持线程间迁移(migration)的,如果你试图这么做,程序一定会挂掉。这 个 co_yield() 其实容易让人产生误解的。

  再补充说明一下,协程库内虽然提供了 co_yield(stCoRoutine_t*) 函数,但是没有任何地方有调用过该函数(包括样例代码)。使用的较多的是另外一个函数—— co_yield_ct(),其实本质上作用都是一样的。

协程的切换(Context switch)

  前面讨论的 co_yield_env() 与 co_resume(),是两个完全相反的过程,但他们的核心任务却是一样的——切换 CPU 执行上下文,即完成协程的切换。在 co_resume() 中,这个切换是从当前协程切换到被调协程;而在 co_yield_env() 中,则是从当前协程切换到调用者协程。最终的上下文切换,都发生在 co_swap() 函数内。 严格来讲这里不属于协程生命周期一部分,而只是两个协程开始执行与让出 CPU 时的一个临界点。既然是切换,那就涉及到两个协程。为了表述方便,我们把当前准备让出 CPU 的协程叫做 current 协程,把即将调入执行的叫做 pending 协程。

coctx_swap.S 汇编代码

 1 .globl coctx_swap
 2 #if !defined( __APPLE__ )
 3 .type  coctx_swap, @function
 4 #endif
 5 coctx_swap:
 6 
 7 #if defined(__i386__)
 8     movl 4(%esp), %eax
 9     movl %esp,  28(%eax)  //ESP 专门用作堆栈指针,被形象地称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,ESP也就越来越小。在32位平台上,ESP每次减少4字节。
10     movl %ebp, 24(%eax)   //基址指针
11     movl %esi, 20(%eax)   //源索引寄存器
12     movl %edi, 16(%eax)   //目标索引寄存器
13     movl %edx, 12(%eax)   //总是被用来放整数除法产生的余数。
14     movl %ecx, 8(%eax)    //计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。
15     movl %ebx, 4(%eax)    //基地址"(base)寄存器, 在内存寻址时存放基地址。
16 
17 
18     movl 8(%esp), %eax
19     movl 4(%eax), %ebx
20     movl 8(%eax), %ecx
21     movl 12(%eax), %edx
22     movl 16(%eax), %edi
23     movl 20(%eax), %esi
24     movl 24(%eax), %ebp
25     movl 28(%eax), %esp
26 
27     ret
28 
29 #elif defined(__x86_64__)

  这里截取的是coctx_swap.S 文件中针对 x86 体系结构的一段代码,x64 下的原理跟这是一样的,代码也在这同一个文件中。从宏观角度看,这里定义了一个名为 coctx_swap 的函数,而且是 C 风格的函数(因为要被 C++ 代码调用)。从调用方看,我们可以将它当做一个普通的 C 函数,函数原型如下:

1 extern "C" {
2 extern void coctx_swap(coctx_t*, coctx_t*) asm("coctx_swap");
3 };

  coctx_swap 接受两个参数,无返回值。其中,第一个参数 curr 为当前协程的 coctx_t 结构指针,其实是个输出参数函数调用过程中会将当前协程的 context (执行上下文)保存在这个参数指向的内存里;第二个参数 pending,即待切入的协程的 coctx_t 指针,是个输入参数coctx_swap 从这里取上次保存的 context,恢复各寄存器的值。前面我们讲过 coctx_t 结构,就是用于保存各寄存器值(context)的。这个函数奇特之处,在于调用之前还处 第一个协程的环境,该函数返回后,则当前运行的协程就已经完全是第二个协程了。这 跟 Linux 内核调度器的 switch_to 功能是非常相似的,只不过内核里线程的切换比这还要复杂得多。正所谓“杨百万进去,杨白劳出来”,当然,这里也可能是“杨白劳进去, 杨百万出来”。 言归正题,这个函数既然是要直接操作寄存器,那当然非汇编不可了。汇编语言都快忘光了?那也不要紧,这里用到的都是常用的指令。值得一提的是,绝大多数学校汇编教程使用的都是 Intel 的语法格式,而这里用到的是 AT&T 格式。这里我们只需知道两者的主要差别,在于操作数的顺序是反过来的,就足够了。在 AT&T 汇编指令里,如果指令有两个操作数,那么第一个是源操作数,第二个即为目的操作数

movl汇编指令

 1 MOV指令的基本格式:
 2         movx source, destination
 3 
 4         source 和 destinatino 的值可以是内存地址,存储在内存中的数据值,指令语句中定义的数据值,或者是寄存器。
 5 
 6 注意:GNU汇编器使用 AT&T 样式的语法,所以其中的源和目的操作数和 Intel 文档中给出的顺序是相反的。
 7 
 8 GNU汇编器为 mov 指令添加了一个维度,在其中必须声明要传送的数据元素的长度。
 9     通过吧一个附加字符添加到 MOV 助记符来声明这个长度。
10     因此,指令就变成了如下:
11         movx
12         其中 x 可以是下面的字符:
13             1,l用于32位的长字值
14             2,w用于16位的字值
15             3,b用于8位的字节值
16 
17     实例:
18 
19         movl     %eax,     %ebx       #把32位的EAX寄存器值传送给32为的EBX寄存器值
20 
21         movw   %ax,       %bx         #把32位的EAX寄存器值传送给32为的EBX寄存器值
22 
23         movb    %al,       %lx           #把32位的EAX寄存器值传送给32为的EBX寄存器值

 leal汇编指令

 1 leal指令用于加载有效地址(loadeffective address)。
 2 
 3 leal指令的目的操作数必须是寄存器。实际上leal指令有时用于与加载地址无关的场景。
 4 
 5 示例:
 6 
 7 leal 6(%eax), %edx //把eax的值+6放入edx中。
 8 
 9 leal (%eax, %ecx), %edx //把eax+ecx的值装入edx中。
10 
11 leal (%eax, %ecx, 4), %edx //把eax + 4*ecx的值装入edx中。
12 
13 leal 7(%eax, %eax, 8), %edx //把9*eax +7的值装入edx中。
14 
15 leal 0xA(,%eax,4), %edx //把4*eax + 10的值装入edx中。
16 
17 leal 9(%eax, %ecx, 2), %edx //把eax + 2*ecx+ 9的值装入

  此外,我们前面提到,这是个 C 风格的函数。什么意思呢?在 x86 平台下,多数 C 编译器会使用一种固定的方法来处理函数的参数与返回值。函数的参数使用栈传递, 且约定了参数顺序,如图3所示。在调用函数之前,编译器会将函数参数以反向顺序压栈,如图3中函数有三个参数,那么参数 3 首先 push 进栈,随后是参数 2,最后参数 1。 准备好参数后,调用 CALL 指令时 CPU 自动将 IP 寄存器(函数返回地址)push 进栈, 因此在进入被调函数之后,便形成了如图3的栈格局。函数调用结束前,则使用 EAX 寄存器传递返回值(如果 32 位够用的话),64 位则使用 EDX:EAX,如果是浮点值则使用 FPU ST(0) 寄存器传递。

C 语言函数传递参数示意图

协程上下文

 1 struct coctx_t
 2 {
 3 #if defined(__i386__)
 4     void *regs[ 8 ];//用来保存协程切换时当前协程的各个寄存器的值
 5 #else
 6     void *regs[ 14 ];
 7 #endif
 8     size_t ss_size;
 9     char *ss_sp;
10 
11 };

  接着,继续分析co_swap函数,它有两个参数,那么进入函数体后,第8行用 4(%esp) 便可以取到第一个参数(当前协程 context 指针),可以认为是将当前的栈顶地址保存下来(实际保存的地址比栈顶还要⾼ 4 字节,为了⽅便我们就称之为栈顶)。为什么要保存栈指针呢,因为紧接着就要 进⾏栈切换了。第9行开始就是将当前协程使用的寄存器的值全部保存在regs[ 8 ]数组中。从第18行开始就是将pending 协程的上下文恢复到寄存器中,继续上次伪完成的工作。

 协程的退出

  这里讲的退出,有别于协程的挂起,是指协程的任务函数执行结束后发生的过程。 更简单的说,就是协程任务函数内执行了 return 语句,结束了它的生命周期。这在某些场景是有用的。同协程挂起一样,协程退出时也应将 CPU 控制权交给它的调用者,这也是调用 co_yield_env() 函数来完成的。这个机制很简单,限于篇幅,具体代码就不贴出来了。 值得注意的是,我们调用 co_create()、co_resume() 启动协程执行一次性任务,当任务结束后要记得调用 co_free() 或 co_release() 销毁这个临时性的协程,否则将引起内存泄漏

总结:

1、协程的创建

  1.co_create函数、协程控制块(传出参数)、协程属性(栈大小,默认128K、指向共享栈的指针,不使用共享栈该指为NULL)、任务函数、任务函数的传入参数

  2.无论是使用预先分配的共享栈,还是 co_create 内部单独分配的栈,其实都是调用 malloc 从进程的堆内存分配出来的。对于协程而言,这就是“栈”,而对于底层的进程(线程)来说这只不过是普通的堆内存而已。

  3.第一个协程

  libco 的第一个协程,即执行 main 函数的协程,是一个特殊的协程。这个协程又可以称作主协程,它负责协调其他协程的调度执行(后文我们会看到,还有网络 I/O 以及定时事件的驱动),它自己则永远不会 yield,不会主动让出 CPU。不让出(yield)CPU,不等于说它一直霸占着 CPU。我们知道 CPU 执行权有两种转移途径,一是通过 yield 让给 调用者,其二则是 resume 启动其他协程运行。主协程是跟 stCoRoutineEnv_t 一起创建的。主协程也无需调用 resume 来启动,它就是程序本身,就是 main 函数。主协程是一个特殊的存在,可以认为它只是一个结构体而已。在程序首次调用 co_create() 时,此函数内部会判断当前进程(线程)的 stCoRoutineEnv_t 结构是否已分配,如果未分配则分配一个,同时分配 一个 stCoRoutine_t 结构,并将 pCallStack[0] 指向主协程。此后如果用 co_resume() 启动协程,又会将 resume 的协程压入 pCallStack 栈。

  4.协程运行的环境,也是在程序首次调用 co_create() 时,此函数内部会判断当前进程(线程)的 stCoRoutineEnv_t 结构是否已分配,如果未分配则分配一个,也就是环境变量与第一个协程(也就是主协程)一起创建。

2、协程的启动

  libco库提供的是非对称协程,创建协程和启动协程是两个分开的独立的步骤,与go语言有所不同,使用的函数是co_resume() 函数,在该函数中会发生协程的切换,协程上下文的创建(如果该协程是第一次被进入运行状态),会导致pCallStack 的入栈操作。

3、协程的挂起

  使用的函数是co_yield_env() 函数,所有该库提供的挂起函数如co_yield_ct和co_yield_ct底层都是使用co_yield_env实现的,实际上就是pCallStack 的出栈操作;co_resume 是有明确目的对象的,而且可以通过 resume 将 CPU 交给任意协程。但 yield 则不一样,你只能 yield 给当前协程的调用者;此外,libco 的协程是不支持线程间迁移(migration)的,如果你试图这么做,程序一定会挂掉。

4、协程的切换

  使用的函数是co_swap,两个传入参数,第一个参数是当前协程的coctx_t变量,第二个参数是即将运行的协程的coctx_t变量;大致实现的操作就是将当前协程的上下文(也就是各个寄存器值)保存到coctx_t变量,然后将即将运行的协程的上下文(上次切换出保存的各个寄存器的值)恢复到各个寄存器。

5、协程的退出

  如果某些协程只是完成一些零时性的任务,完成相应的任务后就会调用return结束生命周期,并返回调用者那里,这些协程就称为零时协程,当其生命周期结束时,需要调用 co_free() 或 co_release() 销毁这个临时性的协程,否则将引起内存泄漏。

posted @ 2021-07-18 22:59  Mr-xxx  阅读(203)  评论(0编辑  收藏  举报