libco学习二

libco 的协程

  通过上一篇的分析,我们已经对 libco 中的协程有了初步的印象。我们完全可以把 它当做一种用户态线程来看待,接下来我们就从线程的角度来开始探究和理解它的实现机制。

   以 Linux 为例,在操作系统提供的线程机制中,一个线程一般具备下列要素:

  (1) 有一段程序供其执行,这个是显然是必须的。另外,不同线程可以共用同一段程序。这个也是显然的,想想我们程序设计里经常用到的线程池、工作线程,不同的工 作线程可能执行完全一样的代码。

   (2) 有起码的“私有财产”,即线程专属的系统堆栈空间

  (3) 有“户口”,操作系统教科书里叫做“进(线)程控制块”,英文缩写叫 PCB。在 Linux 内核里,则为 task_struct 的一个结构体。有了这个数据结构,线程才能成为内核 调度的一个基本单位接受内核调度。这个结构也记录着线程占有的各项资源。

  此外,值得一提的是,操作系统的进程还有自己专属的内存空间(用户态内存空 间),不同进程间的内存空间是相互独立,互不干扰的。而同属一个进程的各线程,则是共享内存空间的。显然,协程也是共享内存空间的

  我们可以借鉴操作系统线程的实现思想,在 OS 之上实现用户级线程(协程)。跟 OS 线程一样,用户级线程也应该具备这三个要素。所不同的只是第二点,用户级线程 (协程)没有自己专属的堆空间,只有栈空间。首先,我们得准备一段程序供协程执行, 这即是 co_create() 函数在创建协程的时候传入的第三个参数——形参为 void*,返回值 为 void 的一个函数。 其次,需要为创建的协程准备一段栈内存空间。栈内存用于保存调用函数过程中的临时变量,以及函数调用链(栈帧)。在 Intel 的 x86 以及 x64 体系结构中,栈顶由 ESP(RSP)寄存器确定。所以创建一个协程,启动的时候还要将 ESP(RSP)切到分配的栈内存上,后文将对此做详细分析。 co_create() 调用成功后,将返回一个 stCoRoutine_t 的结构指针(第一个参数)。从 命名上也可以看出来,该结构即代表了 libco 的协程,记录着一个协程拥有的各种资源, 我们不妨称之为“协程控制块”。这样,构成一个协程三要素——执行的函数,栈内存, 协程控制块,在 co_create() 调用完成后便都准备就绪了。

总结1:

协程的创建函数:co_create() 

协程的启动函数:co_resume()

协程三要素——执行的函数,栈内存, 协程控制块

关键数据结构及其关系

协程控制块stCoRoutine_t

 1 //协程控制块
 2 struct stCoRoutine_t
 3 {
 4     stCoRoutineEnv_t *env;    //协程执行的环境,全局性的资源,一个进程下面的所有协程都共享
 5     pfn_co_routine_t pfn;    //协程函数
 6     void *arg;    //参数
 7     coctx_t ctx;    //用于协程切换时保存 CPU 上下文(context)的;所谓的上下文,即esp、ebp、eip和其他通用寄存器的值
 8 
 9     char cStart;
10     char cEnd;
11     char cIsMain;
12     char cEnableSysHook;    //通过dlsym机制 hook 了各种网络 I/O 相关的系统调用
13     char cIsShareStack;    //Separate coroutine stacks 和 Copying the stack,默认使用前者
14 
15     void *pvEnv;    //保存程序系统环境变量的指针
16 
17     //char sRunStack[ 1024 * 128 ];
18     stStackMem_t* stack_mem;    //协程运行时的栈内存,128K
19 
20 
21     //save satck buffer while confilct on same stack_buffer;共享栈
22     char* stack_sp;
23     unsigned int save_size;
24     char* save_buffer;
25 
26     stCoSpec_t aSpec[1024];
27 
28 };

  接下来我们逐个来看一下 stCoRoutine_t 结构中的各项成员。首先看第 2 行的 env, 协程执行的环境。这里提一下,不同于 go 语言,libco 的协程一旦创建之后便跟创建时的那个线程绑定了的,是不支持在不同线程间迁移(migrate)的这个 env,即同属于一个线程所有协程的执行环境,包括了当前运行协程、上次切换挂起的协程、嵌套调用的协程栈,和一个 epoll 的封装结构(TBD)。第 3、4 行分别为实际待执行的协程函数以及参数。第 5 行,ctx 是一个 coctx_t 类型的结构,用于协程切换时保存 CPU 上下文 (context)的;所谓的上下文,即esp、ebp、eip和其他通用寄存器的值。第 7 至 11 行是 一些状态和标志变量,意义也很明了。第 13 行 pvEnv,这是一个用于保存程序系统环境变量的指针。16 行这个 stack_mem,协程运行时的栈内存。通过注释我们知道这个栈内存是固定的 128KB 的大小。我们可以计算一下,每个协程 128K 内存,那么一个进程启 100 万个协程则需要占用高达 122GB 的内存。读者大概会怀疑,不是常听说协程很轻量级吗,怎么会占用这么多的内存?答案就在接下来 19 至 21 行的几个成员变量中。这里要提到实现 stackful 协程(与之相对的还有一种 stackless 协程)的两种技术:Separate coroutine stacks 和 Copying the stack (又叫共享栈)。实现细节上,前者为每一个协程分配一个单独的、固定大小的栈;而后者则仅为正在运行的协程分配栈内存,当协程被调度切换出去时,就把它实际占用的栈内存 copy 保存到一个单独分配的缓冲区;当被切出去的协程再次调度执行时,再一 次 copy 将原来保存的栈内存恢复到那个共享的、固定大小的栈内存空间。通常情况下, 一个协程实际占用的(从 esp 到栈底)栈空间,相比预分配的这个栈大小(比如 libco 的 128KB)会小得多;这样一来,copying stack 的实现方案所占用的内存便会少很多。 当然,协程切换时拷贝内存的开销有些场景下也是很大的。因此两种方案各有利弊,而 libco 则同时实现了两种方案,默认使用前者,也允许用户在创建协程时指定使用共享栈。

总结2:

栈内存的实现方案:

  stackless 协程:为每一个协程分配一个单独的、固定大小的栈。

  stackful 协程:仅为正在运行的协程分配栈内存,当协程被调度切换出去时,就把它实际占用的栈内存 copy 保存到一个单独分配的缓冲区;当被切出去的协程再次调度执行时,再一 次 copy 将原来保存的栈内存恢复到那个共享的、固定大小的栈内存空间。

通常情况下, 一个协程实际占用的(从 esp 到栈底)栈空间,相比预分配的这个栈大小(比如 libco 的 128KB)会小得多。

 

用于保存协程执行上下文的 coctx_t 结构

 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 };

  协程控制块 stCoRoutine_t 结构里第一个字段 env,用于保存协程的运 行“环境”。前文也指出,这个结构是跟运行的线程绑定了的,运行在同一个线程上的 各协程是共享该结构的,是个全局性的资源。源码如下

 1 struct stCoRoutineEnv_t
 2 {
 3     stCoRoutine_t *pCallStack[ 128 ];    //保存这些函数(协程看成一种特殊的函数)的调用链的栈,调用栈
 4     int iCallStackSize;
 5     /**
 6      * 这个结构也是一个全局性的资源,被同一个线程上所有协程共享。从命名也看得出来,
 7      * stCoEpoll_t 是跟 epoll 的事件循环相关的
 8      */
 9     stCoEpoll_t *pEpoll;
10 
11     //for copy stack log lastco and nextco
12     stCoRoutine_t* pending_co;
13     stCoRoutine_t* occupy_co;
14 };

  stCoRoutineEnv_t 内部有一个叫做 CallStack 的“栈”,还有个 stCoPoll_t 结构 指针。此外,还有两个 stCoRoutine_t 指针用于记录协程切换占有共享栈的和将要切换运行的协程。在不使用共享栈模式时 pending_co 和 ocupy_co 都是空指针

  stCoRoutineEnv_t 结构里的 pCallStack 不是普通意义上我们讲的那个程序运行栈, 那个指的是 ESP(RSP)寄存器指向的栈,是用来保留程序运行过程中局部变量以及函数调用关系的。但是,这个 pCallStack 又跟 ESP(RSP)指向的栈有相似之处。如果将协程看成一种特殊的函数,那么这个 pCallStack 就时保存这些函数的调用链的栈。我们已经讲过,非对称协程最大特点就是协程间存在明确的调用关系;甚至在有些文献中,启动协程被称作 call,挂起协程叫 return。非对称协程机制下的被调协程只能返回到调用者协程,这种调用关系不能乱,因此必须将调用链保存下来。这即是 pCallStack 的作用,将它命名为“调用栈”实在是恰如其分。 每当启动(resume)一个协程时,就将它的协程控制块 stCoRoutine_t 结构指针保存在 pCallStack 的“栈顶”然后“栈指针”iCallStackSize 加 1,最后切换 context 到待启动协程运行。当协程要让出(yield)CPU 时,就将它的 stCoRoutine_t 从 pCallStack 弹 出,“栈指针”iCallStackSize 减 1,然后切换 context 到当前栈顶的协程(原来被挂起的 调用者)恢复执行。这是一个“压栈”和“弹栈”的过程。

  那么这里存在一个问题,libco 程序的第一个协程呢,假如第一个协程 yield 时,CPU 控制权让给谁呢?关于这个问题,我们首先要明白这“第一个”协程是什么。实际上, libco 的第一个协程,即执行 main 函数的协程,是一个特殊的协程。这个协程又可以称作主协程,它负责协调其他协程的调度执行(后文我们会看到,还有网络 I/O 以及定时 事件的驱动),它自己则永远不会 yield,不会主动让出 CPU。不让出(yield)CPU,不 等于说它一直霸占着 CPU。我们知道 CPU 执行权有两种转移途径,一是通过 yield 让给 调用者,其二则是 resume 启动其他协程运行。后文我们可以清楚地看到,co_resume()与 co_yield() 都伴随着上下文切换,即 CPU 控制流的转移。当你在程序中第一次调用 co_resume() 时,CPU 执行权就从主协程转移到了 resume 目标协程上了。 提到主协程,那么另外一个问题又来了,主协程是在什么时候创建出来的呢?什么时候 resume 的呢?事实上,主协程是跟 stCoRoutineEnv_t 一起创建的主协程也无需调用 resume 来启动它就是程序本身,就是 main 函数。主协程是一个特殊的存在,可以认为它只是一个结构体而已。在程序首次调用 co_create() 时,此函数内部会判断当前进程(线程)的 stCoRoutineEnv_t 结构是否已分配,如果未分配则分配一个,同时分配 一个 stCoRoutine_t 结构,并将 pCallStack[0] 指向主协程。此后如果用 co_resume() 启动协程,又会将 resume 的协程压入 pCallStack 栈。以上整个过程可以用图1来表示。

stCoRoutineEnv_t 结构的 pCallStack 示意图

  coroutine2 整处于栈顶,也即是说,当前正在 CPU 上 running 的协程是 coroutine2。而 coroutine2 的调用者是谁呢?是谁 resume 了 coroutine2 呢?是 coroutine1。 coroutine1 则是主协程启动的,即在 main 函数里 resume 的。当 coroutine2 让出 CPU 时, 只能让给 coroutine1;如果 coroutine1 再让出 CPU,那么又回到了主协程的控制流上了。 当控制流回到主协程上时,主协程在干些什么呢?回过头来看生产者消费者那个例子。那个例子中,main 函数中程序最终调用了 co_eventloop()。该函数是一个基于 epoll/kqueue 的事件循环,负责调度其他协程运行,具体细节暂时略去。这里我们只需知道,stCoRoutineEnv_t 结构中的 pEpoll 即使在这里用的就够了。 至此,我们已经基本理解了 stCoRoutineEnv_t 结构的作用。

总结

1、协程的创建函数:co_create() 

2、协程的启动函数:co_resume()

3、协程三要素——执行的函数,栈内存, 协程控制块

4、栈内存的实现方案:

  stackless 协程:为每一个协程分配一个单独的、固定大小的栈。

  stackful 协程:仅为正在运行的协程分配栈内存,当协程被调度切换出去时,就把它实际占用的栈内存 copy 保存到一个单独分配的缓冲区;当被切出去的协程再次调度执行时,再一 次 copy 将原来保存的栈5内存恢复到那个共享的、固定大小的栈内存空间。

通常情况下, 一个协程实际占用的(从 esp 到栈底)栈空间,相比预分配的这个栈大小(比如 libco 的 128KB)会小得多。

5、非对称协程最大特点就是协程间存在明确的调用关系,这种调用关系也就是调用链由协程环境中的pCallStack数组来保存,协程的启动和挂起就是入栈和出栈的过程。 

6、协程的启动

  每当启动(resume)一个协程时,就将它的协程控制块 stCoRoutine_t 结构指针保存在 pCallStack 的“栈顶”,然后“栈指针”iCallStackSize 加 1,最后切换 context 到待启动协程运行。

7、协程的挂起

  当协程要让出(yield)CPU 时,就将它的 stCoRoutine_t 从 pCallStack 弹 出,“栈指针”iCallStackSize 减 1,然后切换 context 到当前栈顶的协程(原来被挂起的 调用者)恢复执行

8、libco 的第一个协程

  实际上, 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 栈。

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