0%

微信 libco 协程库源码分析

libco 是微信后台开发和使用的协程库,同时也是极少数的直接将 C/C++ 协程运用到如此大规模的生产环境中的案例。

《云风 coroutine 协程库源码分析》 中,介绍了有栈协程的实现原理。相比云风的 coroutine,libco 在性能上号称可以调度千万级协程。 从使用上来说,libco 不仅提供了一套类 pthread 的协程通信机制,同时可以零改造地将三方库的阻塞 IO 调用进行协程化。

本文将从源码角度着重分析 libco 的高效之道。

在正式阅读本文之前,如果对有栈协程的实现原理不是特别了解,建议提前阅读 《云风 coroutine 协程库源码分析》

同时,我也提供了 libco 注释版,用以辅助理解 libco 的源码

libco 和 coroutine 的基本差异

相比于云风的 coroutine 协程库, libco 整体更成熟,性能更高,使用上也更加方便。主要体现在以下几个方面:

  1. 协程上下文切换性能更好
  2. 协程在 IO 阻塞时可自动切换,包括 gethostname、mysqlclient 等。
  3. 协程可以嵌套创建,即一个协程内部可以再创建一个协程。
  4. 提供了超时管理,以及一套类 pthread 的接口,用于协程间通信。

关于 libco 的如何实现有栈协程的切换,co_resume、co_yield 是如何实现的。此部分内容已在 云风 coroutine 协程库源码分析 中进行了详细的剖析。各个协程库这里的实现大同小异,本文就不再重复讲述此部分内容了。

不过,libco 在协程的栈空间上有不一样的地方:

  1. 共享栈是可选的,如果想要使用共享栈模式,则需要用户自行创建栈空间,在 co_create 时传递给 libco。(参数 stCoRoutineAttr_t* attr)
  2. 支持协程使用独立的栈空间,不使用共享栈模式。(默认每个协程有 128k 的栈空间)
  3. libco 默认是独立的栈空间,不使用共享栈。

除此之外,出于性能考虑,libco 不使用 ucontext 进行用户态上下文的切换,而是自行写了一套汇编来进行上下文切换。

另外,libco 利用 co_create 创建的协程, 需要自行调用 co_release 进行释放。这里和 coroutine 不太一样。

协程上下文切换性能更好

我们之前提到,云风的 coroutine 库使用 ucontext 来实现用户态的上下文切换,这也是实现协程的关键。

libco 基于性能优化的考虑,没有使用 ucontext,而是自行编写了一套汇编来处理上下文的切换, 具体代码在 coctx_swap.S

libco 的上下文切换大体只保存和交换了两类东西:

  1. 寄存器:函数参数类寄存器、函数返回值、数据存储类寄存器等。
  2. 栈:rsp 栈顶指针

相比于 ucontext,缺少了浮点数上下文和 sigmask(信号屏蔽掩码)。具体可对比 glibc 的相关源码

  • 取消 sigmask 是因为 sigmask 会引发一次 syscall,在性能上会所损耗。
  • 取消浮点数上下文,主要是在服务端编程几乎用不到浮点数计算。

此外,libco 的上下文切换只支持 x86,不支持其他架构的 cpu,这是因为在服务端也几乎都是 x86 架构的,不用太考虑 CPU 的通用性。

知乎网友的实验 证明:libco 的上下文切换效率大致是 ucontext 的 3.6 倍。

总结来说,libco 牺牲了通用性,把运营环境中用不到的寄存器拷贝去掉,对代码进行了极致优化,但是换取到了很高的性能。

协程在 IO 阻塞时可自动切换

我们希望的是,当协程中遇到阻塞 IO 的调用时,协程可以自行 yield 出去,等到调用结束,可以再 resume 回来,这些流程不用用户关心。

然而难点在于, 对于自己代码中的阻塞类调用尚且容易改造,可以把它改成非阻塞 IO,然后框架内部进行 yield 和 resume。但是大量三方库也存在着阻塞 IO 调用,如知名的 mysqlclient 就是阻塞 IO,对于此类的 IO 调用,我们无法直接改造,不便于和我们现有的协程框架进行配合。

然而,libco 的协程不仅可以做到 IO 阻塞协程的自动切换,甚至包括三方库的阻塞 IO 调用都可以零改造的自动切换。

这是因为,libco 巧妙运用了 Linux 的 hook 技术,同时配合了 epoll 事件循环,完美的完成了阻塞 IO 的协程化改造。

所谓系统函数 hook,简单来说,就是可以替换原有的系统函数,例如 read、write 等,将其替换为自己的逻辑。libco 目前所有关于 hook 系统函数的代码都在 co_hook_sys_call.cpp 中可以看到。

在分析具体代码之前,有个点需要先注意下:libco 的 hook 逻辑用于 client 行为的阻塞类 IO 调用

client 行为指的是,本地主动 connect 一个远程的服务,使用的时候一般先往 socket 中 write 数据,然后再 read 回包这种形式。

read 函数的 hook 流程

我们以 read 函数为例,看下都做了什么:

1
2
3
4
5
6
7
8
9
10
11
ssize_t read(int fd, void *buf, size_t nbyte)
{
struct pollfd pf = {0};
pf.fd = fd;
pf.events = (POLLIN | POLLERR | POLLHUP);

int pollret = poll(&pf,1,timeout);

ssize_t readret = g_sys_read_func(fd,(char*)buf ,nbyte );
return readret;
}

上述代码对原有代码进行了简略,只保留了最核心的 hook 逻辑。

注意:这里 poll 函数实际上也是被 hook 过的函数,在这个函数中,最终会交由 co_poll_inner 函数处理。

co_poll_inner 函数主要有三个作用:

  1. 将 poll 的相关事件转换为 epoll 相关事件,并注册到当前线程的 epoll 中。
  2. 注册超时事件,到当前的 epoll 中
  3. 调用 co_yield_ct, 让出该协程。

可以看到,调用 poll 函数之后,相关事件注册到了 EventLoop 中后,该协程就 yield 走了。

那么,什么时候,协程会再 resume 回来呢?
答案是:当 epoll 相关事件触发或者超时触发时,会再次 resume 该协程,处理接下来的流程。

协程 resume 之后,会接着处理 poll 之后的逻辑,也就是调用了 g_sys_read_func。这个函数就是真实的 linux 的 read 函数。

libco 使用 dlsym 函数获取了系统函数, 如下:

1
2
typedef ssize_t (*read_pfn_t)(int fildes, void *buf, size_t nbyte);
static read_pfn_t g_sys_read_func = (read_pfn_t)dlsym(RTLD_NEXT,"read");

这个逻辑就非常巧妙了:

  • 从内部来看,本质上是个异步流程,在 EventLoop 中注册相关事件,当事件触发时就执行接下来的处理函数。
  • 从外部来看,调用方使用的时候函数行为和普通的阻塞函数基本一样,无需关系底层的注册事件、yield 等过程。

这个就是 libco 的巧妙之处了,通过 hook 系统函数的方式,几乎无感知的改造了阻塞 IO 调用。

此外,libco 也 hook 了系统的 socket 函数。在 libco 实现的 socket 函数中,会将 fd 变成非阻塞的 (O_NONBLOCK)。

那么,为什么 libco 连 mysql_client 都可以一并协程化改造呢?

这是因为 mysql_client 里面的具体网络 IO 实现,也是用的 Linux 的那些系统函数 connect、read、write 这些函数。
所以,libco 只用 hook 十几个 socket 相关的 api,就可以将用到的三方库中的 IO 调用也一起协程化改造了。

read 的超时处理

libco 的 read 函数和普通的阻塞 IO 中的 read 函数,行为上稍微有一点不一样。

普通的 read 函数,如果一直没有消息可读,则会一直阻塞。
但是 libco 中的 read 函数,如果 1 秒钟之内 socket 依然不可读,则就认为 read 失败,返回 - 1。这也是 read 中注册超时事件的原因。

在 client 侧网络的 IO 调用里面,一般行为都是,write 请求,然后 read 回包。
所以一定是会引入一个超时判断,判断该次调用是否超时。
同时,还要保证要保证 read 的行为和语义,与原有的系统函数保持一致。毕竟 hook 的目标是 mysql_client 这种三方库。
所以这个超时只能做在 read 内部,把超时当成一次 read 失败处理。

这样即能保证 read 原有行为,也能保证 read 不会一直阻塞。

但这里有个问题:libco 把 read 的超时时间硬编码为 1s,那么所有被 hook 的阻塞 IO 的 read,一旦超过 1s,就会被认为失败。
但对于某些特殊场景,会存在一些耗时请求,server 端的处理时间确实有可能会超过 1s。
对于这种情况,libco 似乎也没有提供一个自定义超时时间的办法。

stCoEpoll_t 结构体分析

libco 的事件循环同时支持 epoll 和 kqueue,libco 会在每个线程维护一个 stCoEpoll_t 对象。
stCoEpoll_t 结构体中维护了事件循环需要的数据。

1
2
3
4
5
6
7
8
9
10
struct stCoEpoll_t
{
int iEpollFd;
co_epoll_res *result;

struct stTimeout_t *pTimeout;
struct stTimeoutItemLink_t *pstTimeoutList;

struct stTimeoutItemLink_t *pstActiveList;
};
  1. iEpollFd:epoll 或者 kqueue 的 fd
  2. result: 当前已触发的事件,给 epoll 或 kevent 用。如果是 epoll 的话,则是 epoll_wait 的已触发事件
  3. pTimeout:时间轮定时管理器。记录了所有的定时事件
  4. pstTimeoutList:本轮超时的事件
  5. pstActiveList: 本轮触发的事件。

此外,libco 使用了时间轮来做超时管理,关于时间轮的原理分析网上比较多,这块也不是 libco 最核心的东西,就不在本文讨论了。

协程可以嵌套创建

libco 的协程可以嵌套创建,协程内部可以创建一个新的协程。这里其实没有什么黑科技,只不过云风 coroutine 中不能实现协程嵌套创建,所以在这里单独讲下。
libco 使用了一个栈来维护协程调用过程。
我们模拟下这个调用栈的运行过程, 如下图所示:
协程调用栈

图中绿色方块代表栈顶,同时也是当前正在运行的协程。

  1. 当在主协程中 co_resume 到 A 协程时,当前运行的协程变更为 A,同时协程 A 入栈。
  2. A 协程中 co_resume 到 B 协程,当前运行的协程变更为 B,同时协程 B 入栈。
  3. 协程 B 中调用 co_yield_ct。协程 B 出栈,同时当前协程切换到协程 A。
  4. 协程 A 中调用 co_yield_ct。协程 B 出栈,同时当前协程切换到主协程。

libco 的协程调用栈维护 stCoRoutineEnv_t 结构体中,如下:

1
2
3
4
5
6
struct stCoRoutineEnv_t
{
stCoRoutine_t *pCallStack[128];
int iCallStackSize;
stCoEpoll_t *pEpoll;
};

其中 pCallStack 即是协程的调用栈,从参数可以看出,libco 只能支持 128 层协程的嵌套调用,这个深度已经足够使用了。
iCallStackSize 代表当前的调用深度。

libco 的运营经验

libco 的负责人 leiffyli 在 purecpp 大会上分享了 libco 的一些运营经验,个人觉得里面的建议具体非常高的价值,这里直接引用过来。

协程栈大小有限,接入协程的服务谨慎使用栈空间;

libco 中默认每个协程的栈大小是 128k,虽然可以自定义每个协程栈的大小,但是其大小依然是有限资源。避免在栈上分配大内存对象 (如大数组等)。

池化使用,对系统中资源使用心中有数。随手创建与释放协程不是一个好的方式,有可能系统被过多的协程拖垮;

关于这点,libco 的示例 example_echosvr.cpp 就是一个池化使用的例子。

协程不适合运行 cpu 密集型任务。对于计算较重的服务,需要分离计算线程与网络线程,避免互相影响;

这是因为计算比较耗时的任务,会严重拖慢 EventLoop 的运行过程,导致事件响应和协程调度受到了严重影响。

过载保护。对于基于事件循环的协程调度框架,建议监控完成一次事件循环的时间,若此时间过长,会导致其它协程被延迟调度,需要与上层框架配合,减少新任务的调度;

总结

libco 巧妙的利用了 hook 技术,将协程的威力发挥的更加彻底,可以改良 C++ 的 RPC 框架异步化后的回调痛苦。整个库除了基本的协程函数,又加入类 pthread 的一些辅助功能,让协程的通信更加好用。

然而遗憾的是,libco 在开源方面做得并不是很好,后续 bug 维护和功能更新都不是很活跃。

但好消息是,据 leiffyli 的分享,目前有一些 libco 有一些实验中的特性,如事件回调、类 golang 的 channel 等,目前正在内部使用。相信后期也会同步到开源社区中。

参考

cyhone wechat