libco 简介
libco 是微信后台大规模使用的 c/c++协程库,2013 年至今稳定运行在微信后台的数万台机器上,使得微信后端服务能同时 hold 大量请求,被誉为微信服务器稳定性的基石。libco 在 2013 年的时候作为腾讯六大开源项目首次开源。libco源码地址。
libco 首先能解决 CPU 利用率与 IO 利用率不平衡,比用多线程解决 IO 阻塞 CPU 问题更高效。因为用户态协程切换比线程切换性能高:线程切换保存恢复的数据更多,需要用户态和内核态切换。其次 libco 又避免了异步调用和回调分离导致的代码结构破碎。
libco 采用 epoll 多路复用使得一个线程处理多个 socket 连接,采用钩子函数 hook 住 socket 族函数,采用时间轮盘处理等待超时事件,采用协程栈保存、恢复每个协程上下文环境。
为了让大家更容易阅读 libco 源码,本文以源码为主介绍 libco,内容偏底层细节。更多个人文章,欢迎关注作者博客。
设计思想
1. 协程切换
1.1 函数栈
首先复习下进程的地址空间,如图 1 所示,与本文相关的有代码段、堆、栈。代码段包含应用程序的汇编代码,指令寄存器 eip 存的是代码段中某一条汇编指令地址,cpu 从 eip 中取出汇编指令的地址,并在代码段中找到对应汇编指令开始执行。CPU 执行指令时在栈里存参数、局部变量等数据。代码通过 malloc、new 在堆上申请内存空间。
图 1
图 2 所示 C 代码,通过 gcc -m32 test.c -o test.o 在 i386 下编译,然后执行 gdb test.o。disas main 可看到图 3 所示的 main 函数汇编码,disas sum 可看到图 4 所示 sum 函数的汇编代码。调用 sum 时,main 和 sum 的函数栈如图 5 所示。图 5 的表共有两列,第一列为内存地址,第二列为该地址存的内容,除了用“…”省略的内存地址,其他每一行均比上一行低 4byte,因为栈地址从高到低增长。
从图 5 可以看出:一,每个函数的栈在 ebp 栈底指针和 esp 栈顶指针之间;二,存在调用关系的两个函数的栈内存地址是相邻的;三,ebp 指针指的位置存储的是上级函数的 ebp 地址,例如 sum 的 ebp 0xffffd598 位置存的是 main 的 ebp0xffffd5c8,目的是 sum 执行后可恢复 main 的 ebp,而 main 的 esp 可通过 sum 的 ebp + 4 恢复;四,sum 的 ebp + 4 位置,即 main 的 esp 位置存的是 sum 执行后的返回地址 0x08048415,该地址不在图 1 中的栈(Stack)里,而在图 1 中的代码段里,sum 执行后,leave 指令恢复 ebp、esp,ret 指令将 esp 处的内容 0x08048415 放到寄存器 eip,cpu 从 eip 里取出下一条待执行的指令地址,并根据指令地址从代码段里获取指令执行;五,sum 的参数 y、x 按高地址到低地址,依次存在 sum 的 ebp + 12、ebp + 8 位置处。
图 2
图 3 main 函数汇编码
图 4 sum 函数汇编码
图 5 32 位系统函数栈
1.2 协程栈
共享栈下文介绍,此处介绍非共享栈。在非共享栈模式下,每个非主协程有自己的栈,而该栈是在堆上分配的,并不是系统栈,但主协程的栈仍然是系统栈,每两个协程的栈地址不相邻。协程栈切换分为第 1 次、第 k 次(k>=2)换到目的协程 TargetCoroutine。
因为主协程即当前线程的第 1 次运行是系统调度的,后续才由用户调度,而非主协程每次都由用户调度。所以每次主协程切回的行为都一样,且和非主协程第 k 次(k>=2)的切回行为一致。
第 1 次切到 TargetCoroutine 之前, coctx_make(图 6)将函数地址 pfn 写入协程变量 regs[ kEIP ],pfn 即为 CoRoutineFunc 的指针。CoRoutineFunc 函数(图 7)在第 448 行调进用户自定义的协程函数 UserCoRoutineFunc(图 8)。图 6 中 ss_sp 为 128K 协程栈低地址,ss_size 为 128K 将 ss_sp+ss_size – sizeof(coctx_param_t)–sizeof(void*)作为 esp 开始位置,记录在 regs[kESP]。因为栈从高到低增长,所以真正的栈空间从高地址 ss_sp + ss_size – sizeof(void*) – sizeof(coctx_param_t)增长到低地址 ss_sp。这部分空间虽然是协程栈,但实际是通过 stack_mem->stack_buffer= (char*)malloc(stack_size)申请的堆空间。CoRoutineFunc、其调用的函数、其调用的函数再调用的函数…的函数栈均在该 128K 的堆空间里。
图 6
图 7
图 8
第 1 次切换到 TargetCoroutine 时。图 9 第 50 行将 esp 指向 TargetCoroutine.coctx_t.regs,此时 esp 指向的地址如图 10 所示。第 54 行从 regs[0]弹出返回地址,即 CoRoutineFunc 函数地址 0x08043212。第 65 行弹出 esp 地址即 ss_sp+ss_size–sizeof(coctx_param_t)–sizeof(void*)。第 67 行 pushl %eax 做两件事情:
一,将 esp 地址减 4,即 esp = ss_sp + ss_size – sizeof(coctx_param_t) – sizeof(void*) – 4;
二,在 esp 新位置压入 CoRoutineFunc 函数地址。第 71 行 ret 从 esp 取出 CoRoutineFunc 函数地址放入 eip 寄存器,cpu 从 eip 寄存器取出 CoRoutineFunc 函数第一条指令地址开始执行指令,CoRoutineFunc 函数第一条指令的地址就是 CoRoutineFunc 函数地址。
图 6 中没有对 regs[kEBP]赋初值,因此切到 TargetCoroutine 时弹出的 ebp 是 0,导致 CoRoutineFunc 函数栈存的上级函数的 ebp 是 0,但没有影响。CoRoutineFunc(图 7)只能执行到第 454 行:co_yield_env(env),然后切到其他协程,不会执行到 456 行。上级函数的 ebp 是在 CoRoutineFunc 执行后,用于恢复上级函数的 esp,但在这里 CoRoutineFunc 函数在 return 0 之前已经切到其他协程,因此上级函数的 ebp 是 0 不会导致错误。
图 9
图 10 第 1 次切到 TargetCoroutine
TargetCoroutine 在第 k-1 次被 coctx_swap 切出时,TargetCoroutine 是图 2.3.2.4 的源协程 curr。切出 TargetCoroutine 时会调进 coctx_swap,在执行图 9 第 34 行之前,函数栈如图 11 所示。然后将 co_swap(注意不是 coctx_swap,因为这里没有像图 4 的第 3、4、5 行修改 ebp 和 esp)栈顶地址+4 即 0xffd2dc14 通过第 38 行写入到 regs[kESP]。将 co_swap(不是 coctx_swap,原因同上)栈底地址 ebp 即 0xffd2dc3c 通过第 40 行写入 regs[kEBP]。将 stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()的第一条汇编指令地址 0x08043212 通过第 47 行写入 regs[ kEIP ]。此时 regs 数组内容如图 12 所示,在此期间 esp 的变化如图 12 左侧所示。
图 11 co_swap 函数栈
图 12 第 k(k>=2)次切到 TargetCoroutine
第 k(k>=2)次切回 TargetCoroutine 时,图 9 第 54 行弹出 eax,即 stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()的第一条汇编指令地址 0x08043212。第 61 行弹出 ebp 即图 2.3.2.6 所示的 0xffd2dc3c。图 2.3.2.4 第 65 行弹出 esp,即图 11 所示的 0xffd2dc14(不是 0xffd2dc10,因为图 2.3.2.4 第 38 行的压入的 eax 是 0xffd2dc10 + 4)。第 67 行做两件事情,首先 esp 减 4 得到切出时的栈地址 0xffd2dc10,再将 eax 存的汇编指令地址 0x08043212 写到 esp 即 0xffd2dc10 处。最后 ret 从 esp 取出汇编指令地址 0x08043212 放入 eip 寄存器,cpu 从 eip 寄存器取出指令地址开始执行指令。因为 TargetCoroutine 切回时,首先执行 stCoRoutineEnv_t* curr_env=co_get_curr_thread_env(),而 TargetCoroutine 在之前切出时,最后执行的代码是 coctx_swap(&(curr->ctx),&(pending_co->ctx) ),因此协程在切回后能接着之前切出时的代码继续执行。
对比图 10 和 12 发现:第 1 次切到 TargetCoroutine 时 ebp、esp、返回地址、以及其他寄存器和第 k(k>=2)次均不同。
libco 在 stCoRoutineEnv_t 定义了 pCallStack 数组,大小为 128,数组里的每个元素均为协程。pCallStack 用于获取当前协程 pCallStack[iCallStackSize - 1];获取当前协程挂起后该切到的协程 pCallStack[iCallStackSize - 2]。pCallStack 存的是递归调用(暂且称之为递归,并不是递归)的协程,pCallStack[0]一定是主协程。例如主协程调用协程 1,协程 1 调用协程 2…协程 k-1 调用协程 k,这种递归关系的 k 最大为 127,调到协程 127 时,此时 pCallStack[0]存主协程,pCallStack[1]存协程 1…pCallStack[k]存协程 k…pCallStack[127]存协程 127。但递归如此之深的协程实际中不会遇到,更多的场景应该是主协程调用协程 1,协程 1 挂起切回主协程,主协程再调用协程 2,协程 2 挂起切回主协程,主协程再调用协程 3…因此主协程调到协程 k 时,pCallStack[0]是主协程,pCallStack[1]是协程 k,其他元素为空;协程 k 挂起切回主协程时,pCallStack[0]是主协程,其他元素为空。因此 128 大小的 pCallStack 足够上万甚至更多协程使用。
1.3 主协程
主协程即为当前线程,用 stCoRoutine_t. cIsMain 标记。主协程的栈在图 1 所示的栈(Stack)区,而其他协程的栈在图 1 所示的堆(Heap)区。图 9 中切出主协程时 38-47 行寄存器存在 regs 数组里(不在 128K 的协程栈里,另外申请的堆空间)。切回主协程时,第 61、65 行弹出的 ebp、esp 指向的是系统栈里的内存,因此主协程的栈始终在系统栈上,用不到 128K 的协程栈。
那是否有必要将当前协程标记为主协程?图 2.1.3.1 的第 1024、1033 行是代码仅有的两处需要判断主协程。如果声明了协程私有变量,但没有创建其他协程时,co 为 NULL,此时通过 1026 行获取主协程私有变量。但在程序运行到某段代码时开始创建协程,如果不标记主协程,因为 co 不为 NULL,代码会通过第 1028 行获取主协程私有变量,此时因为拿不到以前设置的主协程私有变量而导致错误。例如若将图 2.1.3.1 第 1024、1033 行的 co->cIsMain 条件删掉,图 2.1.3.2 第 24 行输出的主协程私有变量为 11,而第 30 行输出 0。
图 13
图 14
1.4 共享栈
每个协程申请一个固定 128K 的栈,在协程数量很大时,存在内存压力。因此 libco 引入共享栈模式,示例代码可参看 example_copystack.cpp。共享栈对主协程没有影响,共享栈仍然是在堆上,而主协程的栈在系统栈上。
采用共享栈时,每个协程的栈从共享栈拷出时,需要分配空间存储,但按需分配空间。因为绝大部分协程的栈空间都远低于 128K,因此拷出时只需分配很小的空间,相比私有栈能节省大量内存。共享栈可以只开一个,但为了避免频繁换入换出,一般开多个共享栈。每个共享栈可以申请大空间,降低栈溢出的风险。
假设开 10 个共享栈,每个协程模 10 映射到对应的共享栈。假设协程调用顺序为主协程、协程 2、协程 3、协程 12。协程 2 切到协程 3 时,因为协程 2、3 使用的共享栈分别是第 2、3 个共享栈,没有冲突,所以协程 2 的栈内容仍然保留在第 2 个共享栈,并不拷出来,但协程 2 的寄存器会被 coctx_swap 保存在 regs 数组。调用到协程 12 时,协程 12 和协程 2 都映射到第 2 个共享栈,因此需要将协程 2 的栈内容拷出,并将协程 12 的栈内容拷贝到第 2 个共享栈中。所以共享栈多了拷出协程 2 的栈、拷进协程 12 的栈两个操作,即用拷贝共享栈的时间换取每个协程栈的空间。
图 15 在 609 行将 curr 的 stack_sp 指向 c 的地址,记录当前协程栈的低位置,当前协程栈的高位置是 ss_sp + ss_size – sizeof(coctx_param_t) – sizeof(void*),存的是 CoRoutineFunc 函数第一条汇编指令。curr 协程拷出协程时,需要拷贝从 curr->stack_sp(即 &c)到 ss_sp + ss_size – sizeof(void*) – sizeof(coctx_param_t)的栈内容。以后从其他协程再切换回 curr 协程时,如果共享栈里有 curr 协程,则只通过 coctx_swap 恢复寄存器即可;否则如图 15 第 657 行所示,需要将 curr 保存在 curr->save_buffer 的协程栈复制到从 curr->stack_sp 到 ss_sp + ss_size – sizeof(void*) – sizeof(coctx_param_t)的内存空间。
图 15 从 curr 协程切到 pending_co 协程时,如果是共享栈模式,先拿到 pending_co 的共享栈 stack_mem 里已有的协程 occupy_co,如果 occupy_co 非空且不是 pending_co,则保存已有的协程 save_stack_buffer(occupy_co),将 stack_mem 指向的协程换为 pending_co。并将 pending_co 和 occupy_co 均保存在 env 里,不能用局部变量记录,因为局部变量在 coctx_swap 之前属于 curr 协程,但 coctx_swap 后协程栈已经被切换,curr 的所有局部变量无法被 pending_co 访问。如果 occupy_co 和 pending_co 不是同一个协程,需要将 occupy_co 在共享栈里的数据拷贝到 occupy_co->save_buffer。协程的数据除了在栈里还分布在寄存器,如果 occupy_co 不是 curr,则在 occupy_co 之前被切换到其他协程时寄存器已经被 coctx_swap 保存;否则,则其寄存器在本次执行 coctx_swap 被保存。
图 15
1.5 线程切换 VS 协程切换
IO 密集时也可以使用多个线程。但线程有两个不足:一,切换代价大(保存恢复上下文、线程调度);二,占用资源多。
线程往往需要对公共数据加锁,锁会导致线程调度。因为用户的线程是在用户态执行,而线程调度和管理是在内核态实现,所以线程调度需要从用户态转到内核态,再从内核态转到用户态。切到内核态时需要保存用户态上下文,再切到用户态时,需要恢复用户态上下文,而线程的用户态上下文比协程上下文大得多。另外线程调度也需要耗时。
线程栈默认为 1M 大于协程栈 128K,另外线程还需要各种 struct 存一些状态,实测每个 pthread_create 创建的线程大约占 8M 左右内存,因此线程占用资源也远比协程多。
2. 协程控制
2.1 epoll 多路复用 IO 模型
协程使用 epoll 多路复用 IO 模型。常见的同步调用是同步阻塞模型,异步调用是异步 IO 模型。以 read 为例说明以下三种 IO 模型的区别,read 分为两个阶段:一,等待数据;二,将数据从 kernel 拷贝到用户线程。
同步调用 read 使用同步阻塞 IO,kernel 等待数据到达,再将数据拷贝到用户线程,这两个阶段用户线程都被阻塞。异步调用 read 使用异步 IO 模型,用户线程调用 read 后,立刻可以去做其它事, kernel 等待数据准备完成,然后将数据拷贝到用户内存,都完成后,kernel 通知用户 read 完成,然后用户线程直接使用数据,两个阶段用户线程都不被阻塞。而协程调用 read 使用多路复用 IO 模型,用户线程调用 read 后,第一阶段也不会被阻塞,但第二个阶段会被阻塞,epoll 多路复用 IO 模型可以在一个线程管理多个 socket。
同步调用在两个阶段都会阻塞用户线程,因此效率低。虽然可以为每个连接开个线程,但连接数多时,线程太多导致性能压力,也可以开固定数目的线程池,但如果存在大量长连接,线程资源不被释放,后续的连接得不到处理。异步调用时,因为两个阶段都不阻塞用户线程,因此效率最高,但异步的调用逻辑和回调逻辑需要分开,在异步调用多时,代码结构不清晰。而协程的 epoll 多路复用 IO 模型,虽然会阻塞第二个阶段,但因为第二阶段读数据耗时很少,因此效率略低于异步调用。协程最大的优点是在接近异步效率的同时,可以使用同步的写法(仅仅是同步的写法,不是同步调用)。例如 read 函数的调用代码后,紧接着可以写处理数据的逻辑,不用再定义回调函数。调用 read 后协程挂起,其他协程被调用,数据就绪后在 read 后面处理数据。
系统 select/poll、epoll 函数都提供多路 IO 复用方案,协程使用的是 epoll。select、poll 类似,监视 writefds、readfds、exceptfds 文件描述符(fd)。调用 select/poll 会阻塞,直到有 fd 就绪(可读、可写、有 except),或超时。select/poll 返回后,通过遍历 fd 集合,找到就绪的 fs,并开始读写。poll 用链表存储 fd,而 select 用 fd 标记位存储,因此 poll 没有最大连接数限制,而 select 限制为 1024。select/poll 共有的缺点是:一,返回后需要遍历 fd 集合找到就绪的 fd,但 fd 集合就绪的描述符很少;二,select/poll 均需将 fd 集合在用户态和内核态之间来回拷贝。epoll 的引入是为了解决 select/poll 上述两个缺点,epoll 提供三个函数 epoll_create、epoll_ctl、epoll_wait。epoll_create 在内核的高速 cache 中建一棵红黑树以及就绪链表(activeList)。epoll_ctl 的 add 在红黑树上添加 fd 结点,并且注册 fd 的回调函数,内核在检测到某 fd 就绪时会调用回调函数将 fd 添加到 activeList。epoll_wait 将 activeList 中的 fd 返回。epoll_ctl 每次只往内核添加红黑树节点,不需像 select/poll 拷贝所有 fd 到内核,epoll_wait 通过共享内存从内核传递就绪 fd 到用户,不需像 select/poll 拷贝出所有 fd 并遍历所有 fd 找到就绪的。
2.2 非阻塞 IO
协程的 epoll 多路复用 IO 模型使用的是非阻塞 IO,发起 read 操作后,可立即挂起协程,并调度其他协程。非阻塞 IO 是通过 fcntl 函数设置 O_NONBLOCK,影响 socket 族 read、write、connect、sendto、recvfrom、send、recv 函数。因为 read、recv、recvfrom 实现类似,write、send 实现类似。因此接下来介绍 read、write、connect 三个函数。
2.2.1 钩子函数 read
如图 16 所示,用户自定义的 read 函数 hook 住系统 read 函数。Read 时分三种情况:一,用户未开启 hook,直接在 337 行调用系统 read;二,如果用户指定了 O_NONBLOCK,也直接在 343 行调用系统 read,此时是非阻塞的;三,如果用户既开启了 hook,又没有指定 O_NONBLOCK,如果 libco 不做任何处理,即使通过 353 行 poll 了,但如果协程是超时返回,第 355 行还是会被阻塞。所以只要用户开启 hook,socket 函数(图 17)会在 234 行调用 fcntl 函数,最终调用图 18 的第 696 行,悄悄的设置 O_NONBLOCK,但 user_flag 并没有记录 O_NONBLOCK,这样即可和第二种情况区分。然后图 16 第 353 行调用 poll 将当前协程挂起,直到有数据可读或者超时,协程才会重新调度。在第二种情况下,不能先将协程挂起,等待就绪后再切回,因为用户显示的设置 O_NONBLOCK 是为了立即返回,如果挂起,就绪或超时后再切回,与用户需要立即获得返回结果的初衷违背。
图 16
图 17
图 18
2.2.2 钩子函数 write
非阻塞 write 在发送缓冲区没有空间时直接返回,发送缓冲区有空间时,则拷贝全部或部分(空间不够)数据,返回实际拷贝的字节数。
如图 19 所示,分三种情况:一,用户未开启 hook,在 372 行调用系统 write;二,如果用户指定了 O_NONBLOCK,在 378 行调用系统 write,此时是非阻塞的,这种情况与第三种情况区分的原因见 2.2.2.1;三,如果用户既开启了 hook,又没有指定 O_NONBLOCK,由 2.2.2.1 可知,libco 已悄悄设置 O_NONBLOCK,只要数据没有完全写入缓冲区,就通过第 402 行 poll 将协程挂起,等待有空余空间唤醒协程或者超时唤醒。writeret 记录本次调用写入的字节数,wrotelen 记录总共写入的字节数,如果本次写入没有空间或出错,则直接返回。因为 write 明确知道要写入数据的长度 nbyte,而一次可能无法写入全部数据,所以 write 在 while 循环里不断写数据,直到数据写完、写出错,才会停止写数据。而 2.2.2.1 的 read 不知要读多少数据,因此读一次就返回。
图 19
2.2.3 钩子函数 connect
图 20 所示为 libco 自定义 connect 函数片段。如果用户启用 hook,且未设置 O_NONBLOCK,libco 悄悄帮用户设置了 O_NONBLOCK,但调用 connect 后不能立即返回,因为 connect 有三次握手的过程,内核中对三次握手的超时限制是 75 秒,超时则会失败。libco 设置 O_NONBLOCK 后,立即调用系统 connect 可能会失败,因此在图 20 中循环三次,每次设置超时时间 25 秒,然后挂起协程,等待 connect 成功或超时。
图 20
2.3 epoll 激活协程
协程 hook 住了底层 socket 族函数,设置了 O_NONBLOCK,调用 socket 族函数后,调用 poll 注册 epoll 事件并挂起协程,让其他协程执行,所有协程都挂起后通过 epoll,在主协程里检查注册的 IO 事件,若就绪或超时则切到对应协程。
poll 最终会调进 co_poll_inner,图 21、图 22 分别为 co_poll_inner 函数上、下部分。该函数的参数 ctx 为 epoll 的环境变量,包含:epoll 描述符 iEpollFd,超时队列 pTimeOut,已超时队列 pstTimeoutList,就绪队列 pstActiveList,epoll_wait 就绪事件集合 result。其余参数 fds 为 socket 文件描述符集合,nfds 为 socket 文件描述符数目,timeout 超时时间,pollfunc 为系统 poll 函数。
第 908 行 epfd 是 epoll_create 创建的 epoll 描述符,相当于红黑树、就绪链表的标识,后文通过 epfd 管理所有 fd。919 到 927 行是在 nfds 少于 3 个时直接在栈上分配空间(更快),否则在堆上分配。第 930 行记录就绪 fd 的回调函数 OnPollProcessEvent,该回调函数会切回对应的协程。第 940 行记录切回协程之前的预处理函数 OnPollPreparePfn。第 948 行将每个 fd 通过 epoll_ctl 加入红黑树。第 968 行将 arg 加入超时队列 pTimeOut,在 980 行挂起协程。等到所有协程均挂起,主协程开始运行。
图 21
图 22
图 23 为主协程调用的 co_eventloop 函数片段。co_epoll_wait 找出所有就绪 fd,调用 pfnPrepare 即 OnPollPreparePfn,将就绪 fd 从超时队列 pTimeOut 移除,并加入就绪队列 pstActiveList。801 行用不到。TakeAllTimeout 拿出超时队列里所有超时元素,并在第 817 行加入 pstActiveList。824 行到 833 行将 807 行取出的未超时事件再加回超时队列,因为 TakeAllTimeout 拿出的不一定都是超时事件,超时队列底层实现是 60000 大小的循环数组,存放每毫秒(共 60000 毫秒)的超时事件,每个数组的元素均是一条链表,循环数组的目的是便于通过下标找到所有超时链表。例如超时时间是 10 毫秒的所有事件均记录在数组下标为 9(在循环数组实际的下标可能不是 9,仅举个例子)的链表里,所有超时时间大于 60000 毫秒的事件均记录在数组下标为 59999 的链表里。如果取出超时时间是 60000 毫秒的事件,TakeAllTimeout 会把超时时间大于 60000 毫秒的也取出来,因此需要再把超时时间大于 60000 毫秒的重新加回超时队列。在第 836 行是在协程超时或 fd 就绪时调用 pfnProcess 即 OnPollProcessEvent 切回协程。协程切回后,由上文 2.1.2 可知,协程接着 co_yield_env 里 co_swap 里的 stCoRoutineEnv_t* curr_env = co_get_curr_thread_env()继续执行,co_yield_env 执行完后从图 22 的第 981 行继续执行。第 986 行应该是多余的,因为 pfnPrepare 已经从超时队列删除事件。992 行调用 epoll 删除当前协程的就绪 fd。
注意到 co_poll_inner 传入的 fd 数组,而 arg 只是链表中的一个元素。假设 co_poll_inner 传入 10 个文件描述符,如果只有 1 个 fd 就绪,OnPollPreparePfn 从 pTimeOut 删除 arg,则 10 个文件 fd 都从超时队列删除,在图 22 切回协程时在第 987 到 995 行将 10 个描述符都从红黑树删除,然后应用层需要将 9 个未就绪的 fd 重新调用 co_poll_inner 再加入红黑树。如果每次只就绪一个 fd,这样共需要加入红黑树:10 + 9+ 8 +… +1 次,效率低于 10 次 poll,每次只 poll 一个 fd。co_poll_inner 提供传入 fd 数组的原因是,co_poll_inner 是 poll 调用的,而 poll 是 hook 的系统函数,不能改变系统的语义。系统 poll 支持数组的原因是,调用系统 poll 一次,可检查多个 fd,比调用系统 poll 多次,每次检查一个 fd,效率更高。因此系统 poll 适合一次 poll 多个 fd,但 libco 自定义的钩子函数 poll 不适合一次 poll 多个 fd,所以 libco 使用 poll 时需避免一次 poll 多个 fd。
图 23
2.4 信号量激活协程
libco 提供信号量机制,类似 golang 的 channel。example_cond.cpp 举了生产者和消费者的例子,一个协程做生产者,另一个做消费者,生产者产生数据后,唤醒消费者。
主协程首先创建消费者协程,并通过 co_resume 切换到消费者协程函数 Consumer,co_resume 会调用到 coctx_swap 保存主协程的栈内容,并将消费者协程初始化在 regs 里的 esp、ebp、返回地址等数据弹到寄存器和消费者协程栈里,此时开始调度消费者协程。Consumer 在检查到 task_queue 为空时,将消费者协程通过 co_cond_timedwait,加入超时队列 pTimeout,并加入信号量队列 env->cond,然后通过 co_yield_ct 切换回主协程,co_yield_ct 调用到 coctx_swap 函数,保存消费者协程,并恢复主协程。主协程接着创建生产者协程,通过 co_resume 切换到生产者协程。生产者协程函数 Producer 在 task_queue 里添加数据后,通过 co_cond_signal 从 pTimeOut、env->cond 里删掉消费者协程,并加入就绪队列 pstActiveList,然后通过 poll 挂起将生产者协程加入超时队列 pTimeout,并在 co_poll_inner 通过 co_yield_env 切回到主协程。主协程在 co_eventloop 遍历就绪队列 pstActiveList,调度消费者协程消费 task_queue 里数据。
2.5 超时激活协程
当前协程通过语句 poll(NULL, 0, duration),可设置协程的超时时间间隔 duration。poll 是被 hook 住的函数,执行 poll 之后,当前协程会被加到超时队列 pTimeOut,并被切换到其他协程,所有协程挂起后,主协程扫描超时队列,找到超时的协程,并切换。因此可用 poll 实现协程的睡眠。注意不可用 sleep,因为 sleep 会睡眠线程,线程睡眠了,协程无法被调度,所有的协程也都不会执行了。
2.6 回调激活协程
使用 libco 需要 hook 住 socket 族函数,但业务代码一般使用现成的非 libco 网络库,如果改该网络库使其 hook 住 socket 族函数,工作量太大。因此业务侧可在协程里异步调用,异步调用后挂起协程,所有的异步回调使用同一函数,在同一回调函数里,根据异步调用时的标记决定唤醒哪个协程。该方案也可做到不分离异步调用和处理异步调用返回的数据。但需要业务侧添加一个统一的异步回调函数,并在该函数里根据标识调度协程。
3. 协程池
协程池的好处是不用每次使用协程时都创建新的协程。创建新协程主要开销有两个:一,需要 malloc 协程环境 stCoRoutine_t,stCoRoutine_t 有 4K 大小的协程私有变量数组;二,协程栈 128K。每次创建新的协程要分配这么大的空间需要有时间开销,另外频繁申请、销毁会导致内存碎片的产生。即使在共享栈模式下不用为每个协程申请协程栈,也会有第一部分 stCoRoutine_t 的开销。每次从协程池取出协程后,将 stCoRoutine_t.pfn 重新初始化为用户自定义的协程函数即可。
4. 协程私有变量
协程私有变量为每个协程独享,不会被其他协程修改,可参看 example_specific.cpp。协程私有变量通过宏 CO_ROUTINE_SPECIFIC 定义,然后重载操作符->实现 set/get。该宏首先定义线程私有变量,声明 pthread_key_t 类型的变量,并通过 pthread_once_t 保证 pthread_key_t 类型变量只被 create 一次。如果当前没有创建过协程,或者当前协程是主协程,则通过 co_pthread_setspecific/pthread_getspecific 来 set/get 线程私有变量。否则在当前协程的 aSpec 数组里 set/get 协程私有变量,pthread_key_t 类型的变量作为数组下标。
pthread_once 控制 pthread_key_create 在多线程环境中只会执行一次。routine_init##name 控制一个线程的多个协程只会调用 pthread_once 一次,避免每次 set、get 变量时均调用 pthread_once。
因为协程私有变量需要重载操作符->,因此 CO_ROUTINE_SPECIFIC 第一个参数必须为结构体或类。虽然 aSpec 有 1024 个容量,但通过 pthread_key_create 创建的 key 是从 1 开始,因此协程私有变量实际可容纳 1023 个元素。
注意事项
1. 共享栈下内容篡改
图 24 所示代码,协程 RoutineFuncA、RoutineFuncB 共用一个共享栈。运行代码,第 7 行输出 1,第 14 行输出 2,但在第 14 行之前只在 RoutineFuncA 里将 global_ptr 指向的内容设置为 1。原因是在 RoutineFuncA 里 global_ptr 指向 routineidA 的地址,而在 RoutineFuncB 里,因为是共享栈,所以 routineidB 和 routineidA 的地址一样,而该地址处的值被第 13 行修改为 2,因此第 14 行打印出来 2。所以共享栈模式下,需避免协程之前传递地址,因为地址的内容会被篡改。
图 24
2. poll 效率
libco 自定义的钩子函数 poll,虽然支持传入 fd 数组,但一次 poll 多个 fd 的效率,不如多次 poll 每次 poll 一个 fd 的效率,原因见 2.3 epoll 激活协程。
3.栈溢出
每个协程栈使用 128K 的堆内存,128K 是 malloc 使用 brk 和 mmap 分配堆内存的分界线。但 128K 的空间可能不够一个协程使用,导致协程栈溢出。协程栈溢出问题,有如下三种解决方案。
3.1 堆内存
大内存不在协程栈上分配,通过 malloc、new 在堆上分配,可以使用编译参数-Wstack-usage 检查使用较大栈空间的变量。另外栈以外空间的非法读写不一定会 Crash,因此在 128K 协程栈上下添加保护页,并通过 mprotect 设置保护页权限,非法读写保护页时,程序会立即 Crash。推荐使用该方案。
3.2 自动扩容协程栈
在协程调用的所有函数入口检查栈使用率,如果栈使用率超过阈值,就自动扩容协程栈。在进入函数时,声明一个临时变量,获取该变量的地址 varaddr,因为协程栈空间高地址为 stack_mem->stack_bp,低地址为 stack_mem->stack_buffer,因此使用率为(stack_mem->stack_bp -varaddr) / (stack_mem->stack_bp - stack_mem->stack_buffer)。如果使用率超过阈值,重新申请大空间的新协程栈,并将旧协程栈拷贝到新协程栈。
但如果协程函数里使用了指针,比如指针 ptr 指向旧协程栈内存地址 0xffd344c0,栈拷贝后,访问 ptr 的内容仍然是访问旧协程栈 0xffd344c0,导致非法访问。在协程函数里使用指针的概率很大,比如声明数组,因此该方案风险较大。
golang 支持协程栈的自动扩容,1.3 之前是分段栈,即老栈保留,另外再开辟新栈,老栈新栈一起使用,超出老栈的数据用新栈保存。使用分段栈存在 hot split 问题,所以 1.3 及之后采用连续栈,老栈不够用时,申请大空间的新栈,并将老栈数据拷贝到新栈。拷贝老栈到新栈时,golang 也面临指针失效的问题,原文参考,但 golang 的编译器会管理每个指针的位置,原文参考。只要知道每个指针在老栈的位置,算出相对协程栈顶偏移量,即可将指针迁移到新栈的位置。
3.3 虚拟内存
因为 malloc 使用的是虚拟内存,而物理内存按需分配,协程占用的内存并不是 malloc 的内存大小,而是实际使用到的内存大小,因此可将 malloc(128K)改为 malloc(8M)。但在非协程池模式下,频繁 mmap 8M 的堆内存会导致大量缺页中断。在协程池模式下,假设池子有 10 个协程,10 个协程轮流处理同一个用户自定义的协程函数,而该函数若最终会使用 8M 的物理内存,最终导致协程池里的所有协程实际使用内存都为 8M,在协程池大的情况下,会迅速耗尽内存。因此在回收协程池里的协程时,需要检测物理内存实际使用量(方法同 3.2.2),超过 128K 时,需要销毁协程,重建 128K 的协程加入协程池。
原文链接:
https://cloud.tencent.com/developer/article/1459729
评论