3

微信 libco 协程库源码分析

 2 years ago
source link: https://www.cyhone.com/articles/analysis-of-libco/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 函数为例,看下都做了什么:

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 函数获取了系统函数, 如下:

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 调用也一起协程化改造了。

默认情况下,阻塞IO不会超时。但可以通过设置setsockopt设置socket的SO_RCVTIMEO和SO_SNDTIMEO来调整超时时间。

但在libco里面,默认的读写超时时间为1s。例如对于read函数来说:

unsigned int timeout =
	(lp->read_timeout.tv_sec * 1000) + (lp->read_timeout.tv_usec / 1000);

struct pollfd pf = {0};
pf.fd = fd;
pf.events = (POLLIN | POLLERR | POLLHUP);
poll(&pf, 1, timeout);

ssize_t ret = g_sys_read_func(fd, (char*)buf, nbyte);

这里的lp->read_timeout在socket函数中被设置为默认的1s。

同时,libco也hook了setsockopt函数,当设置SO_RCVTIMEO或SO_SNDTIMEO,会调整read_timeout和write_timeout的超时时间,使其与阻塞IO的行为保持一致。

if (SO_RCVTIMEO == option_name) {
	memcpy(&lp->read_timeout, val, sizeof(*val));
} else if (SO_SNDTIMEO == option_name) {
	memcpy(&lp->write_timeout, val, sizeof(*val));
}

以上是libco对于读写超时的操作。但是对于阻塞IO的connect来说,libco中将其设置为了75s。75s是内核默认的connect超时时间。如下:

int connect(int fd, const struct sockaddr* address, socklen_t address_len) {
  ...

  for (int i = 0; i < 3; i++)  // 25s * 3 = 75s
  {
    memset(&pf, 0, sizeof(pf));
    pf.fd = fd;
    pf.events = (POLLOUT | POLLERR | POLLHUP);

    pollret = poll(&pf, 1, 25000);

    if (1 == pollret) {
      break;
    }
  }
  ...
}

stCoEpoll_t 结构体分析

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

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 结构体中,如下:

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 等,目前正在内部使用。相信后期也会同步到开源社区中。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK