协程这一特性在很多编程语言中都有相关的实现,例如 Java 的 Project Loom 项目为 Java 支持了协程,还有一些其他编程语言例如提倡 CSP 理论的 Go 语言,还有 Rust 语言采用的异步的方式来做的,这些都属于协程范畴了,但是有人知道无栈协程和有栈协程吗?协程作为弥补传统平台线程的不足之处被发明出来的,传统平台线程每个可能需要 ulimit -s 来查看,假设默认创建线程为线程指定栈大小为 8MB 左右,协程就相当于线程来说就要小得多了,例如协程栈为 2KB 大小这就使得协程更加轻量级。在操作系统上程序大部分都是平台线程运行中,线程最 CPU 最小基本调度单位,学过操作系统都知道如果想要支持多个线程在 1:1 模式下并行操作或者出现了系统调用阻塞的状态,就会触发线程 TCB 上下文切换,如果频繁这样操作会导致性能下降,本篇将从程序内存调用布局下来看看是如何受影响的。


函数调用栈

在计算机中每个程序在运行的时候都需要一块儿内存区域,而这块称之为为堆栈内存,例如下面这段 C 语言程序:

#include <stdio.h>

int square(int num) {
        return num * num;
}

int main() {
        int x = 5;
        int result = square(x);
        printf("%d\n",result);

}

下面则为上面 C 语言代码转换成的汇编指令代码,这段 C 语言代码的逻辑很简单就编写一个计算平方的函数 square,当我们运行程序就会产生函数调用栈,如下:

// x86-64 gcc 12.2 
square:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, eax
        pop     rbp
        ret
.LC0:
        .string "%d\n"
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], 5
        mov     eax, DWORD PTR [rbp-4]
        mov     edi, eax
        call    square
        mov     DWORD PTR [rbp-8], eax
        mov     eax, DWORD PTR [rbp-8]
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     eax, 0
        leave
        ret

在计算机处理器运行程序的时候会将其加入到内存中,从逻辑来说存储器就是一个连续的字节数组,并且可以通过索引地址访问这个位置上的数据的值,启始为是从 0 开始的,对于一个程序来说有多条机器指令组成的,在不同的字节位置上。至于上面的汇编指令略知一二即可,笔者也不是专业编译器开发人员,所以也不做更多介绍,怕误人子弟。 主要介绍的一个程序运行过程,函数的栈帧变化过程。

CPU 是一条一条指令运行我们编写的逻辑的代码的,我们虽然写的都是人类能理解的代码,但是经过编译之后会生成机器码存储在磁盘上,开发者编写的都是一些文本文件,而 C 语言编译生成可执行文件,当然这里不排除一些脚本语言例如 Python 只需要被解释器加载执行。不管源文本还是编译好的可执行文件,当运行起来之后就产生一个进程,进程里面包含上一段文字中描述的内容,而栈空间是由操作系统维护的,进程运行中可能会产生零时的变量,目些变量的内存可能会被分配到堆中,而栈则存储这些变量指针引用,每次调用一次函数都会产生一个 Stack Frame,多个栈帧组成了整个程序运行状态,整个过程我画了一张图:

需要注意在计算机里的栈从顶部向下增长的,而堆则是向上增长的,这里我为了更直观画的更像普通的栈。通过上面的例子可以看出一个调用栈的栈帧被使用完成之间会被释放,而 有栈无栈 的含义不是指协程在运行时是否需要栈,而是指协程是否可以在其任意嵌套函数中被挂起,此处的嵌套函数读者可以理解为子匿名函数,显然有栈协程是可以的,而无栈协程则不可以。


有栈协程

有栈协程的实现会当协程发生上下文切换时可以保存当前栈帧上的一些寄存器数据信息,恢复协程的时候会将之前保存信息恢复写入到对应的栈帧和寄存器,而上下文切换过程就是保存和恢复,和普通线程切换类似。有栈协程特点就是不管在什么位置都可以挂起。

基本的原理就是,在运行协程时候会申请一段能存储上下文的内存空间,用来保存上下文信息,这段内存就充当为协程运行时的栈帧空间。但是一个问题就是如何申请合理的内存,如果内存小了会爆栈,而大了又有些浪费,这里 Go 语言早期的版本实现中用的是分段栈,后面新版本又改成连续栈,当内存不够时会扩容将其复制到新的内存中,可以阅读之前的这篇文章Synchronization Primitives


无栈协程

无栈协程无栈协程在不改变函数调用栈的情况下,采用类似生成器(generator)的思路实现了上下文切换,使用起来和同步编写逻辑类似,但是程序会通过异步的方式来完成相关的操作,这里典型的例子就是 Rust 语言中 async 修饰的函数体,至于详细的介绍可以查看下面我给出的阅读链接。


小 结

程是一种用户态线程,不同于操作系统线程,协程可以在一个或多个线程中调度,但不需要切换到内核空间,因此其上下文切换代价较低,可以有效提高并发性能,有栈协程和无栈协程是协程实现中的两种不同的方式:

有栈协程(Stackful Coroutine): 有栈协程指的是协程的调用栈是由程序员显式分配的,类似于线程,协程在创建时需要分配一块内存用于存储其调用栈,因此在切换协程时需要保存和恢复整个调用栈。有栈协程的优点是灵活性较高,可以直接访问协程的调用栈,方便协程的调试和跟踪。但由于每个协程需要独立的调用栈,因此协程的创建和销毁代价较高。

无栈协程(Stackless Coroutine): 无栈协程指的是协程的调用栈是由运行时系统隐式管理的,协程本身不持有自己的调用栈,因此在切换协程时不需要保存和恢复整个调用栈,仅需要保存和恢复协程的执行状态即可。无栈协程的优点是创建和销毁代价较低,可以实现更高的并发性能。但由于协程的调用栈由运行时系统管理,因此无法直接访问协程的调用栈,不利于协程的调试和跟踪。

总的来说,有栈协程和无栈协程各有优缺点,选择哪种实现方式应该根据具体应用场景和性能需求进行选择。


其他资料

便宜 VPS vultr
最后修改:2023 年 09 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !