7、协程的切换与调度:yield 与 resume 的实现,调度器主循环

协程这东西,说白了就是你能主动让出CPU,还能在之后精确地回到让出的那个点继续跑。这跟操作系统线程的抢占式调度完全是两码事。我刚开始接触协程时,总觉得它跟setjmp/longjmp差不多,后来在嵌入式项目里踩了坑才明白——差远了。

今天我们就来聊聊协程最核心的两个操作:yield(让出)resume(恢复),以及调度器这个“大管家”是怎么工作的。

7.1 yield 的本质:保存现场,主动让出

yield 干了什么事?就两件:

  1. 保存当前协程的上下文(寄存器、栈指针、程序计数器)
  2. 把控制权交还给调度器

你想想看,如果连现场都不保存,那resume回来的时候,程序都不知道自己跑到哪一行了,栈上的局部变量也全乱了。我在一个物联网终端项目里就犯过这个错——当时图省事,yield只保存了PC,没保存栈帧,结果resume回来局部变量全变成了随机值,排查了两天才找到原因。

下面是一个典型的yield实现思路(基于ucontext库):

// 协程控制块
typedef struct {
    ucontext_t ctx;      // 上下文
    void *stack;         // 独立栈空间
    int state;           // 状态:READY/RUNNING/SUSPENDED/DONE
    void *(*func)(void*);// 入口函数
    void *arg;           // 函数参数
} coroutine_t;

// yield 操作
void coroutine_yield(coroutine_t *self, scheduler_t *sched) {
    // 保存当前上下文到 self->ctx
    // 切换到调度器的主上下文 sched->main_ctx
    swapcontext(&self->ctx, &sched->main_ctx);
}

嗯,这里要注意:swapcontext 是原子操作——它先保存当前上下文,再跳转到目标上下文。中间不会有任何中断来捣乱。

7.2 resume 的本质:恢复现场,继续执行

resume 跟 yield 正好相反:

  1. 从协程控制块里取出之前保存的上下文
  2. 切换过去,让协程从上次yield的地方继续跑

我个人习惯把resume设计成“幂等”的——如果协程已经结束了,resume应该直接返回错误,而不是再次跳转。否则你会看到一个协程从函数入口重新执行一遍,那就不叫resume了,叫重启。

int coroutine_resume(coroutine_t *co, scheduler_t *sched) {
    if (co->state == DONE) {
        return -1;  // 协程已结束,拒绝恢复
    }
    if (co->state == READY) {
        // 首次执行:创建上下文并跳转到入口函数
        makecontext(&co->ctx, (void (*)(void))co->func, 1, co->arg);
        co->state = RUNNING;
    }
    // 切换到协程上下文
    swapcontext(&sched->main_ctx, &co->ctx);
    return 0;
}

这里有个细节:首次resume和后续resume的处理逻辑不同。首次需要makecontext来初始化入口,后续只需要swapcontext恢复现场。我曾经把这两个混在一起,结果协程每次resume都从函数开头重新跑——那还叫协程吗?那叫函数调用。

7.3 调度器主循环:谁该跑,谁该等

调度器说白了就是一个循环,不断问三个问题:

  • 哪个协程准备好了?
  • 哪个协程在等资源?
  • 哪个协程已经结束了?

最简单的调度器可以用一个就绪队列加一个等待队列来实现。我早期做的一个嵌入式协程库,调度器主循环大概长这样:

typedef struct {
    coroutine_t *ready_queue[MAX_COROUTINES];
    int ready_count;
    coroutine_t *waiting_queue[MAX_COROUTINES];
    int waiting_count;
    int current_index;  // 当前正在跑的协程索引
} scheduler_t;

void scheduler_run(scheduler_t *sched) {
    while (sched->ready_count > 0 || sched->waiting_count > 0) {
        // 检查等待队列:有没有协程的条件满足了?
        check_waiting_queue(sched);
        
        if (sched->ready_count == 0) {
            // 没有就绪的协程,但还有等待的——说明在等外部事件
            // 嵌入式里通常就是进入低功耗模式,等中断唤醒
            break;
        }
        
        // 轮转调度:取下一个就绪协程
        coroutine_t *current = sched->ready_queue[0];
        // 把当前协程从就绪队列移除
        remove_from_ready(sched, 0);
        
        // resume 它
        coroutine_resume(current, sched);
        
        // resume返回后,根据协程状态做后续处理
        if (current->state == SUSPENDED) {
            // 协程主动yield了,放回就绪队列尾部
            add_to_ready(sched, current);
        } else if (current->state == DONE) {
            // 协程执行完毕,释放资源
            coroutine_destroy(current);
        }
    }
}

这个循环看起来简单,但实际项目里坑不少。比如check_waiting_queue里怎么判断条件满足?我建议用回调函数或者事件标志位,别在调度器里做阻塞操作——调度器本身必须是非阻塞的。

7.4 状态机视角:协程的四种状态

每个协程在调度器眼里就是一个状态机。我习惯用枚举来定义:

状态 含义 触发条件
READY 就绪,等待调度 创建完成,或从等待中唤醒
RUNNING 正在执行 调度器resume了它
SUSPENDED 主动让出,等待下次调度 协程内部调用yield
DONE 执行完毕,等待销毁 协程函数return

状态转换图如下:

READY RUNNING SUSPENDED DONE resume(首次) yield 调度器重新入队 return 取消/销毁

核心要点:调度器只操作READY和SUSPENDED状态的协程。RUNNING状态的协程正在执行,调度器不能碰它。DONE状态的协程需要及时释放栈空间,否则内存泄漏。

7.5 避坑指南:我踩过的三个坑

坑一:栈空间复用

我曾经在同一个线程里创建了100个协程,每个协程都分配了独立的4KB栈。结果调度器主循环里忘了检查DONE状态,协程结束后栈没释放,跑了几个小时内存就爆了。后来我加了个引用计数和延迟释放机制,才解决这个问题。

坑二:yield 和 resume 的嵌套调用

如果协程A resume了协程B,B又yield回来,这时候调度器的main_ctx指向哪里?我早期实现里没处理好这个,导致协程B yield之后直接回到了调度器主循环,而不是回到A的resume调用点。解决方案是维护一个调用栈,而不是单一的main_ctx。

坑三:信号安全

在信号处理函数里调用yield或resume?千万别干这种事。swapcontext不是异步信号安全的,你在信号处理函数里切换上下文,轻则死锁,重则栈溢出。我在一个工控项目里就因为这个导致PLC随机重启,查了整整一周。

我的建议:如果你在裸机或RTOS上实现协程,调度器主循环最好放在一个独立的任务/线程里。这样协程的yield/resume不会阻塞其他高优先级任务。如果是在单线程里跑,记得在调度器循环里加一个“空闲钩子”,让CPU在没协程可跑时能休眠省电。

7.6 调度策略:不只是轮转

上面我展示的是最简单的轮转调度(Round-Robin)。但在实际项目中,你可能需要更灵活的调度策略:

  • 优先级调度:高优先级的协程先跑,低优先级的靠后。我建议用多级就绪队列,每个优先级一个队列。
  • deadline 调度:有些协程必须在指定时间内完成。这时候调度器需要根据截止时间排序,优先调度最紧急的。
  • 事件驱动调度:协程等待某个事件(比如UART收到数据),事件发生后调度器直接resume对应的协程,而不是轮询。

我个人比较喜欢事件驱动+优先级调度的组合。在嵌入式场景里,大部分协程都在等外设事件,轮转调度反而浪费CPU。你想想看,如果一个协程在等串口数据,你轮转它100次它还是没数据,这不是白跑吗?

// 事件驱动调度器的核心逻辑
void scheduler_event_driven(scheduler_t *sched, event_t event) {
    // 根据事件类型找到对应的等待协程
    coroutine_t *co = find_coroutine_by_event(sched, event);
    if (co) {
        // 从等待队列移到就绪队列
        move_to_ready(sched, co);
        // 如果当前没有协程在跑,立即调度
        if (sched->current == NULL) {
            schedule_next(sched);
        }
    }
}

这种设计的好处是:没有事件发生时,调度器主循环可以休眠,或者让CPU干别的事。事件来了,直接唤醒对应的协程,响应速度极快。

好了,关于yield、resume和调度器主循环,我们就聊到这里。这些概念看起来简单,但真正实现起来,细节决定成败。尤其是栈管理、状态转换和嵌套调用这三个点,建议你在动手写代码前先画清楚状态图,不然调试的时候会非常痛苦。

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