有栈协程与无栈协程

浅谈有栈协程与无栈协程 - 知乎 (zhihu.com)

相关

Java 虚拟线程和 Kotlin 协程走的两个方向。Java 是有栈协程,协程上下文保存在与执行处独立的栈上,所以异步函数可以直接像正常函数那样写,程序执行到阻塞部分时 JVM 内部自动挂起;Kotlin 是无栈协程,因为干预不了 JVM,所以协程上下文必须保存在堆上,同时得显式声明函数是可挂起的,否则不知道什么函数支持挂起(suspend 在转译为字节码时会添加 Continuation 参数)。

干预不了 JVM,既是 Kotlin 协程的优势,也是它的劣势。因为不需要 JVM 出手,这个特性可以在旧版本 JVM 上实现,也可以在不支持有栈协程的平台上实现,比如 WASM。又因为必须标记可挂起函数,Kotlin 协程也暴露出典型的函数染色问题。

如今虽不敢说协程已经是红的发紫,但确实是越来越受到了大家的重视。Golang中的已经是只有goroutine,以至于很多go程序员是只知有协程,不知有线程了。就连C++也在最新的C++20中原生支持协程。更不用说很多活跃的语言如python,java等,也都是支持协程的。尽管这些协程可能名称不同,甚至用法也不同,但它们都可以被划分为两大类,一类是有(stackful) 协程,例如 goroutine,libco;一类是无栈 (stackless) 协程,例如C++的协程。

这里我们想说的一点是所谓的有栈,无栈并不是说这个协程运行的时候有没有栈,而是说协程之间是否存在调用栈(callbackStack)。其实仔细一想即可,但凡是个正在运行的程序,不管你是协程也好,线程也好,怎么可能在运行的时候不使用栈空间呢,调用参数往哪搁,局部变量往哪搁。

我们知道的所有主流语言在调用另外一个函数的时候都存在一个调用栈,我们来解释一下调用栈这个词。

函数的栈帧指esp和ebp之间的一块地址。拿图上来说ebp存储着Frame Pointer指向的地址,Return Address当然就是我们执行完最新的栈帧以后下一步要执行的指令地址。esp当然就是当前指向栈顶的指针了。

有栈协程

函数运行在调用栈上,把函数作为一个协程,那么这个协程的上下文就是这个函数及其嵌套函数的(连续的)栈帧存储的值,以及此时寄存器存储的值。如果我们调度协程,也就是保存当前正在运行的协程上下文,然后回复下个将要运行的协程上下文。这样我们就轻松完成了协程的调度。并且因为保存的上下文和普通函数执行的上下文是一样的,所有协程可以在任意嵌套函数中挂起(无协程不行)。

有栈协程的优点在易用性上,通常只需要调用对应的方法,就可以切换上下文挂起协程。在有栈协程调度时,需要频繁切换上下文,开销较大。单从实现上看,有栈协程更接近于内核级线程,都需要为每个线程保存单独上下文(寄存器、栈等),区别在于油站协程的调度由应用程序自行实现,对内核是透明的,而内核级线程的调度由系统内核完成,是抢占式的。

列入Golang在进行协程调度时会保存栈寄存器和程序计数器(汇编支持,再进行调度)。


很多地方又把协程称为subroutine,subroutine是什么,就是函数。上古时期的计算机科学家早就给出了概念,coroutinue就是可以中断并恢复执行的subroutine,从这个角度来看协程拥有调用栈并不是一个奇怪的事情。我们再来在思考coroutinue与subroutine相比有什么区别,你会发现区别仅有一个,就是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]之间转换的,

这样我们总是只能在调用栈顶的协程运行完成以后才能去执行更低一层的协程,当然,这也是典型的非对称协程,即协程之间有明显的调用关系。

当然我的描述中也可以看出有栈协程设计到对于寄存器的保存和修改,也涉及到对每一个协程栈(实际运行)的分配。对于寄存器来说,线代寄存器基本都是上百个字节的数据,还有每一个协程的栈,如果选择了共享栈,又涉及到对栈上数据的拷贝,显然在效率上来说相比无栈协程的确是有一些损失的。

无栈协程

相比于有栈直接切换栈帧的思路,无栈写成在不改变函数调用栈的情况下,采用类似生成器generator的思路实现了上下文切换。通过编译器将生成器改写为对应的迭代器类型(内部其实就是一个状态机)。

而无栈写成需要再编译器将代码便以为对应的状态机代码,挂起的位置在编译器确定。

无栈写成的优点在性能上,不需要保存单独的上下文,内存占用低,切换成本低,性能高。缺点是需要编译器提供语法支持,无栈协程的实现是通过编译器对语法糖做支持,比如C#的yield return,aysncawait,编译器将这些带有关键字的方法编译为生成器,以及对应类型作为状态机。

只有状态机支持才能进行协程调度,例如Rust的tokio,基于Future的用户态线程,根据poll方法获取Future状态,它不可以再任意嵌套函数中挂起(同步代码未实现状态机)。


那么所谓的无栈协程是什么呢?其实无栈协程本质上就是一个状态机(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局部性更好,同时也使得无栈协程的中断和函数返回几乎没有区别,这样也可以凸显出无栈协程的高效。

对称协程非对称协程

其实对于“对称”这个名词,阐述的实际是协程之间的关系,用大白话来说就是对称协程就是说协程之间人人平等,没有谁调用谁一说,大家都是一样的,而非对称协程就是协程之间存在明显的调用关系。

go语言的协程就是对称协程,而腾讯libco提供的协程就是非对称协程。

简单来说就是这样:

  • 非对称协程(asymmetric coroutines)是跟一个特定的调用者绑定的,协程让出CPU时,只能让回给调用者。何为非对称呢?在于协程的调用(call/resume)和返回(return/yield)的地位不对等。程序控制流转移到被调用者协程,而被调用者只能返回最初调用它的协程。
  • 对称协程(symmetric coroutines)在于协程调用和返回的地位是对等的。启动之后就跟启动之前的协程没有任何关系了。协程的切换操作,一般而言只有一个操作,yield,用于将程序控制流转移给其他的协程。对称协程机制一般需要一个调度器的支持,按一定调度算法去选择yield的目标协程。
Last modification:February 28, 2024
如果觉得我的文章对你有用,请随意赞赏