libco学习二
2021/7/18 23:12:27
本文主要是介绍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 栈。
这篇关于libco学习二的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-15在使用平台私钥进行解密时提示 "私钥解密失败" 错误信息是什么原因?-icode9专业技术文章分享
- 2024-11-15Layui框架有哪些方式引入?-icode9专业技术文章分享
- 2024-11-15Layui框架中有哪些减少对全局环境的污染方法?-icode9专业技术文章分享
- 2024-11-15laydate怎么关闭自动的日期格式校验功能?-icode9专业技术文章分享
- 2024-11-15laydate怎么取消初始日期校验?-icode9专业技术文章分享
- 2024-11-15SendGrid 的邮件发送时,怎么设置回复邮箱?-icode9专业技术文章分享
- 2024-11-15使用 SendGrid API 发送邮件后获取到唯一的请求 ID?-icode9专业技术文章分享
- 2024-11-15mailgun 发送邮件 tags标签最多有多少个?-icode9专业技术文章分享
- 2024-11-15mailgun 发送邮件 怎么批量发送给多个人?-icode9专业技术文章分享
- 2024-11-15如何搭建web开发环境并实现 web项目在浏览器中访问?-icode9专业技术文章分享