基于Boost.Context2库的协程库轮子libto的设计与实现

  个人对协程开发还是比较感兴趣的,当然不仅仅是因为从最近的各大开源系统趋势中,发现基于协程的高性能服务端系统被各大厂所越来越重视,而是以前自己对操作系统有过一段时间的学习和专研,可是内核级的线程让我们能做的事情十分有限(不如说是水平太差了吧),反而是现在协程作为用户级别的线程,用户态程序就可以对其做很多事情,比如调度、定时、io阻塞、睡眠等操作,所以说协程库的开发具有很强的可玩性啊,当然要写一个好的协程库也是十分有挑战的。同时,多个协程存在于同一个线程中,且同一时刻只有一个协程处于运行状态,所以很多的调用必须仔细小心,任何一个协程被阻塞就会导致整个线程被阻塞。
  自己学习Boost.Context后,虽然对其基本原理略知一二,但是实际使用上却是无从下手,虽然也知道也有现成可用的Boost.Coroutine协程库,可惜和往常一样,Boost库的代码都想当的晦涩难懂。幸好Meizu的libgo开源来着,代码写的也是能给人看的,抛去Windows兼容和其他一些复杂的技法,模仿着写一个朴实易懂的协程库轮子看来也并非不无可能。当然在最终的实践中,也还是有一些同原库不一致的地方,虽然不一定会比原库的好,但是觉得更符合自己的思维习惯吧。此处还需要提到另外一个协程库libcopp,也据称是一个服务于生产环境的协程库了,虽然在github上面十分低调,但是从博客看来作者的C++和系统底层功力比较深厚。其实啊,有些人一直就很牛,而这位作者的博客历程看来其实前几年也很菜,但是近两年来成长迅速,真是让吾等可望不可及啊,虽然偶也在拼命奔跑着。

一、基本结构

  本着“开发库不应该默默地单独创建线程”的原则,libto可以以两种方式运行,如果创建Task、Timer的时候不添加额外的dispatch参数,那么协程的调度和执行、epoll事件的查询都只会在主线程中执行;如果创建Task、Timer的时候指定了dispatch参数,就会将这个协程创建在指定索引对应的线程中(如果线程不存在就创建对应的线程),每个线程单独调度自己的协程,但是协程状态阻塞事件的检查和更新全部都在主线程中操作。
  在多工作线程模式下把所有的事件轮训放到主线程中,主要的考虑的一方面是将异步的工作放在主线程中,当主线程中不添加其他复杂任务的时候可以及时的轮训各个线程的异步事件,异步信息不会受到各个工作线程的负载量而受影响;二来当工作线程没有活跃的事件时候可以将其阻塞睡眠,主线程在必要的时候可以异步唤醒对应的工作线程,借此降低活跃的线程数节约资源。由于主线程和工作线程都需要修改任务队列,这里就需要额外的同步操作以保护数据结构。

二、协程开发原语

2.1 sch_yield

  类似于yield的工作,当前工作的协程被无条件的被切换出去,然后每个线程的主协程负责调度选择下一个要执行的协程并将其切换至运行的状态。虽然不一定有主协程这个名词,我把线程运行时候默认的执行环境叫做主协程,而对应的其他使用Boost.Context创建出来的执行环境叫做工作协程,由于不像多线程中可以用内核态这个特权状态强制切换执行上下文,线程中的所有协程都是平等的,只能自己主动交出执行权。
  在协程库中,每个工作协程都是从主协程切换进去的,当yield切换出来的时候也是返回到主协程中,主协程在这个时机可以选择下个要执行的协程以实现调度。

2.2 sch_read/write/rdwr

  Linux中几乎所有的文件描述符的操作默认都是阻塞的,但是理论上在协程中的任何操作都不应当被阻塞,否则整个线程都被阻塞了,浪费的是“大家”(所有协程)的时间,所以正确的方法是对于所有可能阻塞的异步操作,都应该把当前协程切换出去,等到资源可用的时候再无障碍顺利执行,这也就是select/poll/epoll的本质思路。
  libgo做了一个小trick(当然libco也是这么个做法,或者是谁借鉴谁,亦或者是整个协程库的通用做法),把原先所有可能阻塞的C库和系统调用接口(accept、read、recv…)都同名重写打了一个hook,根据C++调用名字查找规则,编译器会优先调用相同命名空间中的函数,然后通过libgo_poll->add_into_reactor方式将文件描述符和事件添加到epoll中去,之后立即把自己切换出去,主协程的Run()会轮训检查调用epoll_wait检查底层的就绪事件,并调度的时候在适当时机唤醒这个等待的协程。
  这样固然是好,用户甚至无需修改代码就可以充分利用协程和异步的优势,只是工作量要大一些。我就用了更直接的模式,我认为每个开发者应该有足够的素质知道哪写操作会阻塞,实现调用sch_xxxx进行异步检查,然后进行无阻塞的IO操作,当然缺陷就是要显式把所有的sch_xxxx显式侵入用户代码,好的协程库肯定不能这么干滴。

1
2
3
4
5
6
libto::st_make_nonblock(sock);

sch_read(sock);
size_t count = recv (sock, buf, 512, 0);
sch_write(sock);
write(sock, msg_200.c_str(), msg_200.size());

  当然非阻塞socket读取数据什么时候结束是考验网络开发基本素质了哦。

2.3 sch_timer

  这个的实现主要得益于内核中的timerfd,把定时器也进行fd化了,这一方面是基于信号实现的定时器使用起来是极其不可靠的,同时提供fd接口就是方便统一到select/poll/epoll的框架下面。所以根据上面的思路,添加一个带回调的定时器操作,其实也就是创建一个任务,只是在回调函数前面增加一个timerfd的异步侦听操作。让人兴奋的是内核的很多东西都开始fd化了,比如eventfd、timerfd、signalfd……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if ( (timerfd = _timer_prep(msec, forever)) == -1)
return -1;

if (forever) {
Task_Ptr p_task( new Task([=] {
for(;;){
char null_read[8];
_sch_read(timerfd);
read(timerfd, null_read, 8);
func();
}
}));
addTask(p_task);
}

  上面用for(;;)循环设置了一个连续间隔定时器,如果只需要one_shot类型的定时器就把循环拿掉,同时在回调结束的时候关闭事先创建的timerfd释放资源就可以了。

2.4 sch_sleep_ms

  有了sch_timer那么sch_sleep_ms的协程睡眠实现更是不在话下了,使用相同的思路,创建一个timerfd并再次通过_sch_read等待事件就绪,然后关闭timerfd后执行流程得以继续进行,模拟了一个睡眠等待的效果,此处就不多表了。
  这些fd的异步操作不能在主线程的主协程中被调用,主协程没有Task结构,所以也无法添加到阻塞队列中去。还有就是不要依靠这些定时、睡眠操作有多么高的准确度,因为它基于主线程主协程的事件检测和工作协程的工作负载情况,所以这里十分考量协程调度算法的效率。

三、小结

  项目的主页在:


  当然,现在也仅仅是出了一个原型,后面还有很多工作需要做:资源的安全释放、协程的调度和空闲处理、任务和定时器的取消删除、各种异常情况的处理等等,离生产环境的要求还差的远。最近搜索发现一篇系统性的协程文章A Portable C++ Library for Coroutine Sequencing比较的好,后面仔细研究一下,希望能把这个小项目做地不断完善健壮起来。
  协程的工作实际就是达到基于事件的异步开发效果,但是相比传统的异步开发,因为每个协程都要一个协程栈,所以对内存的要求会多一些;还有就是,用协程开发的程序,线下虽然可以通过不链接协程库采用阻塞的方式进行调试,但是线上遇到问题,协程库程序问题的跟踪调试估计会比较棘手。
  通过main.c中的代码写了个简易的http response服务端,启动了11个线程,然后accept得到的客户端轮询分发的方式,使用-c 30的seige压了一下,发现并发量还不到20 trans/sec。发现自己写过的服务端用这种方式测试性能从来都没有过百的,而同样的方式测试Nginx也是只有几十的响应并发量,对网上那些库单机成千上万的请求量真是望洋兴叹了,是我哪里的姿势不对么?

更新 20161122:
  根据libco的思路,添加了一些系统调用的hook,同时修复了调度BUG,再通过增加siege测试的并发请求数目,吞吐量涨了一些了哦,但是这个协程库的最大并发量还没有去探测。经过大致的测试,跟预想的一样,其性能跟异步模式下的多线程还是有一些差距的(也有可能我本身的调度和切换有问题)。
libto

本文完!

参考