7、协程的切换与调度:yield 与 resume 的实现,调度器主循环
协程这东西,说白了就是你能主动让出CPU,还能在之后精确地回到让出的那个点继续跑。这跟操作系统线程的抢占式调度完全是两码事。我刚开始接触协程时,总觉得它跟setjmp/longjmp差不多,后来在嵌入式项目里踩了坑才明白——差远了。
今天我们就来聊聊协程最核心的两个操作:yield(让出)和resume(恢复),以及调度器这个“大管家”是怎么工作的。
7.1 yield 的本质:保存现场,主动让出
yield 干了什么事?就两件:
- 保存当前协程的上下文(寄存器、栈指针、程序计数器)
- 把控制权交还给调度器
你想想看,如果连现场都不保存,那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 正好相反:
- 从协程控制块里取出之前保存的上下文
- 切换过去,让协程从上次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和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和调度器主循环,我们就聊到这里。这些概念看起来简单,但真正实现起来,细节决定成败。尤其是栈管理、状态转换和嵌套调用这三个点,建议你在动手写代码前先画清楚状态图,不然调试的时候会非常痛苦。