什么是coroutine
什么是coroutine?接触过的脑子里肯定会蹦出来很多词:async-await,generator,channel,yield,高并发,甚至goroutine。其实,这些都是coroutine的外部表象,coroutine的本质是什么?上古时期的计算机科学家们早就给出了概念,coroutine就是可以中断并恢复执行的subroutine,什么是subroutine?就是大家熟知的函数。
大家先不要在脑子里思考这个中断和恢复执行具体要怎么做,而是先建立一个概念模型,一个函数除了调用结束后返回caller,还可以在调用中途返回caller,并可以由caller在中断的地方继续执行,这就是coroutine了。所以coroutine也被叫做resumable function,可继续函数,并且从定义上看,coroutine是subroutine的超集,也就是所有的function也都是coroutine,只不过他们没有执行中断和继续这两个操作。
所以看到这里,先不要想太多,先明确了coroutine是可以中断并恢复的函数就好了,我们然后来明确几个概念,为了后面提到的时候不会混乱。
coroutine的暂停/中断,也叫suspend,是coroutine暂停执行,并将控制流返回给调用者caller的过程。
coroutine的恢复/继续,也叫resume,是caller恢复coroutine执行的过程。
当然coroutine还包含了普通函数也有的调用(invoke)和返回(return)两个操作。
中断和恢复的实现
函数要中断并从中断处恢复,那么从常识上考虑,就像线程切换cpu要保存寄存器状态一样,函数中断前也要保存当前的状态到一个持久的位置,然后中断后这部分栈空间才能放心的交给caller去继续用,不然恢复的时候现场都被破坏了就不对了。
保存当前状态有两种常见的实现,一种是说,既然函数中断之后栈空间不能被别人改写,寄存器的值要保存下来,那不如让这个函数使用独立的栈空间好了,这种实现就是有栈协程(stackful-coroutine)。函数调用前,保存调用者的所有寄存器,然后malloc一块单独的空间并把栈指针指过去,然后正常调用函数,被调用的函数自然就会在这块独立的栈空间上操作。中断前,把所有寄存器都存到栈上,然后把栈指针指回调用者的栈空间,并且恢复调用者的寄存器,这就实现了有栈协程的暂停操作。
根据我的描述也能看出,有栈协程的实现需要底层操作,比如修改栈指针,保存寄存器等等,而且有栈协程需要大块的栈空间分配,不管什么样的函数,每次调用都要malloc出来几kb的空间,并且保存寄存器和恢复也需要一定的性能损失,现代处理器需要保存的寄存器往往有上百字节,还是比较大的。
当然也不是说有栈协程不好,和后面要说的无栈协程相比,有栈协程不需要太多编译器支持,还是很棒棒的。著名的有栈协程实现包括Windows的Fiber,ucontext,fcontext,boost fiber等。
用Windows的Fiber举例,调用起来是这样的
void* main_func;
void coro() {
int i = 0;
i++;
SwitchToFiber(main_func); //suspend回main
i++;
}
int main() {
main_func = ConvertThreadToFiber(xxx);
void* coro = CreateFiber(coro, xxx);
SwitchToFiber(coro); //调用coro
//coro suspend
SwitchToFiber(coro); //让他resume
DeleteFiber(coro);
}
除了有栈协程,另一个常见的实现就是无栈协程(stackless coroutine),这是怎么实现的呢?无栈协程需要编译器的转换工作,对于一个简单的协程(伪代码)
void fun()
{
int i = 0;
i++;
SUSPEND();
i++;
return;
}
编译器会将他转写成一个对象(或类似物),以suspend的地方为分界,将函数拆成几部分,每个部分为一个单独的函数,然后将局部变量都做成类成员,这样每个被拆出来的子过程都可以访问这个成为了成员变量的局部变量,最后,保存一个状态变量,生成一个MoveNext函数(名字只是为了表述方便),每次调用MoveNext,根据状态变量的值,来执行前面被拆出来的不同的函数,比如上面的伪代码,会被编译器转写成以下的样子(命名只是为了表述方便)
struct fun_coroutine {
int i;
int __state = 0;
void MoveNext() {
switch(__state) {
case 0:
return __part0();
case 1:
return __part1();
}
}
void __part0() {
i = 0;
i++;
__state = 1;
}
void __part1() {
i++;
}
};
调用者对fun的调用也会被转写成构造fun_coroutine,然后调用MoveNext成员函数,此时执行的是__part0,__part0的返回就是函数第一次suspend,调用者可以选择第二次调用MoveNext,这时被执行的就是__part1函数了。
由此看来,无栈协程的调用消耗的空间就是局部变量占用的全部空间,相比有栈协程每次分配几KB小很多,而且,无栈协程对象直接构造在调用者的栈上,意味着其中的成员(局部变量)也都在调用者的栈上,相比有栈协程把局部变量放在新开的空间上,CPU cache局部性更好,同时无栈协程的中断和函数返回几乎没有区别,而有栈携程的中断需要保存上百字节的寄存器,并且,无栈携程需要编译器参与,那么编译器完全可以进行类似函数内联,常量折叠之类的操作,将协程的调用尽可能优化到没有。综合性能会比有栈协程更好。
吹了这么多,有栈协程好是好,可是需要编译器支持,而对于C++这种巨复杂的语言,你加点什么东西一要提防着不要影响其它feature和已有代码,二要地方这些东西能不能和已有feature结合,不能冲突,三还要不能限定实现(比如C#的yield只能返回IEnumerator<T>),所以牙膏挤到了C++20甚至23,还没有正式确立加入语言。
后话
我在说无栈协程suspend的性能和函数返回没区别的地方,肯定有人会反驳我说fun_coroutine会被new出来啊,异常之类的东西会增大overhead啊,实际上这些东西普通函数也有,你new了一个对象然后调用成员函数,和调用全局函数,区别大吗,我感觉是不大。此外有人会怀疑转写成对象+状态会让编译器没法优化,实际上,llvm是直接支持coroutine的,如果你的语言编译到llvm,你的前端可以不把他转换成对象给llvm看,而是直接用llvm的协程原语,剩下的丢给llvm去优化。