上文我们介绍了Libco的初步介绍与协程,接下来让我们继续往下看~

Libco 协程的生命周期

创建协程(Creating coroutines)

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

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

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

  • @co: stCoRoutine_t** 类型的指针。输出参数,co_create 内部会为新协程分配一个“协程控制块”,*co 将指向这个分配的协程控制块。
  • @attr: stCoRoutineAttr_t* 类型的指针。输入参数,用于指定要创建协程的属性,可为 NULL。实际上仅有两个属性:栈大小、指向共享栈的指针(使用共享栈模式)。
  • @routine: void* (*)(void *) 类型的函数指针,指向协程的任务函数,即启动这个协程后要完成什么样的任务。routine 类型为函数指针。
  • @arg: void* 类型指针,传递给任务函数的参数,类似于 pthread 传递给线程的参数。

调用 co_create 将协程创建出来后,这时候它还没有启动,也即是说我们传递的 routine 函数还没有被调用。实质上,这个函数内部仅仅是分配并初始化 stCoRoutine_t 结构体、设置任务函数指针、分配一段“栈”内存,以及分配和初始化 coctx_t。

为什么这里的“栈”要加个引号呢?因为这里的栈内存,无论是使用预先分配的共享栈,还是 co_create 内部单独分配的栈,其实都是调用 malloc 从进程的堆内存分配出来的。对于协程而言,这就是“栈”,而对于底层的进程(线程)来说这只不过是普通的堆内存而已。

总体上,co_create 函数内部做的工作很简单,这里就不贴出代码了。

启动协程(Resume a coroutine)

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

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所示。

需要指出的是,不同于 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了。(TDB 补充图)

co_resume() 函数代码实现

void co_resume(stCoRoutine_t *co) {stCoRoutineEnv_t *env = co->env;stCoRoutine_t *lpCurrRoutine = env->pCallStack[env->iCallStackSize-1];if (!co->cStart) {coctx_make(&co->ctx, (coctx_pfn_t)CoRoutineFunc, co, 0);co->cStart = 1;}env->pCallStack[env->iCallStackSize++] = co;co_swap(lpCurrRoutine, co);
}

如果读者对 co_resume() 的逻辑还有疑问,不妨再看一下它的代码实现。

第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() 函数代码:

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];env->iCallStackSize--;co_swap(curr, last);
}

co_yield_env() 函数仅有 4 行代码,事实上这个还可以写得更简洁些。你可以试着把这里代码缩短至 3 行,并不会牺牲可读性。

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

事实上,libco 提供了一个co_yield(stCoRoutine_t*) 的函数。看起来你似乎可以将 CPU 让给任意协程。实际上并非如此:

co_yield() 函数

void co_yield(stCoRoutine_t *co) {co_yield_env(co->env);
}

我们知道,同一个线程上所有协程是共享一个 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 汇编代码

.globl coctx_swap
#if !defined( __APPLE__ )
.type  coctx_swap, @function
#endif
coctx_swap:#if defined(__i386__)leal 4(%esp), %eax //sp movl 4(%esp), %esp leal 32(%esp), %esp //parm a : ®s[7] + sizeof(void*)pushl %eax //esp ->parm a pushl %ebppushl %esipushl %edipushl %edxpushl %ecxpushl %ebxpushl -4(%eax)movl 4(%eax), %esp //parm b -> ®s[0]popl %eax  //ret func addrpopl %ebx  popl %ecxpopl %edxpopl %edipopl %esipopl %ebppopl %esppushl %eax //set ret func addrxorl %eax, %eaxret
#elif defined(__x86_64__)

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

void coctx_swap(coctx_t* curr, coctx_t* pending) asm("coctx_swap");

coctx_swap 接受两个参数,无返回值。其中,第一个参数 curr 为当前协程的 coctx_t 结构指针,其实是个输出参数,函数调用过程中会将当前协程的 context 保存在这个参数指向的内存里;第二个参数 pending,即待切入的协程的 coctx_t 指针,是个输入参数, coctx_swap 从这里取上次保存的 context,恢复各寄存器的值。

前面我们讲过 coctx_t 结构,就是用于保存各寄存器值(context)的。这个函数奇特之处,在于调用之前还处于第一个协程的环境,该函数返回后,则当前运行的协程就已经完全是第二个协程了。这跟 Linux 内核调度器的 switch_to 功能是非常相似的,只不过内核里线程的切换比这还要复杂得多。正所谓“杨百万进去,杨白劳出来”,当然,这里也可能是“杨白劳进去,杨百万出来”。

言归正题,这个函数既然是要直接操作寄存器,那当然非汇编不可了。汇编语言都快忘光了?那也不要紧,这里用到的都是常用的指令。值得一提的是,绝大多数学校汇编教程使用的都是 Intel 的语法格式,而这里用到的是 AT\&T 格式。

这里我们只需知道两者的主要差别,在于操作数的顺序是反过来的,就足够了。在 AT\&T 汇编指令里,如果指令有两个操作数,那么第一个是源操作数,第二个即为目的操作数。

此外,我们前面提到,这是个 C 风格的函数。什么意思呢?在 x86 平台下,多数 C 编译器会使用一种固定的方法来处理函数的参数与返回值。函数的参数使用栈传递,且约定了参数顺序,如图 3 所示。

在调用函数之前,编译器会将函数参数以反向顺序压栈,如图 3 中函数有三个参数,那么参数 3 首先 push 进栈,随后是参数 2,最后参数 1。准备好参数后,调用 CALL 指令时 CPU 自动将 IP 寄存器(函数返回地址)push 进栈,因此在进入被调函数之后,便形成了如图 3 的栈格局。函数调用结束前,则使用 EAX 寄存器传递返回值(如果 32 位够用的话),64 位则使用 EDX:EAX,如果是浮点值则使用 FPU ST(0) 寄存器传递。

在复习过这些汇编语言知识后,我们再来看 coctx_swap 函数。

它有两个参数,那么进入函数体后,用 4(%esp) 便可以取到第一个参数(当前协程 context 指针),8(%esp) 可以取到第二个参数(待切入运行协程的 context 指针)。当前栈顶的内容,(%esp) 则保存了coctx_swap 的返回地址。

搞清楚栈数据布局是理解 coctx_swap 函数的关键,接下来分析 coctx_swap 的每条指令,都需要时刻明白当前的栈在哪里,栈内数据是怎样一个分布。我们把 coctx_swap 分为两部分,以第 21 行那条 MOVL 指令为界。第一部分是用于保存 current 协程的各个寄存器,第二部分则是恢复 pending 协程的寄存器。接下来我们逐行进行分析。

第 8 行:LEA 指令即 Load Effective Address 的缩写。这条指令把 4(%esp) 有效地址保存到 eax 寄存器,可以认为是将当前的栈顶地址保存下来(实际保存的地址比栈顶还要高 4 字节,为了方便我们就称之为栈顶)。为什么要保存栈指针呢,因为紧接着就要进行栈切换了。

第 9~10 行:看到第 9 行,回忆我们前面讲过的 C 函数参数在栈中的位置,此时 4(%esp) 内正是指向 current 协程 coctx_t 的指针,这里把它塞到 esp 寄存器。接下来第 10 行又将 coctx_t 指针指向的地址加上 32 个字节的内存位置加载到 esp 中。经过这么一倒腾,esp 寄存器实际上指向了当前协程 coctx_t 结构的 ss_size 成员位置,在它之下有个名为 regs 的数组,刚好是用来保存 8 个寄存器值的。注意这是第一次栈切换,不过是临时性的,目的只是方便接下来使用 push 指令保存各寄存器值。

第12行:eax 寄存器内容压栈。更准确的讲,是将 eax 寄存器保存到了 coctx_t->regs[7] 的位置。注意到在第 8 行 eax 寄存器已经保存了原栈顶的地址,所以这句实际上是将当前协程栈顶保存起来,以备下次调度回来时恢复栈地址。

第13~18行:保存各通用寄存器的值,到 coctx_t 结构的 regs1~regs[6] 的位置。

第 19 行:这一行又有点意思了。eax 的值,从第 8 行之后就变变过,那么 -4(%eax) 实际上是指向原来 coctx_swap 刚进来时的栈顶,我们讲过栈顶的值是 call 指令自动压入的函数返回地址。这句实际上就是将 coctx_swap 的返回地址给保存起来了,放在 coctx_t->regs[0] 的位置。

第 21 行:至此,current 协程的各重要寄存器都已保存完成了,开始可以放心地交班给 pending 协程了。接下来我们需要将 pending 协程调度起来运行,就需要为它恢复 context——恢复各通用寄存器的值以及栈指针。因此这一行将栈指针切到 pending 协程的 coctx_t 结构体开始,即 regs[0] 的位置,为恢复寄存器值做好了准备。

第 23 行:弹出 regs[0] 的值到 eax 寄存器。regs[0] 正该协程上次被切换出去时在第 19 行保存的值,即 coctx_swap 的返回地址。

第 24~29 行:从 regs1~regs[6] 恢复各寄存器的值(与之相应的是前面第 13~18 行的压栈操作)。

第 30 行:将 pending 协程上次切换出去时的栈指针恢复(与之对应的是第 12 行压栈操作)。请思考一下,栈内容已经完全恢复了吗?注意到第 8 行我们讲过,当时保存的“栈顶”比真正的栈顶差了一个 4 字节的偏移。而这 4 字节真正栈顶的内容,正是 coctx_swap 的返回地址。如果此时程序就执行 ret 指令返回,那程序就不知道会跑到哪去了。

第 31 行:为了程序能正确地返回原来的 coctx_swap 调用的地方,将 eax 内容(第 19 行保存至 regs[7],第 23 行取出来到 eax)压栈。

第 33~34 行:清零 eax 寄存器,执行返回指令。

至此,对 32 位平台的 coctx_swap 分析就到此结束了。

细心的读者会发现一共切换了 3 次栈指针,尽管作者加了一些注释,但这段代码对于很多人来说恐怕还是难以理解的。本文仅仅分析了 32 位的情况,x64 下的代码逻辑是类似的,不过涉及的寄存器更多一些,而且函数调用时参数传递有所区别。有兴趣的读者可以自行分析下,此外,还可以结合 glibc 的 ucontext 源码对比分析一下。ucontext 也提供了支持用户级线程的接口,也有类似功能的 swapcontext() 函数,那里的汇编代码比较容易读懂些,不过运行效率比较低。

协程的退出

这里讲的退出,有别于协程的挂起,是指协程的任务函数执行结束后发生的过程。更简单的说,就是协程任务函数内执行了 return 语句,结束了它的生命周期。这在某些场景是有用的。同协程挂起一样,协程退出时也应将 CPU 控制权交给它的调用者,这也是调用 co_yield_env() 函数来完成的。这个机制很简单,限于篇幅,具体代码就不贴出来了。

值得注意的是,我们调用 co_create()、co_resume() 启动协程执行一次性任务,当任务结束后要记得调用 co_free()或co_release() 销毁这个临时性的协程,否则将引起内存泄漏。

事件驱动与协程调度

协程的“阻塞”与线程的“非阻塞”

我们已经分析了 libco 的协程从创建到启动,挂起、起动以及最后退出的过程。同时,我们也看到,一个线程上的所有协程本质上是如何串行执行的。让我们暂时回到 \ref {subsection:a-simple-example} 节的例子。在 Producer 协程函数内我们会看到调用 poll 函数等待 1 秒,Consumer 中也会看到调用 co_cond_timedwait 函数等待生产者信号。

注意,从协程的角度看,这些等待看起来都是同步的(synchronous),阻塞的(blocking);但从底层线程的角度来看,则是非阻塞的(non-blocking)。

在 \ref {subsection:a-simple-example} 节例子中我们也讲过,这跟 pthread 实现的原理是一样的。在 pthread 实现的消费者中,你可能用 pthread_cond_timedwait 函数去同步等待生产者的信号;在消费者中,你可能用 poll 或 sleep 函数去定时等待。

从线程的角度看,这些函数都会让当前线程阻塞;但从内核的角度看,它本身并没有阻塞,内核可能要继续忙着调度别的线程运行。那么这里协程也是一样的道理,从协程的角度看,当前的程序阻塞了;但从它底下的线程来看,自己可能正忙着执行别的协程函数。在这个例子中,当 Consumer 协程调用 co_cond_timedwait 函数“阻塞”后,线程可能已经将 Producer 调度恢复执行,反之亦然。那么这个负责协程“调度”的线程在哪呢?它即是运行协程本身的这个线程。

主协程与协程的“调度”

还记得前文提过的“主协程”的概念吗?我们再次把它搬出来,这对我们理解协程的“阻塞”与“调度”可能更有帮助。我们讲过,libco 程序都有一个主协程,即程序里首次调用 co_create() 显式创建第一个协程的协程。在 \ref {subsection:a-simple-example} 节例子中,即为 main 函数里调用 co_eventloop() 的这个协程。当 Consumer 或 Producer 阻塞后,CPU 将 yield 给主协程,此时主协程在干什么呢?主协程在 co_eventloop() 函数里头忙活。这个 co_eventloop() 即“调度器”的核心所在。

需要补充说明的是,这里讲的“调度器”,严格意义上算不上真正的调度器,只是为了表述的方便。libco 的协程机制是非对称的,没有什么调度算法。

在执行 yield 时,当前协程只能将控制权交给调用者协程,没有任何可调度的余地。Resume 灵活性稍强一点,不过也还算不得调度。如果非要说有什么“调度算法”的话,那就只能说是“基于 epoll/kqueue 事件驱动”的调度算法。“调度器”就是 epoll/kqueue 的事件循环。

我们知道,在 Go 语言中,用户只需使用同步阻塞式的编程接口即可开发出高性能的服务器,epoll/kqueue 这样的 I/O 事件通知机制(I/O event notification mechanism)完全被隐藏了起来。在 libco 里也是一样的,你只需要使用普通 C 库函数 read()、write() 等等同步地读写数据就好了。

那么 epoll 藏在哪呢?就藏在主协程的 co_eventloop() 中。协程的调度与事件驱动是紧紧联系在一起的,因此与其说 libco 是一个协程库,还不如说它是一个网络库。在后台服务器程序中,一切逻辑都是围绕网络 I/O 转的,libco 这样的设计自有它的合理性。

stCoEpoll_t 结构与定时器

在分析 stCoRoutineEnv_t 结构(代码清单 \ref {stCoRoutineEnv})的时候,还有一个 stCoEpoll_t 类型的 pEpoll 指针成员没有讲到。作为 stCoRoutineEnv_t 的成员,这个结构也是一个全局性的资源,被同一个线程上所有协程共享。从命名也看得出来,stCoEpoll_t 是跟 epoll 的事件循环相关的。现在我们看一下它的内部字段:

stCoEpoll_t结构

struct stCoEpoll_t {int iEpollFd;static const int _EPOLL_SIZE = 1024 * 10;struct stTimeout_t *pTimeout;struct stTimeoutItemLink_t *pstTimeoutList;struct stTimeoutItemLink_t *pstActiveList;co_epoll_res *result;
};

  • @iEpollFd: 显然是 epoll 实例的文件描述符。
  • @_EPOLL_SIZE: 值为 10240 的整型常量。作为 epoll_wait() 系统调用的第三个参数,即一次 epoll_wait 最多返回的就绪事件个数。
  • @pTimeout: 类型为 stTimeout_t 的结构体指针。该结构实际上是一个时间轮(Timing wheel)定时器,只是命名比较怪,让人摸不着头脑。
  • @pstTimeoutList: 指向 stTimeoutItemLink_t 类型的结构体指针。该指针实际上是一个链表头。链表用于临时存放超时事件的 item。
  • @pstActiveList: 指向 stTimeoutItemLink_t 类型的结构体指针。也是指向一个链表。该链表用于存放 epoll_wait 得到的就绪事件和定时器超时事件。
  • @result: 对 epoll_wait() 第二个参数的封装,即一次 epoll_wait 得到的结果集。

我们知道,定时器是事件驱动模型的网络框架一个必不可少的功能。网络 I/O 的超时,定时任务,包括定时等待(poll 或 timedwait)都依赖于此。一般而言,使用定时功能时,我们首先向定时器中注册一个定时事件(Timer Event),在注册定时事件时需要指定这个事件在未来的触发时间。在到了触发时间点后,我们会收到定时器的通知。

网络框架里的定时器可以看做由两部分组成,第一部分是保存已注册 timer events 的数据结构,第二部分则是定时通知机制。保存已注册的 timer events ,一般选用红黑树,比如 nginx;另外一种常见的数据结构便是时间轮,libco 就使用了这种结构。当然你也可以直接用链表来实现,只是时间复杂度比较高,在定时任务很多时会很容易成为框架的性能瓶颈。

定时器的第二部分,高精度的定时(精确到微秒级)通知机制,一般使用 getitimer/setitimer 这类接口,需要处理信号,是个比较麻烦的事。不过对一般的应用而言,精确到毫秒就够了。精度放宽到毫秒级时,可以顺便用 epoll/kqueue 这样的系统调用来完成定时通知;这样一来,网络 I/O 事件通知与定时事件通知的逻辑就能统一起来了。

笔者之前实现过的一个基于 libcurl 的异步 HTTP client,其中的定时器功能就是用 epoll 配合红黑树实现的。libco 内部也直接使用了 epoll 来进行定时,不同的只是保存 timer events 的用的是时间轮而已。

使用 epoll 加时间轮的实现定时器的算法如下:

  • Step 1 [epoll_wait] 调用epoll_wait() 等待 I/O 就绪事件,最大等待时长设置为 1 毫秒(即e poll_wait() 的 第 4 个参数)。
  • Step 2 [处理 I/O 就绪事件] 循环处理 epoll_wait() 得到的 I/O 就绪文件描述符。
  • Step 3 [从时间轮取超时事件] 从时间轮取超时事件,放到 timeout 队列。
  • Step 4 [处理超时事件] 如果 Step 3 取到的超时事件不为空,那么循环处理 timeout 队列中的定时任务。否则跳转到 Step 1 继续事件循环。
  • Step 5 [继续循环] 跳转到 Step 1,继续事件循环。

挂起协程与恢复的执行

在前文的第 \ref {subsection:resume} 与第 \ref {subsection:yield} 小节,我们仔细地分析了协程的 resume 与 yield 过程。那么协程究竟在什么时候需要 yield 让出 CPU,又在什么时候恢复执行呢?

先来看 yield,实际上在 libco 中共有 3 种调用 yield 的场景:

  1. 用户程序中主动调用 co_yield_ct();
  2. 程序调用了 poll() 或 co_cond_timedwait() 陷入“阻塞”等待;
  3. 程序调用了 connect(), read(), write(), recv(), send() 等系统调用陷入“阻塞”等待。

相应地,重新 resume 启动一个协程也有3种情况:

  1. 对应用户程序主动 yield 的情况,这种情况也有赖于用户程序主动将协程 co_resume() 起来;
  2. poll() 的目标文件描述符事件就绪或超时,co_cond_timedwait() 等到了其他协程的 co_cond_signal() 通知信号或等待超时;
  3. read(), write() 等 I/O 接口成功读到或写入数据,或者读写超时。

在第一种情况下,即用户主动 yield 和 resume 协程,相当于 libco 的使用者承担了部分的协程“调度”工作。这种情况其实也很常见,在 libco 源码包的 example_echosvr.cpp 例子中就有。这也是服务端使用 libco 的典型模型,属于手动“调度”协程的例子。

第二种情况,前面第 \ref {subsection:a-simple-example} 节中的生产者消费者就是个典型的例子。在那个例子中我们看不到用户程序主动调用 yield,也只有在最初启动协程时调用了 resume。生产者和消费者协程是在哪里切换的呢?在 poll() 与 co_cond_timedwait() 函数中。

首先来看消费者。当消费者协程首先启动时,它会发现任务队列是空的,于是调用 co_cond_timedwait() 在条件变量 cond 上“阻塞”等待。同操作系统线程的条件等待原理一样,这里条件变量 stCoCond_t 类型内部也有一个“等待队列”。co_cond_timedwait() 函数内部会将当前协程挂入条件变量的等待队列上,并设置一个回调函数,该回调函数是用于未来“唤醒”当前协程的(即 resume 挂起的协程)。

此外,如果 wait 的 timeout 参数大于 0 的话,还要向当前执行环境的定时器上注册一个定时事件(即挂到时间轮上)。在这个例子中,消费者协程 co_cond_timedwait的timeout 参数为 -1,即 indefinitly 地等待下去,直到等到生产者向条件变量发出 signal 信号。

然后我们再来看生产者。当生产者协程启动后,它会向任务队列里投放一个任务并调用 co_cond_signal() 通知消费者,然后再调用 poll() 在原地“阻塞”等待 1000 毫秒。这里 co_cond_signal 函数内部其实也简单,就是将条件变量的等待队列里的协程拿出来,然后挂到当前执行环境的 pstActiveList(见 7.3 节 stCoEpoll_t 结构)。co_cond_signal 函数并没有立即 resume 条件变量上的等待协程,毕竟这还不到交出 CPU 的时机。

那么什么时候交出CPU控制权,什么时候 resume 消费者协程呢?继续往下看,生产者在向消费者发出“信号”之后,紧接着便调用 poll() 进入了“阻塞”等待,等待 1 秒钟。这个 poll 函数内部实际上做了两件事。首先,将自己作为一个定时事件注册到当前执行环境的定时器,注册的时候设置了 1 秒钟的超时时间和一个回调函数(仍是一个用于未来“唤醒”自己的回调)。然后,就调用 co_yield_env() 将 CPU 让给主协程了。

现在,CPU 控制权又回到了主协程手中。主协程此时要干什么呢?我们已经讲过,主协程就是事件循环 co_eventloop() 函数。在 co_eventloop() 中,主协程周而复始地调用 epoll_wait(),当有就绪的 I/O 事件就处理 I/O 事件,当定时器上有超时的事件就处理超时事件,pstActiveList 队列中已有活跃事件就处理活跃事件。这里所谓的“处理事件”,其实就是调用其他工作协程注册的各种回调函数而已。

那么前面我们讲过,消费者协程和生产者协程的回调函数都是“唤醒”自己而已。工作协程调用 co_cond_timedwait() 或 poll() 陷入“阻塞”等待,本质上即是通过 co_yield_env 函数让出了 CPU;而主协程则负责在事件循环中“唤醒”这些“阻塞”的协程,所谓“唤醒”操作即调用工作协程注册的回调函数,这些回调内部使用 co_resume() 重新恢复挂起的工作协程。

最后,协程 yield 和 resume 的第三种情况,即调用 read(), write() 等 I/O 操作而陷入“阻塞”和最后又恢复执行的过程。这种情况跟第二种过程基本相似。需要注意的是,这里的“阻塞”依然是用户态实现的过程。我们知道,libco 的协程是在底层线程上串行执行的。

如果调用 read 或 write 等系统调用陷入真正的阻塞(让当前线程被内核挂起)的话,那么不光当前协程被挂起了,其他协程也得不到执行的机会。因此,如果工作协程陷入真正的内核态阻塞,那么 libco 程序就会完全停止运转,后果是很严重的。

为了避免陷入内核态阻塞,我们必须得依靠内核提供的非阻塞 I/O 机制,将 socket 文件描述符设置为 non-blocking 的。为了让 libco 的使用者更方便,我们还得将这种 non-blocking 的过程给封装起来,伪装成“同步阻塞式”的调用(跟 co_cond_timedwait() 一样)。

事实上,Go 语言就是这么做的。而 libco 则将这个过程伪装得更加彻底,更加具有欺骗性。它通过 dlsym 机制 hook 了各种网络 I/O 相关的系统调用,使得用户可以以“同步”的方式直接使用诸如 read()、 write() 和 connect()等系统调用。

因此,我们会看到 \ref {subsection:a-simple-example} 节那里的生产者消费者协程任务函数里第一句就调用了一个 co_enable_hook_sys() 的函数。调用了 co_enable_hook_sy s函数才会开启 hook 系统调用功能,并且需要事先将要读写的文件描述符设置为 non-blocking 属性,否则,工作协程就可能陷入真正的内核态阻塞,这一点在应用中要特别加以注意。

以 read() 为例,让我们再来分析一下这些“伪装”成同步阻塞式系统调用的内部原理。首先,假如程序 accept 了一个新连接,那么首先我们将这个连接的 socket 文件描述符设置为非阻塞模式,然后启动一个工作协程去处理这个连接。工作协程调用 read() 试图从该新连接上读取数据。这时候由于系统 read() 函数已经被 hook,所以实际上会调用到 libco 内部准备好的 read() 函数。

这个函数内部实际上做了 4 件事:第一步将当前协程注册到定时器上,用于将来处理 read() 函数的读超时。第二步,调用 epoll_ctl() 将自己注册到当前执行环境的 epoll 实例上。这两步注册过程都需要指定一个回调函数,将来用于“唤醒”当前协程。第三步,调用 co_yield_env 函数让出 CPU。第四步要等到该协程被主协程重新“唤醒”后才能继续。

如果主协程 epoll_wait() 得知 read 操作的文件描述符可读,则会执行原 read 协程注册的会回调将它唤醒(超时后同理,不过还要设置超时标志)。工作协程被唤醒后,在调用原 Glibc 内被 hook 替换掉的、真正的 read() 系统调用。这时候如果是正常 epoll_wait 得知文件描述符 I/O 就绪就会读到数据,如果是超时就会返回 -1。

总之,在外部使用者看来,这个 read() 就跟阻塞式的系统调用表现出几乎完全一致的行为了。

主协程事件循环源码分析

前文已经多次提到过主协程事件循环,主协程是如何“调度”工作协程运行的。最后,让我们再来分析下它的代码(为了节省篇幅,在不妨碍我们理解其工作原理的前提下,已经略去了数行不相关的代码)。

void co_eventloop(stCoEpoll_t *ctx, pfn_co_eventloop_t pfn, void *arg)
{co_epoll_res *result = ctx->result;for (;;) {int ret= co_epoll_wait(ctx->iEpollFd, result, stCoEpoll_t::_EPOLL_SIZE, 1);stTimeoutItemLink_t *active = (ctx->pstActiveList);stTimeoutItemLink_t *timeout = (ctx->pstTimeoutList);memset(timeout, 0, sizeof(stTimeoutItemLink_t));for (int i=0; i<ret; i++) {stTimeoutItem_t *item = (stTimeoutItem_t*)result->events[i].data.ptr;if (item->pfnPrepare) {item->pfnPrepare(item, result->events[i], active);} else {AddTail(active, item);}}unsigned long long now = GetTickMS();TakeAllTimeout(ctx->pTimeout, now, timeout);stTimeoutItem_t *lp = timeout->head;while (lp) {lp->bTimeout = true;lp = lp->pNext;}Join<stTimeoutItem_t, stTimeoutItemLink_t>(active, timeout);lp = active->head;while (lp) {PopHead<stTimeoutItem_t, stTimeoutItemLink_t>(active);if (lp->pfnProcess) {lp->pfnProcess(lp);}lp = active->head;}}
}

第 6 行:调用 epoll_wait() 等待 I/O 就绪事件,为了配合时间轮工作,这里的 timeout 设置为 1 毫秒。

第 8~10 行:active 指针指向当前执行环境的 pstActiveList 队列,注意这里面可能已经有“活跃”的待处理事件。timeout 指针指向 pstTimeoutList 列表,其实这个 timeout 完全是个临时性的链表, pstTimeoutList 永远为空。

第 12~19 行:处理就绪的文件描述符。如果用户设置了预处理回调,则调用 pfnPrepare 做预处理(15行);否则直接将就绪事件 item 加入 active 队列。实际上,pfnPrepare() 预处理函数内部也是会将就绪item加入 active 队列,最终都是加入到 active 队列等到 32~40 行统一处理。

第 21~22 行:从时间轮上取出已超时的事件,放到 timeout 队列。

第 24~28 行:遍历 timeout 队列,设置事件已超时标志(bTimeout 设为 true)。 第30行:将 timeout 队列中事件合并到 active 队列。 第32~40行:遍历 active 队列,调用工作协程设置的 pfnProcess() 回调函数 resume 挂起的工作协程,处理对应的 I/O 或超时事件。

这就是主协程的事件循环工作过程,我们看到它周而复始地 epoll_wait(),唤醒挂起的工作协程去处理定时器与 I/O 事件。这里的逻辑看起来跟所有基于 epoll 实现的事件驱动网络框架并没有什么特别之处,更没有涉及到任何协程调度算法,由此也可以看到 libco 其实是一个很典型的非对称协程机制。或许,从 call/return 的角度出发,而不是 resume/yield 去理解这种协程的运行机理,反而会有更深的理解吧。

小结

最后,通过本文的分析,希望读者应该能真正 libco 的运行机理。

对于喜欢动手实战的同学来说,如果你打算在未来的实际项目中使用它的话,能够做到心中有底,能绕过一些明显的坑,万一遇到问题时也能快速地解决。对于那些希望更深入理解各种协程工作原理的同学来说,希望本文的分析能起到抛砖引玉的作用,以后再学习 boost 协程或 golang 协程原理也能更快地抓住要点。

由于时间仓促,本文的分析与归纳思路不是很清晰,尤其对于初次接触 libco 的理解起来可能更加吃力。建议亲自动手,下载源码编译,阅读例子代码并运行一下,必要时直接阅读源代码可能会更有帮助。

本文作者:王亮

滴滴云官网:https://www.didiyun.com/?channel=14112

滴滴云疯狂双十一,服务器最低35折起

微信开源C++Libco介绍与应用(二)相关推荐

  1. 优秀的 Verilog/FPGA开源项目介绍(十二)- 玩FPGA不乏味

    优秀的 Verilog/FPGA开源项目介绍(十二)- 玩FPGA不乏味 Hello,大家好,之前给大家分享了大约一百多个关于FPGA的开源项目,涉及PCIe.网络.RISC-V.视频编码等等,这次给 ...

  2. qrencode生成二维码\微信开源算法识别opencv-contrib(一)

    最近需要开发一个二维码识别的应用,调研了几个开源算法库,opencv.quirc.zxing.zbar和微信开源的基于深度学习的识别算法,最终选定了微信开源的算法,毕竟又好又快 二维码 QR(Quic ...

  3. open source license主流的开源软件协议介绍

    open source license主流的开源软件协议介绍(20190222) 文章目录: 一.开源软件的直观介绍 二.常用开源协议介绍 三.常用开源协议之间的对比 当Adobe.Microsoft ...

  4. ROS系统玩转自主移动机器人(3)-- 开源机器人结构介绍

    ROS系统玩转自主移动机器人(3)-- 开源机器人结构介绍 本机器人机械结构设计相关的所有设计文件下载地址为:传送门  其中包含:三维造型设计文件(所有零件+装配效果)(tips:基于Solidwor ...

  5. 最新在线客服系统php代码微信软件公众号小程序app二维码聊天网站源码

    最新在线客服系统php代码微信软件公众号小程序app二维码聊天网站源码 管理界面 独家长期更新日志(欢迎反馈BUG) 1.添加手机端前后台声音提示 2.添加后台客户管理显示在线离线 3.添加清空当前对 ...

  6. 同城婚恋相亲交友系统源码开源版婚姻介绍红娘分销平台源码盲盒交友多种认证可封装APP

    带详细视频教程 程序全部开源(前台+后台),支持手机微信/公众号端(服务号),WAP手机端, 包含婚恋相亲系统主站,媒婆推广返利系统,红娘CRM管理系统,商家预约下单系统. ------------- ...

  7. OSS.Social微信项目标准库介绍

    经过本周的努力,昨晚终于完成OSS.Social微信项目的标准库支持,当前项目你已经可以同时在.net framework和.net core 中进行调用,调用方法也发生了部分变化,这里我简单分享下, ...

  8. 微信开源组件WCDB漫谈及Demo

    代码地址如下: http://www.demodashi.com/demo/12422.html 前言 移动端的数据库选型一直是一个难题,直到前段时间看到了WeMobileDev(微信前端团队)放出了 ...

  9. 企业微信开源系统,让开发者快速搭建基于企业微信的私域流量运营系统

    "经过行业的实战应用,企业微信已经成为"私域流量"运营的主要工具" 尽管现在基于企业微信开发的第三方产品处于一个百家争鸣的时代,但仍旧未能看到一个开源的.真正为 ...

最新文章

  1. Android JNI(Java Native Interface)技术介绍
  2. 数据结构与算法 | 用队列实现栈
  3. java笔试题递归,递归(网上搜的一些笔试题)
  4. 分布式之redis复习精讲
  5. php 模拟并发请求_PHP模拟并发请求
  6. 实践案例丨ACL2020 KBQA 基于查询图生成回答多跳复杂问题
  7. Activity之间的数据传递—实现Parcelable接口
  8. 数据库并发中的串行化
  9. 我的世界服务器皮肤怎么用文件夹,我的世界怎么用皮肤文件,怎么通过文件夹更改皮肤...
  10. 死磕Mosek!新mosek学习笔记1:VS项目配置。
  11. TAOCP_2.3.1_遍历二叉树
  12. c51汇编语言如何定义全局变量_汇编语言程序访问C语言全局变量
  13. SpringMvc工作原理学习总结
  14. 中国信通院 | 车联网白皮书合集(10本)
  15. 高中信息技术课程标准读后感
  16. Android 获得 usb 权限的两种方式
  17. 【AD系列教程】在PCB中加入任意图形
  18. 微信「看一看」多模型内容策略与召回
  19. 01背包中背包装满和不装满
  20. pageoffice 打开word异常

热门文章

  1. goframe与gin对比(一) 综述
  2. VSCode:python import下载的第三方库,报错问题解决
  3. 环境变量的用户变量与系统变量的区别
  4. softlayer iso_在SoftLayer服务器上使用图形界面
  5. 如何提升论文写作能力?
  6. 当类型设置为Integer时,传入的值为0,会将其转化为空字符串,从而造成查询数据异常
  7. python同时打开两个文件_在python中使用with打开多个文件的方法
  8. Linux磁盘I/O(二):使用vm.dirty_ratio和vm.dirty_background_ratio优化磁盘性能
  9. 2018年中考计算机考试成绩,2018年中考录取分数汇总,35所初中考成绩看2019中考!...
  10. 有哪一刻你彻底恨上了你的老师?