文/Lych
(本文假设你了解协程的概念、原理、作用与意义)
在标准C中引入协程,确实是不可能的任务,顶多做到Protothreads库的程度(呃……这也已经不是标准C了),其原理可参阅本人另一文章《最轻量级的C协程库:Protothreads》(以下简称《最》),不再赘述,这里要说的只是,在语言基础特性本身所限之下,这个库仍然存在的不足:
1.协程中所用的变量都不能是局部变量,因为yield/resume操作实质上是该函数的返回和下一次调用,局部变量会失效。
2.只能在协程入口函数中yield,不能在入口函数所调用的其他函数中yield。
其中1.的解决方法,就是协程用的一切变量都用外部变量。这点是相当的不河蟹,不过别急,解决2.的方法更加不河蟹呢。比方说协程入口函数是A,调用了另一函数B,而现在需要在B中yield,那么,函数A和B都要以协程的规范来打造:即嵌入一堆PT_*宏,不能有局部变量,而且A调用B的地方不能简简单单调用一下就完了,得是这样:循环不停地调用B并检查返回值,若B是yield出来的,则自己也得yield,等待下一次循环回来;若B是执行完毕出来的,自己才能出了循环,只有这样才能真正满足yield的语义。我想如果1.还有圣人可以忍受的话,2.估计是可忍孰不可忍了。
综上所述,要构建复杂一点的应用(比如本人心目中的基于协程和epoll的伪阻塞并发IO框架),Protothreads还是不怎么靠谱的。难道真是再一次证明了C与协程宿命般的有缘无分吗?
本着小强般打不死的精神,咱再来一次深刻的思考。导致1.和2.的本质原因,是协程没有自己的栈。只要有了栈,两个问题都迎刃而解了。标准C语言只有唯一的栈,函数怎么嵌套调用都脱不了其手掌心,而协程的世界里我们偏偏需要打破这个限制,比如协程A华丽地调用了10层嵌套的函数,协程B也是,C也是……而且我们需要在ABC……之间随意切换,而任何一个的10层栈帧都不能被干掉。这是唯一一个栈无论如何也完成不了的。这也是uC/OS-II的一个进程再怎么寒酸也不能不给分配一个栈的原因所在。
OK,现在焦点在于,如何在C中建立多个栈,并彼此切换。若能实现,再加上《最》文中所述的任意跳转功能,则实现靠谱的协程将不再存在任何障碍。显然标准C已经离靠谱太远了,那就干脆不标准到底吧——我们想要的无非是一个功能,就是对栈指针寄存器的任意读写。幸好,这对于一般编译器的扩展来说实在是小菜一碟。
理论障碍不存在了,现在来看这个演示。显然,首先我们需要将一些变量名映射到CPU寄存器:
register void *esp asm(“esp”);
register void *ebp asm(“ebp”);
这是GCC的扩展语法,不用理会太多,总之就是知道定义了esp和ebp两个变量,分别对应同名的寄存器而已。其他编译器如VC同样有此功能,只是语法不同。注意,我们用到的寄存器不止ESP一个,还有EBP。
以下结构体表示一个协程,需要保存ESP和EBP寄存器,并提供一个堆栈。这个堆栈的大小可有讲究,因为这是每个协程都有的开销,而协程往往一下子建立成千上万个。所以既要控制好开销,又不要让栈太小以至于溢出。
typedef struct {
void *esp; // 保存自己的esp内容
void *ebp; // 保存自己的ebp内容
void *stack[0]; // 协程的栈
} Coro;
或许一点让人意外,协程结构体中居然没有保存当前执行点的指针变量,就是《最》文中那个接收&&label的变量。——这也是trick之一,这次我们要采用和Protothreads完全不同的方式来实现任意跳转,待后述。
每个协程在被创建的时候,都给分配一个对应的Coro结构体。唯一例外是那个没爹没娘、天生石头缝里蹦出来的主协程,我们还得特殊照顾一下,先给它老人家建立一个结构体。由于主协程已经有C语言本身的栈了,我们并不需要给其分配任何栈空间:
Coro *main_coro = (Coro *)malloc(sizeof(Coro));
另外,任意时刻都只有一个协程为当前协程,这需要一种指示:
Coro *cur_coro = main_coro; // 最初的当前协程自然是主协程
好了,关键的东西来了,注意,注意看这个函数,这是一切的核心:
void coro_switch(Coro *target) {
cur_coro->esp = esp; // 将栈相关寄存器保存进当前协程自己的Coro结构体中
cur_coro->ebp = ebp;
cur_coro = target; // 使cur_coro指向新协程
esp = cur_coro->esp; // 将切换目标协程所保存的寄存器值装载到系统寄存器中
ebp = cur_coro->ebp;
}
就这些,就能完成协程的切换,可能简单得出乎意料,至少,我们没看到任何goto或跳转之类的东西,它怎么就能把执行点换到另一个地方呢?
我们来点山寨风格图示,表示一下各个协程的堆栈,立刻就能弄懂这个问题。为了简化思路,我们暂且假设系统很单纯,只用一个esp来指示栈顶就足够了,且不管ebp的作用,一样能正确演示其原理:
协程1:【函数1的栈帧】【函数2的栈帧】【coro_switch的栈帧】<-赋值前,ESP指向这里
协程2:【函数3的栈帧】【函数4的栈帧】【coro_switch的栈帧】<-赋值后,ESP指向这里
协程3:【函数5的栈帧】【函数6的栈帧】【coro_switch的栈帧】
现象很明显:每个非活动协程的栈的顶端都是一个coro_switch函数调用。为啥一定是?——因为它不调这个函数也不可能发生协程切换,所以只要自己交出运行权的函数,一定会在栈顶留下一个coro_switch的栈帧。而这一个个的coro_switch栈帧,其中所含的返回地址显然是不同的,分别指向函数2、函数4和函数6,因为coro_switch是分别在以上3个地方被调用的。答案显而易见了,coro_switch函数就像一个跳板,因为在内部修改了ESP,就是说如果协程1调用了coro_switch,那返回的时候,根据esp所查到的函数返回值已经是协程2的了,自然会返回到函数4 中。一个随机跳转就这样偷梁换柱地完成于无形之中。coro_switch在执行过程中更换了自己的栈帧,却不会引起混乱,因为这些栈帧都是如假包换的、符合自己格式的栈帧,唯一不同的是局部数据的值变了。具体地说,变的只有两个:返回地址和形参target的值。前者正是我们的阴谋想要的,而后者则需要注意,不能弄混语句的顺序,即篡改寄存器之后不能再使用target形参(准确地说,target是变成了旧target协程上次交权的时候交给的目标 ——绕口ing)。
最后来简单解释下为何还要操作ebp。这纯粹是个底层细节,就是说x86的ebp寄存器是用来在函数的一开始保存下 esp的,这样在函数中间你尽可能发挥想象力构建程序,让esp参与逻辑,怎么折腾都行,最后要返回啦,来,把ebp的值恢复给esp,一切重归于旧,这才能用esp在栈上正确地找到返回地址。另一方面,由于esp可能变化,所以改用恒定不变的ebp来定位局部变量也更加方便。据实测,GCC根据编译优化选项不同,如果函数足够简单没有改变esp(coro_switch正是,但谁能保证以后不给这个函数扩展功能?),则返回时将ebp恢复到esp这一步可能省略。这时只篡改esp就已经完全达到目的了,但如果编译器没省这一步,篡改esp就会被原来的ebp给洗回来,导致还是返回到原来协程的上层函数,而不是新协程的。所以为了应对所有情况,就把ebp也一并篡改了。
至此,协程切换貌似完美解决了,只剩下一个自然会想到的问题,就是上述一切废话的理论前提:非活动协程栈顶一定是个coro_switch栈帧。那刚建立的新协程算不算非活动?如果算,那又没调过coro_switch,栈顶哪来的这个帧?
这个问题确实值得思考,而答案其实也不止一种,比如在Coro结构体中建立一个标志成员来指出一个协程是新建还是挂起,然后在coro_switch中判断,分别处理。这最大的缺点是使coro_switch函数变得复杂,引入了更多的编译器相关的不确定性,最终对可移植性产生不利影响。几经考虑,最终还是用另一个办法:在每个新建的协程的空栈之中,伪造一个coro_switch栈帧,让一个协程一建立,就装成一种“挂起”的样子。做到这点,需要先来分析一下coro_switch的栈帧的结构。我们知道C语言的一个完整的栈帧是这个样子的:
【形参1~n】【返回地址】【保存的上一个EBP】<-EBP指向这里【局部变量1~n】<-ESP指向这里
其中,形参1~n是由主调函数通过压栈产生,其顺序与调用约定有关,cdecl和stdcall是按照源代码书写的参数顺序从右到左,pascal则是从左到右。接下来的返回地址是由call指令产生的。从此处开始,程序执行点就跳到被调函数的第一条指令去了,在被调函数中再进行接下来的栈帧建立工作:首先要用EBP来备份ESP的值以在返回时恢复,但上层函数也同样这么用EBP,所以同时必须先将原来的EBP值压栈保存。备份后,ESP就可以随便折腾了,第一个要折腾的就是给局部变量开辟空间,一般通过给ESP减去(x86的栈向下增长)一个总量来一次性完成,而局部变量的顺序是无关紧要的,根据优化甚至可以重叠。至此,栈帧建立才全部完成。
OK,现在照猫画虎来伪造栈帧。具体到coro_switch函数,形参只有一个,不需要考虑顺序……实际上再想想,需要形参么?其实只要伪造半个栈帧就足够了,从返回地址那里开始。还有,在某种预谋下,这个函数一个局部变量也没有。于是工作简化为这样:
【返回地址】【保存的上一个EBP】<-EBP和ESP都指向这里
貌似简单,可现在问题来了:返回地址怎么填?——也就是说,谁调用了coro_switch?当然,没人调用它,从来没有过,我们这是在凭空无中生有。那让 coro_switch往哪儿返回啊?——我们刚才要怎么来着,把一个新建的协程伪装成一上来就交权挂起的。即然这样,那恢复的时候应该从恢复到哪?毫无疑问是入口函数的开头。ok,我们要的就是这个冒牌coro_switch返回到协程入口函数的开头,即使那里没有对应的call指令,我们也要做得跟真事似的,好像之前有个call在那。那啥也别说了,返回地址就填协程入口函数的入口地址吧(绕口令?),正好在C中获得这个地址一点都不难,那叫做函数指针。——我得承认在邪有暗香盈袖恶的路上是越走越远了,至此,C语言的法律已然面目全非。
#define STACKSIZE 1024
typedef void (*CoroEntry)(); // 协程入口函数
Coro *coro_spawn(CoroEntry ent) {
Coro *res = (Coro *)malloc(sizeof(Coro) + STACKSIZE * sizeof(void *));
res->stack[STACKSIZE - 1] = coro_fini; // 此行以后另有用途,暂且无视之
res->stack[STACKSIZE - 2] = ent;
res->esp = res->stack + STACKSIZE - 3;
res->ebp = res->esp;
return res;
}
这是个最简版的创建协程的函数。再简单不过,只是建立了一个向下增长的堆栈,并在其中置入了几个元素。按说完成后,堆栈应该是这个样子的(假设右边为低地址方向):
【coro_fini】【ent】【未知内容】<-ESP和EBP都指向这里
先不管coro_fini为何物,我们现在关注的是:按照阴谋,返回地址由ent填充了,使得再次切换到该协程的时候,就会“返回”进ent函数中。而 ent后边本来应该是保存的EBP来着,为啥是未知内容?——理由也很简单,因为入口函数的执行完成标志着整个协程的结束,不需要返回了,它根本没有上层函数,所以用不着为上层函数维护一个旧EBP。但编译器生成的指令序列,在函数返回之前,仍然会执行从栈中弹出内容到EBP的过程,所以这个空当还是要留出来的。弹出后EBP会变成未知内容,但无所谓。
新建协程后,如果用coro_switch切换过去,则该函数返回之时,就是进入协程入口函数之日。以后的步骤就中规中矩了,入口函数的代码会自己按部就班地再次保存EBP、备份ESP,建立局部变量,执行。即栈是这个样子:
【coro_fini】【保存的EBP,即未知内容】<-EBP指向这里【入口函数的局部变量1~n】<-ESP指向这里
当入口函数执行完毕后,按部就班恢复ESP,弹出未知内容到EBP,然后按照返回地址来返回。慢着!都说了入口函数不能返回,这个返回怎么处理?——协程的结束,意味着切换到另外一个协程,并永远不再切换回来,也就是说协程入口函数必须以coro_switch结尾。可是强求用户自己挨个手写这些调用也很不厚道,于是故伎重施,让入口函数也有“返回到别的函数头部”的本领,让所有结束的协程统一返回到一个函数里,在那里统一coro_switch之。这就是 coro_fini的由来!不用说,这也是个函数指针了。
来吧,来看那个迟到的coro_fini函数:
void coro_fini() {
Coro *target = 从哪个犄角旮旯随便拽出一个挂起的协程来;
coro_switch(target);
}
啊,气着了吧,那行中文算什么?——不是不想把程序写全,而是这里边太有玄机了,全写出来就是另一个话题了,称为:协程的调度策略。简单地说,那行中文背后的东西要是完整实现,代码还要增加几倍,而且写法也不限一种,可以自由发挥。其要点在于一个协程交权的时候(结束也是一种特殊的交权)决定谁来接棒,可以每次都由交权者显式任意指定(对称协程),可以一些时候可以显式指定,另一些时候必须还给当初交给自己的人(非对称协程/半协程),也可以所有协程顺序轮转,或者优先级抢占,还可以跟协程间I/O挂钩等(跟OS进程调度本是一家),这样Coro结构体为了保存调度相关的信息,也要有相应的扩展,也可以根据具体调度方式编写出不同的协程操作原语:resume几乎等同于coro_switch,yield和coro_fini没有本质区别,协程间I/O、消息队列和带参数的resume/yield可以由coro_switch和提另的公共变量轻易组成。
不过怎样都好,也都是C世界内部中规中矩的事了,底层的冒险到此为止,我们篡改后的C世界已经具备了原生支持协程的能力,本文只作为一种原理演示。
顺便提下,实现C协程的方法也不止这一种。ConcurrentLua/Coco是另一个成品,而且用了多种方法,从嵌入汇编到context相关API到 Windows Fiber API。这都是为了可移植性,每种方法对应一种场合。本文所述的方法也尽量减少对编译器扩展的依赖,比如放弃了GCC特有的&&label扩展,而是使用更加通用的寄存器变量,也同样具备还算可以的移植性。这同时也要求coro_switch函数决不能被显式或隐式的内联,否则后果不堪设想。目前,此方案于跨编译器的限制基本总结于此,或许还会遇到各种各样新的问题,但至少这个本质问题得到了解决,我们可以无庸质疑这一点:C和协程在一起,不都是苦涩,也有快乐。