天涯明月刀-无栈协程的应用
一、协程介绍
协程是一种用户级轻量线程,不仅拥有自己的寄存器上下文和栈,而且可以由用户自主调度执行,我们可以在一个线程里面轻松创建数十万个协程,就像数十万次普通函数调用一样轻松。相对于普通函数只有一次进出,协程可以有多次进出的能力,它通过将函数上下文数据(主要指寄存器和函数栈)保存起来,在特定的时刻恢复回去继续执行来实现的。在linux里,可以通过getcontext和swapcontext等接口来实现协程。
下面展示了一个典型协程应用的场景,某个功能分为3个步骤,分布在两个进程A、B上,A执行步骤1,需要向B发出消息完成步骤2,等待返回后完成步骤3:
可以看到协程拥有将一个异步过程转化为同步过程的非凡能力,大大减轻了我们的开发工作量。
协程主要分为对称式(symmetric)、非对称(asymmetric)式两种(参见boost协程库),两者的主要区别在于:
1、对称协程只提供一种传递操作,用于在协程间直接传递控制,协程每次需要挂起时需要指定一个明确切换的目标协程,也就是说控制权只能在协程间跳转。
2、非对称协程提供调用和挂起两种操作,挂起时控制权返回给调用者。被调用的协程可以看成时从属于调用者,这种协程在日常使用中更常见,上文的例子就属于非对称式。
二、协程在游戏中的应用
游戏服务端往往会按照业务特点拆分成多种角色,例如天涯明月刀服务器主要包含:world(负责玩家登录、场景管理等)、scene(场景服务器)、social(帮派)、home(家园)、auction(拍卖)等。玩家从一个场景跳转到另外一个场景,牵涉到world和scene两种角色的服务器,主要包括创建新场景、离开旧场景、进入新场景、旧场景的销毁四个异步操作,状态转换如下:
两个状态机定义代码如下:
我们可以看到场景跳转过程中的每一个异步操作都带来了一个中间状态,比如创建新场景带来了actor_in_waitlist状态、离开旧场景带来了actor_wait_leave_old状态。这么多的中间状态不仅带来了开发上的难度,而且使项目的维护成本极高,使用协程对跳转过程中的中间状态进行收敛就显得格外有必要。
将这四个异步操作归并到一个协程trans中,如下所示:
最终玩家和场景的众多状态得到了有效的收敛,结果如下:
三、天涯明月刀协程应用存在的问题
天涯明月刀服务器通过共享内存实现了一种resume的机制,将状态数据存放在共享内存中,游戏进程crash后再次重启attach原共享内存,所有状态得到恢复,玩家不受影响。resume机制使游戏容灾能力得到很大程度的提升,但同时也让上文所述的普通协程失去了作用,主要有以下两点原因:
(1)resume后栈基地址、堆基地址等发生了变化,寄存器上下文中保存的地址将会失效;
(2)协程栈内用户使用的指针也将失效;
如果我们切换时不保存函数的上下文(寄存器上下文和栈),仅记录函数的执行位置,并作为函数的一个参数传递,函数每次调用时根据这个参数直接跳到相应的位置执行,函数的位置作为一个值记录在共享内存中,这样自然的解决了普通协程失效的问题。基于这种思路,利用switch case实现了一种无栈协程的方案,成功模拟了普通协程的语义,取得了很好的应用效果。
我们首先来看一下一个叫达夫设备(Duff's Device)的代码:
voidsend_duff(char *to, char *from, int count)
{
int n = (count 7) / 8;
switch(count% 8) {
case0: do { *to = *from ;
case7: *to = *from ;
case6: *to = *from ;
case5: *to = *from ;
case4: *to = *from ;
case3: *to = *from ;
case2: *to = *from ;
case1: *to = *from ;
}while(--n > 0);
}
}
代码的结构显得非常巧妙,把一个switch语句和一个do-while语句糅合在了一起。程序的执行流程是:程序一开始顺序执行,当它执行到了switch的时候,就会根据n的值,直接跳转到 case n那里,程序继续顺序执行,当它执行到while那里时,就会判断循环条件。若为真,则while循环开始,程序跳转到do那里开始执行循环;为假,则退出循环,即程序中止。
这段代码的本意是为了提高执行的效率(一次比较能带来多个赋值),但是我们在这主要关注它的奇特语法,可以看到switch case分支可以渗透到代码中的任意地方,如果能在我们需要异步处理的地方加上case分支后离开,异步处理回来后可以利用switch直接跳转到case分支处继续往下执行,从而模拟了普通协程的yield语义。
利用 swich case对普通函数进行改造,使其成为一个协程,改造过程如下:
改造完的代码比较奇特,建议将代码实际运行一遍,可以跟踪到在step1后flag设置为1后函数退出,切换回来后跳过step1后执行step2,达到了我们需要的效果。
阅读上面的代码马上可以发现它的一个致命缺点:可读性太差、不便于理解维护。是否可以利用宏定义来简化上面的代码,其中(1)定义如下:
(2)、(3)定义如下:
经简化后协程代码如下:
CORO_YIELD_IMPL中需要传入1、2等位置参数,这种参数对协程实际使用者没有任何作用,反而增加了理解负担,可以利用编译器计数器__COUNTER__自动设置位置参数:
至此,一个通用的无栈协程基本实现,我们只要对函数增加一个位置参数并默认为0,即可将其改造为一个协程,最终一个协程如下:
只要将coro_data保存起来,随时都可以再次恢复现场,做到了使异步操作同步化,降低了异步开发的复杂性、提高了代码的可维护性。在天刀的实际应用中根据应用场景还会对上述无栈协程进一步封装,实现了一个协程管理器,实现了更多的功能,这个以后有机会还会谈到。
四、总结
上文所述的无栈协程成功的解决了普通有栈协程在resume失效的问题,同有栈协程相比,优点:
1、切换时,不涉及内核态切换,类似于普通函数调用效率更高。
2、不用考虑有栈协程中的由于栈开辟空间太小导致的栈溢出的问题。
3、代码小巧,跨平台。
缺点:
由于其不保存上下文,函数栈内的零时变量都不会保存,只能依赖传递的参数再次恢复数据。