写在前面

国庆假期过半,phxrpc的代码阅读大概要小小告一段落啦。因为这两天还要读工作相关的代码,以及最后几天还有一次短途旅行。

所以非阻塞TCP流可能要留到下一篇了,这一篇只涉及非阻塞TCP流使用到的ucontext协程库,及其使用的一些框架代码。

161013更新:这点破东西写到今天才写完,GG。

什么是ucontext

"Subroutines are special cases of ... coroutines." –Donald Knuth.

首先我们来看一下,什么是线程。线程是进程内一条执行流的状态,包含了硬件状态(硬件计数器,寄存器,条件码等)和堆栈中的数据。

线程通常只有一个入口和一个出口。当线程返回时,线程的生命周期也结束了。所以,通常线程的执行由内核调度。

协程的定义与线程类似,也是硬件状态+堆栈的状态组合。但是与线程不同的是,协程可以有多个出口。可以通过yield来暂停自己,调用其它协程。再次启动时,会从上次挂起的地方继续运行。

phxrpc中的ucontext

phxrpc提供了system和boost两种ucontext的实现,所以提供了一个uthread_context_base的基类。其实在这里我是有一点怀疑虚函数的性能的,不过好在协程的切换以及网络IO操作还是比较耗性能的,所以虚函数多出来的几次内存寻址也并非不能接受。

在这篇文章中,我们只看uthread_context_system这个使用系统ucontext库的实现。

协程上下文:UThreadContext

这个类是协程上下文的虚基类,所以代码很少。并且也没有什么好解释的。

void Make(UThreadFunc_t func, void * args)函数是makecontext()的封装。

bool Resume()bool Yield()swapcontext的封装。

个人感觉这个类拆分成一个工厂类(传入一个Create仿函数)和一个上下文基类会更清楚一点。

使用系统ucontext库的协程上下文:UThreadContextSystem

在phxrpc的文档中,说明使用系统原生的ucontext库的性能要差于boost版本的。但是从数据上来看微乎其微,所以我们先从这个版本看起,力求举一反三。

UThreadContextSystem在构造函数中传入了协程栈大小,协程要执行的函数(及参数),协程执行后的回调,以及调试用的need_stack_protect flag。

每一个上下文对象都维护了两个context,main_context用来表示主程序执行流的上下文,而context_则用来表示协程的上下文。

main_contextstatic thread_local修饰的,也就意味着这个静态变量在每一个线程中有且只有一个。执行在同一个线程上的不同协程,都会切换/被切换到这个上下文上。

Resume()函数中,我们激活协程上下文,并将主程序执行流的上下文保存在main_context上。

Yield()函数中,我们将主程序执行流的上下文激活,将协程上下文保存回context_中。

这里的UThreadFuncWrapper()值得我们特别关注。这个函数包装了我们的工作函数uc->func_,并且将this指针传进去。

传入指针时,这里使用了一个技巧。首先我们将指针强转为uintptr_t,这个是编译器内置的一个typeof,意在将指针类型无损失的转为整型。之后,将一个uintptr_t拆为两个uint32_t。最后,在wrapper函数中,将这两个uint32_t拼回成一个指针类型。

初看这段代码,我们就有这样的疑问:“这特么不是有病么?” 但是,折腾自然有折腾的道理。

When this context is later activated (using setcontext(3) or swapcontext()) the function func is called, and passed the series of integer (int) arguments that follow argc; the caller must specify the number of these arguments in argc.

从官方的文档中我们可以看到,用在setcontext中的函数,只支持int类型的参数,并且需要我们显式声明参数的数目。这里一定要小心,因为变长参数列表并不能有很强的编译期检查支持,搞出UB或core dump来就非常难查。

ucontext中使用的栈内存:UThreadStackMemory

ucontext协程是在同一个线程执行多个上下文,所以就要配备多个栈空间。这里的栈大小我们是可以手动管理的,所以我们可以根据程序的实际情况来调整栈大小,以节省内存使用。

内存的申请并不是使用malloc或者new这种比较高层次的内存操作函数,而是使用的mmap。这样的好处是我们可以使用参数控制申请出的内存的权限。

栈内存有两种模式,保护和非保护。保护模式用于调试,会在正常栈内存的两端,各申请一个页大小的保护内存。正常栈内存的权限是读写:PROT_READ | PROT_WRITE,而保护内存的权限是禁止访问:PROT_NONE,也就是说,任何试图访问这块内存的请求,都会触发段错误。

在非保护的运行模式下,栈内存还会使用MAP_ANONYMOUS | MAP_PRIVATE还进行保护。MAP_ANONYMOUS表明这段内存是匿名的,即不占用fd,也无需进行写回操作,使mmap的行为类似于malloc。MAP_PRIVATE意为这段内存不会被其它进程访问,可以使用私有的写时复制映射。(虽然没找到相关资料,但是感觉这两个配置牺牲了可调试性来获取更好的性能)

ucontext的运行时:UThreadRuntime

这个类其实很简单,但是由于代码的命名过于意识流,所以很容易把人绕晕。

一个UThreadRuntime代表着一个线程中运行着的N个ucontext上下文。上下文信息保存在std::vector<ContextSlot> context_list_中。

slot是可以复用的,first_done_item_记录着已执行完的context的下标,然后slot中的next_done_item记录着下一个执行完的context的下标。简而言之,这就是类似一个“脏池”的设计。不过这个命名啊,一点都不赛艇。

剩下的代码基本就是UThreadContext的无脑封装了。需要哪个协程开始工作就Resume哪个协程,需要暂停就调用Yield。结束后,调用回调函数,把运行完的协程往脏池一扔,完活。

写在最后

上面我们分析了phxrpc对ucontext协程库的封装,下一篇,我们就来正式看一看ucontext是如何与IO多路复用的技术连接在一起的。

最后上几张皂片:


Comments

comments powered by Disqus