产品战略专家梁宁确认出席AICon北京站,分享AI时代下的商业逻辑与产品需求 了解详情
写点什么

微信后台 libco 协程设计及实现

  • 2019-07-28
  • 本文字数:12185 字

    阅读完需:约 40 分钟

微信后台libco协程设计及实现

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


2019-07-28 08:003960

评论

发布
暂无评论
发现更多内容

解读surging 的内存过高的原因

不在线第一只蜗牛

内存 .NET 7

win版Serato DJ Pro(专业DJ软件) v3.1.4.890 (x64)特别版

iMac小白

京东JD商品sku信息API返回值实践:商品规格数据驱动的供应链优化

技术冰糖葫芦

API Explorer API 接口 pinduoduo API

事务中存在多线程,怎么处理?

江南一点雨

Java spring

win版Android Studio(安卓开发环境)v2023.3.1.20 特别版

iMac小白

Simplemind pro for Mac(mac上的思维导图软件)v2.4.0版

Mac相关知识分享

Mac 办公软件 Mac软件 思维导图软件 思维导图绘制软件

微信多开、消息防撤回工具 WechatTweak for Mac v3.8.8.18中文集成版

Mac相关知识分享

办公软件 Mac软件 mac软件下载 微信多开 微信软件

深入理解Playwright的高级功能和用法

我再BUG界嘎嘎乱杀

Python playwright

全网爆火【MBTI人格测试】是如何实现的?

AppBuilder

三维建模软件Rhinoceros 8 for Mac(犀牛8 mac版)v8.8.24163.12482版

Mac相关知识分享

Mac软件 三维建模 mac软件下载

软件测试学习笔记丨Python 内置库 科学计算

测试人

软件测试

QLab Pro for Mac(音频剪辑软件)v5.4.0版

Mac相关知识分享

音频制作 Mac软件 音频软件 mac下载

强大的音频处理软件Celemony Melodyne 5 Studio for mac(多功能音频编辑)v5.4.0.036版

Mac相关知识分享

Mac软件 音频处理 音频工具 音频软件

必不可少的办公软件Microsoft Outlook 2021 LTSC for Macv16.86中文正式版

Mac相关知识分享

办公软件 Mac软件 mac软件下载

以容器方式使用桌面系统

walker12138

win版BricsCAD Ultimate2024(2D与3D CAD建模设计) v24.2.05特别版

iMac小白

MoneyPrinterPlus:AI自动短视频生成工具,赚钱从来没有这么容易过

程序那些事

工具 程序那些事 AIGC

DeFi(去中心化金融)是什么,DeFi应用有哪些?如何利用它赚钱?

区块链开发团队DappNetWork

DeFi流动性挖矿 NFT链游 区块链开发 交易所源码 dapp合约开发

UE4/UE5像素流送云推流|程序不稳定、弱网画面糊怎么办?

点量实时云渲染

UE5 像素流送 像素流 像素流送技术 UE4

TDengine Cloud 正式入驻 Azure Marketplace,服务中国企业出海

TDengine

数据库 tdengine

微信后台libco协程设计及实现_数据库_runzhi_InfoQ精选文章