8、协程状态管理:就绪、运行、阻塞、死亡状态转换
协程的状态管理,说白了就是协程的「生命周期」。我刚开始写协程库的时候,觉得状态不就是几个枚举值嘛,随便搞搞就行。结果呢?跑起来各种诡异——协程跑着跑着就丢了,或者阻塞的协程永远醒不过来。嗯,后来我才明白,状态管理是协程调度器的骨架,骨架歪了,肉长再多也没用。
8.1 协程的四种基本状态
一个协程从创建到销毁,会经历四个状态。我个人习惯把它们比作人的一生:
- 就绪(Ready):协程已经创建好,万事俱备,只等调度器给它CPU时间片。就像你准备好了简历,等着面试官叫你进去。
- 运行(Running):协程正在执行它的函数体。说白了就是「正在干活」的状态。
- 阻塞(Blocked):协程在等某个条件——比如等I/O完成、等锁释放、等另一个协程发消息。它主动让出CPU,但还没死。
- 死亡(Dead):协程执行完毕,或者被强制取消。资源可以回收了。
你想想看,这四个状态其实和操作系统的线程状态很像。但协程是用户态调度的,所以状态转换的时机完全由我们自己控制。
8.2 状态转换图
下面这张图是我在课程里必讲的。它展示了协程状态之间的合法转换路径:
这张图里有个关键点:阻塞的协程不能直接回到运行状态。它必须先变成就绪,然后等待调度器再次选中它。为什么?因为调度器需要统一管理「谁可以跑」这件事。我在项目中遇到过有人直接让阻塞协程跳回运行态,结果调度器的就绪队列和运行态计数全乱了,debug了一整天才找到原因。
8.3 状态管理的核心数据结构
每个协程都需要一个状态字段。我个人习惯用枚举:
typedef enum {
COROUTINE_READY = 0, // 就绪
COROUTINE_RUNNING = 1, // 运行
COROUTINE_BLOCKED = 2, // 阻塞
COROUTINE_DEAD = 3 // 死亡
} coroutine_state_t;
然后每个协程控制块(CCB)里存这个状态:
typedef struct coroutine {
coroutine_state_t state; // 当前状态
void *stack; // 栈指针
size_t stack_size; // 栈大小
void (*func)(void *arg); // 协程函数
void *arg; // 函数参数
// ... 其他字段
} coroutine_t;
你想想看,状态字段其实就一个int,但它的变化必须严格遵循上面的转换图。我见过最蠢的bug是什么?有人在协程函数里直接写 co->state = COROUTINE_DEAD,然后继续执行后面的代码——协程都死了还在跑,这不是诈尸吗?
8.4 状态转换的时机与实现
每个状态转换都有明确的触发条件。我总结了一张表,方便你对照:
| 转换 | 触发条件 | 谁触发 | 注意事项 |
|---|---|---|---|
| 创建 → 就绪 | 协程创建完成,加入就绪队列 | 创建者/调度器 | 不要直接设为运行态 |
| 就绪 → 运行 | 调度器从就绪队列取出协程 | 调度器 | 同一时刻只有一个协程在运行 |
| 运行 → 就绪 | 协程主动yield,或时间片用完 | 协程自身/调度器 | 保存上下文后再切换 |
| 运行 → 阻塞 | 协程等待I/O、锁、条件变量 | 协程自身 | 移出运行态,加入阻塞队列 |
| 阻塞 → 就绪 | 等待的条件满足 | 事件回调/其他协程 | 不要直接设为运行态 |
| 运行 → 死亡 | 函数返回,或调用co_exit | 协程自身/调度器 | 清理资源,从调度器移除 |
核心原则:状态转换必须由调度器统一管理。协程自身不能随意修改自己的状态,尤其是不能自己把自己设成「死亡」然后继续跑。我曾经在一个嵌入式项目里看到有人这么干,结果协程栈被回收了,但协程还在执行——直接踩内存,系统崩溃。
8.5 阻塞队列与就绪队列的管理
调度器内部至少维护两个队列:
- 就绪队列:所有状态为
COROUTINE_READY的协程。调度器从这里取协程来运行。 - 阻塞队列:所有状态为
COROUTINE_BLOCKED的协程。通常按等待的事件分组。
我建议用双向链表来实现这两个队列。为什么?因为协程经常需要从队列中删除(比如阻塞条件满足时,要从阻塞队列移到就绪队列),双向链表的删除操作是O(1)的。
// 就绪队列:先进先出(FIFO)或优先级队列
typedef struct ready_queue {
coroutine_t *head;
coroutine_t *tail;
int count;
} ready_queue_t;
// 阻塞队列:按等待事件分组
typedef struct blocked_queue {
void *wait_event; // 等待的事件标识
coroutine_t *head;
coroutine_t *tail;
struct blocked_queue *next;
} blocked_queue_t;
小技巧:阻塞队列可以按事件ID哈希,这样唤醒时能快速找到对应的队列。我在一个网络库中就是这么做的——每个socket fd对应一个阻塞队列,数据到达时直接唤醒该队列上的所有协程。
8.6 状态转换的原子性
这里有个坑,我必须提醒你:状态转换必须是原子的。尤其是在多线程环境下,调度器可能在一个线程里运行,而协程在另一个线程里触发阻塞条件。如果状态转换不是原子的,就会出现「协程既在就绪队列又在阻塞队列」的混乱局面。
我常用的做法是加一个自旋锁:
void coroutine_set_state(coroutine_t *co, coroutine_state_t new_state) {
spin_lock(&co->state_lock);
co->state = new_state;
spin_unlock(&co->state_lock);
}
当然,如果你是在单线程的协程调度器里,可以不用锁。但即便如此,也要保证状态切换和队列操作在同一个临界区内。我曾经在单线程环境下因为先改了状态再移出队列,中间被中断处理插了一脚,结果队列指针乱了——嗯,从那以后我都是先操作队列,再改状态。
8.7 死亡状态的资源回收
协程进入死亡状态后,调度器需要回收它的资源:栈、控制块、以及它持有的其他资源(比如打开的文件描述符)。我建议采用「延迟回收」策略:
- 协程执行完毕,状态设为
COROUTINE_DEAD,但控制块还在。 - 调度器在每次调度循环中,检查是否有死亡协程需要回收。
- 回收时,先调用析构函数(如果有),再释放栈内存,最后释放控制块。
为什么不能立即回收?因为协程可能正在被其他协程引用(比如等待它的返回值)。立即回收会导致悬空指针。延迟回收给了其他协程一个「告别」的机会。
注意:不要忘记清理阻塞队列中的死亡协程。如果一个协程在阻塞状态被强制取消,它可能还在某个阻塞队列里。如果不清理,调度器会试图唤醒一个已经不存在的协程——这会导致野指针访问。
8.8 实战经验总结
最后,分享几个我在项目中踩过的坑:
- 状态重复设置:不要在协程函数里手动设置状态。让调度器统一管理。我曾经在协程里写了
co->state = COROUTINE_DEAD,结果调度器又设了一次,虽然没出大问题,但代码维护起来很恶心。 - 阻塞队列的唤醒风暴:如果多个协程等待同一个事件,事件到达时不要一次性全部唤醒。应该逐个唤醒,让它们重新竞争。否则所有协程同时变成就绪,调度器压力剧增。
- 死亡协程的二次回收:确保回收函数是幂等的。我见过有人回收两次同一个协程,第二次free的时候直接double free崩溃。
状态管理看似简单,但它是协程调度器的基石。你把状态转换搞清楚了,调度器就稳了一半。剩下的,就是调度策略和上下文切换的细节了。
公众号:蓝海资料掘金营,微信deep3321