26、对称协程与非对称协程:两种模型的对比与实现

协程这东西,圈里人聊起来总爱分个派别。一派说对称协程才是正统,另一派觉得非对称协程更实用。说实话,我刚入行那会儿也被这两个概念绕晕过。后来在嵌入式项目里亲手撸了几个协程调度器,才算真正搞明白它们的区别。

今天咱们就把这两种模型掰开揉碎了讲。不扯玄乎的理论,直接看代码、看调度流程、看实际场景。

什么是协程模型的核心差异?

先问一个问题:协程之间怎么切换?

对称协程的思路很简单——所有协程平起平坐。任何一个协程都可以直接切换到另一个协程。就像一群朋友聊天,谁想说话谁就说,不用经过主持人。

非对称协程呢?它有个“调度器”或者叫“主协程”。协程只能把控制权交回给调度器,由调度器决定下一个运行谁。这就像开会,每个人发言前要先举手,主持人点名才能说。

你想想看,这两种模式在实现上会有什么不同?

核心区别一句话总结:

  • 对称协程:协程 ↔ 协程,直接跳转
  • 非对称协程:协程 → 调度器 → 协程,间接跳转

对称协程的实现思路

对称协程的切换,本质上就是保存当前协程的上下文,然后加载目标协程的上下文。在C语言里,我们通常用 setjmp/longjmp 或者汇编级别的上下文切换来实现。

我记得第一次写对称协程调度器时,犯了个低级错误——忘记保存栈指针。结果协程切回来时,局部变量全乱套了。嗯,这个坑后面会细说。

来看一个简化的对称协程切换示例:

// 对称协程:协程A直接切换到协程B
void co_yield_to(struct coroutine *from, struct coroutine *to) {
    // 保存当前协程的上下文
    if (setjmp(from->context) == 0) {
        // 直接跳转到目标协程
        longjmp(to->context, 1);
    }
    // 当协程被切回时,从这里继续执行
}

这段代码看起来简单,但实际项目里要考虑的事情多着呢。比如栈怎么分配?每个协程是共用栈还是独立栈?我曾经在一个RTOS项目里用了共用栈,结果协程嵌套调用深了,直接把系统栈撑爆了。

避坑指南:对称协程的栈管理一定要小心。如果多个协程共用同一个栈,切换时栈上的数据会被覆盖。我建议每个协程至少分配4KB的独立栈,嵌入式环境可以酌情减少到1KB。

非对称协程的实现思路

非对称协程的实现更符合我们平时的编程习惯。它有一个调度器循环,协程执行到某个点就“挂起”,把控制权还给调度器。

说白了,这就是一个生产者-消费者模型。协程是生产者,产生“挂起”事件;调度器是消费者,决定下一步做什么。

// 非对称协程:协程挂起,返回调度器
void co_yield(struct coroutine *co) {
    // 保存当前协程状态
    co->state = SUSPENDED;
    // 跳转回调度器
    longjmp(co->scheduler_context, 1);
}

// 调度器主循环
void scheduler_run() {
    while (1) {
        struct coroutine *next = pick_next_ready();
        if (!next) break;
        
        // 切换到协程
        if (setjmp(scheduler_context) == 0) {
            longjmp(next->context, 1);
        }
        // 协程挂起后回到这里
        handle_yield(next);
    }
}

你看,非对称协程的调度逻辑非常清晰。协程永远不会直接跳到另一个协程,它只能“向上”返回给调度器。这种模式在事件驱动型系统里特别好用,比如网络服务器处理多个连接。

两种模型的对比表格

对比维度 对称协程 非对称协程
控制流 任意协程间直接跳转 通过调度器间接跳转
实现复杂度 较低,但栈管理复杂 较高,但调度逻辑清晰
栈使用 通常需要独立栈 可以共用栈(挂起时保存)
调度策略 由协程自己决定 由调度器统一管理
适用场景 并行计算、状态机 IO多路复用、事件驱动
调试难度 较高(跳转不可预测) 较低(执行路径清晰)

对称协程 vs 非对称协程:SVG结构图

下面这张图把两种模型的控制流画出来了。左边是对称的,右边是非对称的。你看箭头方向就能感受到区别。

对称协程 vs 非对称协程 控制流对比 对称协程 协程A 协程B 协程C 直接切换 直接切换 直接切换 非对称协程 调度器 协程A 协程B 协程C 调度 挂起 调度 挂起 调度 挂起 协程间直接跳转,无中心调度 所有协程通过调度器间接切换

实际项目中怎么选?

我个人习惯在嵌入式系统里用非对称协程。为什么?因为嵌入式系统对确定性要求高。非对称协程的调度器可以精确控制每个协程的运行时机,这对实时性有帮助。

但如果你在做的是计算密集型的任务,比如图像处理或者科学计算,对称协程可能更合适。协程之间可以直接传递数据,省去了调度器的中间开销。

我记得有个项目是做多路传感器数据采集。一开始用了对称协程,每个传感器一个协程,数据采集完直接传给处理协程。结果调试时发现,协程之间的跳转关系太乱了,根本理不清数据流。后来改成非对称协程,所有传感器数据先上报给调度器,调度器再分发给处理协程。代码清晰多了,bug也少了一半。

我的建议:

  • 团队新手多,选非对称协程。代码可读性好,容易维护。
  • 追求极致性能,选对称协程。减少一次调度器跳转。
  • 做框架或库,两种都支持。让用户根据场景选择。

实现上的几个关键点

不管选哪种模型,有几个技术细节绕不开:

  1. 上下文保存:寄存器、栈指针、程序计数器,一个都不能少。我见过有人只保存了栈指针没保存帧指针,结果函数返回时栈帧错位,程序直接崩溃。
  2. 栈管理:对称协程建议用独立栈,非对称协程可以用共享栈加栈拷贝。共享栈省内存,但拷贝开销大。怎么取舍?看你的内存和CPU哪个更紧张。
  3. 调度策略:非对称协程的调度器可以实现优先级、时间片轮转、事件驱动等多种策略。对称协程的调度策略分散在各个协程里,想统一改策略?那得改所有协程的代码。

嗯,说到栈拷贝,我想起一个坑。曾经在非对称协程里用了共享栈,协程挂起时把栈数据拷贝到堆上。结果有个协程在挂起前申请了一块栈上的局部变量地址,挂起恢复后那个地址指向的内容已经变了。这种bug特别难查,因为不是每次都复现。后来我强制要求所有协程挂起前不能持有栈上指针,才算根治。

小结

对称协程和非对称协程,没有绝对的好坏。对称协程灵活、高效,但控制流复杂;非对称协程规整、可控,但多了一层调度开销。

选哪个?看你的场景。做底层库、追求极致性能,对称协程值得一试。做业务逻辑、追求可维护性,非对称协程更稳妥。

我个人更倾向于非对称协程。毕竟在工程里,代码是写给人看的,顺便给机器执行。清晰比高效更重要——除非高效到能省下一块芯片的钱。

一句话记住:对称协程是“点对点”,非对称协程是“星型拓扑”。想清楚你的数据流和控制流,选择自然就出来了。

公众号:蓝海资料掘金营,微信deep3321