如今虽不敢说协程已经是红的发紫,但确实是越来越受到了大家的重视。Golang中的已经是只有goroutine,以至于很多go程序员是只知有协程,不知有线程了。就连C++也在最新的C++20中原生支持协程。更不用说很多活跃的语言如python,java等,也都是支持协程的。尽管这些协程可能名称不同,甚至用法也不同,但它们都可以被划分为两大类,一类是有(stackful) 协程,例如 goroutine,libco;一类是无栈 (stackless) 协程,例如C++的协程。
这里我们想说的一点是所谓的有栈,无栈并不是说这个协程运行的时候有没有栈,而是说协程之间是否存在调用栈(callbackStack)。其实仔细一想即可,但凡是个正在运行的程序,不管你是协程也好,线程也好,怎么可能在运行的时候不使用栈空间呢,调用参数往哪搁,局部变量往哪搁。我们知道基本所有的主流语言在调用另外一个函数的时候都存在一个调用栈,我们来解释一下调用栈这个词:
这幅图是有两个栈帧的调用栈,我在这篇文章中对栈帧下过定义,即:函数的栈帧是指esp和ebp之间的一块地址。拿上图来说ebp存储着Frame Pointer指向的地址,Return Address当然就是我们在执行完最新的栈帧以后下一步要执行的指令地址。esp当然就是当前指向栈顶的指针了。
很多地方又把协程称为subroutine,subroutine是什么,就是函数。上古时期的计算机科学家们早就给出了概念,coroutine就是可以中断并恢复执行的subroutine,从这个角度来看协程拥有调用栈并不是一个奇怪的事情。我们再来思考coroutine与subroutinue相比有什么区别,你会发现区别仅有一个,就是coroutinue可以中断并恢复,对应的操作就是yield/resume,这样看来subroutinue不过是coroutinue的一个子集罢了。也就是说把协程当做一个特殊的函数调用,有栈协程就是我们理想中协程该有的模样。
既然把其当做一个特殊的函数调用,对我们来说最严峻的挑战就是如何像切换函数一样去切换协程,难点在于除了像函数一样切换出去,还要在某种条件满足的时候切换回来,我们的做法可以是在协程内部存储自身的上下文,并在需要切换的时候把上下文切换就可以了,我们知道上下文其实本质上就是寄存器,所以保存上下文实际上就是把寄存器的值保存下来,有两种方法,一种是使用汇编,libco就使用了这种方法。还有一种是使用ucontext.h,这个封装好的库也可以帮我们完成相关工作。
汇编的话我们来看一看libco中对于32位机器的上下文切换操作是如何完成的:
// 获取第一个参数
movl 4(%esp), %eax
// 参数的类型我们暂且理解为一个拥有八个指针的数组,即regs
| regs[7] |
| regs[6] |
| regs[5] |
| regs[4] |
| regs[3] |
| regs[2] |
| regs[1] |
| regs[0] |
-------------- < ---EAX
movl %esp, 28(%eax)
movl %ebp, 24(%eax)
movl %esi, 20(%eax)
movl %edi, 16(%eax)
movl %edx, 12(%eax)
movl %ecx, 8(%eax)
movl %ebx, 4(%eax)
// 想想看,这里eax加偏移不就是对应了regs中的值吗?这样就把所有寄存器中的值保存在了参数中
// ESP偏移八位就是第二个参数的偏移了,这样我们就可以把第二个参数regs中的上下文切换到寄存器中了
movl 8(%esp), %eax
movl 4(%eax), %ebx
movl 8(%eax), %ecx
movl 12(%eax), %edx
movl 16(%eax), %edi
movl 20(%eax), %esi
movl 24(%eax), %ebp
movl 28(%eax), %esp
ret
// 这样我们就完成了一次协程的切换
我们可以看到其实就是参数中传入两个协程的上下文结构,然后第一个参数执行保存上下文,然后把第二个参数的上下文存入寄存器,这样就执行了两个协程的切换。
当然我们上面提到了调用栈,那么既然有调用栈,那么肯定有一个执行的顺序,即一定要把栈顶的协程全部运行完才可以运行下一层的协程,这样说可能比较抽象,我们举一个简单的例子:
主协程A中执行协程B,此时调用栈是在[A,B]和[A]之间切换,因为B会主动让出执行权,然后调用栈上此时就只有一个A了
B协程中执行C,D协程,此时调用栈是在[A,B,C],[A,B],[A,B,D]之间转换的,
这样看来我们总是只能在调用栈顶的协程运行完以后才能去执行更低一层的协程,当然,这也是典型的非对称协程,即协程之间有明显的调用关系。
当然在我的描述中也可以看出有栈协程涉及到对于寄存器的保存和修改,也涉及到对每一个协程栈(实际运行的栈)的分配。对于寄存器来说,现代寄存器基本都是上百个字节的数据,还有每一个协程的栈,如果选择了共享栈,又涉及到对栈上数据的拷贝,显然在效率上来说相比无栈协程的确是有一些损失的。
那么所谓的无栈协程是什么呢?其实无栈协程的本质就是一个状态机(state machine),它可以理解为在另一个角度去看问题,即同一协程协程的切换本质不过是指令指针寄存器的改变。这里推荐一篇文章,其内容是用C语言实现一个协程,其实就是一个无栈协程的实现。
我们来看一个使用libco的协程的例子,当然libco是一个有栈协程:
void* test(void* para){
co_enable_hook_sys();
int i = 0;
poll(0, 0, 0. 1000); // 协程切换执行权,1000ms后返回
i++;
poll(0, 0, 0. 1000); // 协程切换执行权,1000ms后返回
i--;
return 0;
}
int main(){
stCoRoutine_t* routine;
co_create(&routine, NULL, test, 0);// 创建一个协程
co_resume(routine);
co_eventloop( co_get_epoll_ct(),0,0 );
return 0;
}
这段代码实际的意义就是主协程跑一个协程去执行test函数,在test中我们需要两次从协程中切换出去,这里对应了两个poll操作(hook机制,有兴趣的朋友可以点击这里),hook后的poll所做的事情就是把当前协程的CPU执行权切换到调用栈的上一层,并在超时或注册的fd就绪时返回(当然样例这里就只是超时了)。那么无栈协程跑相同的代码是怎么样的呢?其实就是翻译成类似于以下代码:
struct test_coroutine {
int i;
int __state = 0;
void MoveNext() {
switch(__state) {
case 0:
return frist();
case 1:
return second();
case 2:
return third();
}
}
void frist() {
i = 0;
__state = 1;
}
void second() {
i++;
_state = 2;
}
void third() {
i--;
}
};
我们可以看到相比与有栈协程中的test函数,这里把整个协程抽象成一个类,以原本需要执行切换的语句处为界限,把函数划分为几个部分,并在某一个部分执行完以后进行状态转移,在下一次调用此函数的时候就会执行下一部分,这样的话我们就完全没有必要像有栈协程那样显式的执行上下文切换了,我们只需要一个简易的调度器来调度这些函数即可。
从执行时栈的角度来看,其实所有的协程共用的都是一个栈,即系统栈,也就也不必我们自行去给协程分配栈,因为是函数调用,我们当然也不必去显示的保存寄存器的值,而且相比有栈协程把局部变量放在新开的空间上,无栈协程直接使用系统栈使得CPU cache局部性更好,同时也使得无栈协程的中断和函数返回几乎没有区别,这样也可以凸显出无栈协程的高效。
其实对于“对称”这个名词,阐述的实际是协程之间的关系,用大白话来说就是对称协程就是说协程之间人人平等,没有谁调用谁一说,大家都是一样的,而非对称协程就是协程之间存在明显的调用关系。
简单来说就是这样:
其实两者的实现我觉得其实差异不大,非对称协程其实就是拥有调用栈,而非对称协程则是大家都平等,不需要调用栈,只需要一个数据结构存储所有未执行完的协程即可。至于哪种更优?我觉得分情况,如果你使用协程的目的是为了优化一些IO密集型应用,那么协程切换出去的时候就是它等待事件到来的时候,此时你就算切换过去也没有什么意义,还不如等到事件到来的时候自动切换回去。
其实上面说的是有一些问题,因为这个执行权的切换实际上是(调用者–被调用者)之间的切换,对称就是它们之间都是平等的,就是假如A协程执行了B,C协程,那么B协程可以切换回A,也可以切换回C。而非对称只能是B切换回A,A切换回C,C再切换回A,以此类推。
这样看起来显然非对称协程相比之下更为符合我们的认知,因为对称协程目前我不知道如何选择一个合适的协程来获得CPU执行权,正如上面所说,此协程可能正在等待事件。当然如果调度算法足够优秀的话,对称协程也是可取的。
全部0条评论
快来发表一下你的评论吧 !