27、协程库的线程安全:多线程调度器设计
好,咱们今天聊点硬核的——协程库的线程安全。
说实话,很多人在单线程里玩协程玩得挺溜,一上多线程就翻车。我自己也踩过这个坑。早期我写过一个轻量级协程库,单线程跑得好好的,一放到多核环境,各种诡异崩溃。后来花了整整一周排查,发现是调度器的就绪队列没加锁。
嗯,这节课我们就来彻底搞定它。
为什么单线程调度器不够用?
你想想看,单线程调度器本质上就是一个事件循环:
while (1) {
co = dequeue(&ready_queue);
if (co) {
resume(co);
}
// 处理其他事件...
}
这个模型在单核上没问题。但现代服务器动不动就 16 核、32 核,你只用一个线程跑协程,其他核心全闲着,这不是暴殄天物吗?
所以,我们需要多线程调度器——让多个工作线程同时从就绪队列里取协程执行。
多线程调度器的核心挑战
说白了,就三个问题:
- 队列安全:多个线程同时操作就绪队列,不能乱
- 负载均衡:不能让一个线程忙死,其他线程闲死
- 协程迁移:协程在哪个线程执行,能不能换线程
我在项目中遇到过最头疼的就是第三个问题。协程在 A 线程执行到一半,yield 了,结果被 B 线程捡起来继续跑。如果协程的栈上数据没处理好,直接崩给你看。
架构设计:多线程调度器结构
先画个图,让大家有个整体印象。
核心数据结构设计
先看代码,再解释。
// 协程控制块
typedef struct coroutine {
void *stack; // 协程栈
void *ctx; // 上下文
int state; // 状态:就绪/运行/等待/结束
struct coroutine *next; // 链表指针
int owner_thread; // 所属线程ID
} coroutine_t;
// 线程本地调度器
typedef struct {
coroutine_t *local_queue; // 无锁本地队列(单链表)
int queue_len; // 本地队列长度
coroutine_t *current; // 当前正在执行的协程
} thread_scheduler_t;
// 全局调度器
typedef struct {
coroutine_t *global_queue; // 全局就绪队列
pthread_mutex_t lock; // 全局队列锁
pthread_cond_t cond; // 条件变量(用于唤醒空闲线程)
int thread_count; // 工作线程数
thread_scheduler_t *local; // 每个线程的本地调度器
} global_scheduler_t;
这里有个设计要点:每个线程都有自己的本地队列。为什么?
因为本地队列是单线程独占的,不需要加锁。协程在同一个线程内 yield 和 resume,直接走本地队列,零开销。只有跨线程操作才需要碰全局队列。
核心原则:尽量让协程在同一个线程内完成生命周期,减少跨线程迁移。
调度器工作流程
每个工作线程的 main loop 长这样:
void *worker_thread(void *arg) {
thread_scheduler_t *local = get_thread_scheduler();
while (!scheduler_stopped) {
// 1. 优先从本地队列取协程
coroutine_t *co = dequeue_local(local);
// 2. 本地队列为空,尝试从全局队列取
if (!co) {
co = dequeue_global();
}
// 3. 全局队列也为空,尝试工作窃取
if (!co) {
co = steal_work();
}
// 4. 实在没有,就休眠等待
if (!co) {
wait_for_work();
continue;
}
// 5. 执行协程
local->current = co;
resume(co);
local->current = NULL;
// 6. 协程执行完毕或yield,根据状态处理
handle_co_after_resume(co);
}
return NULL;
}
这个流程我调过很多次。最开始我犯过一个错误——在步骤 4 直接忙等待,结果 CPU 占用率飙到 100%。后来改成条件变量休眠,才正常。
工作窃取算法
工作窃取是多线程调度器的精髓。当一个线程的本地队列空了,它就去别的线程的本地队列「偷」协程来执行。
实现起来其实不复杂:
coroutine_t *steal_work() {
int my_id = get_thread_id();
int n = scheduler->thread_count;
// 随机选择一个目标线程
int target = (my_id + rand() % (n - 1) + 1) % n;
for (int i = 0; i < n; i++) {
thread_scheduler_t *other = &scheduler->local[target];
// 尝试窃取对方本地队列的一半协程
coroutine_t *stolen = steal_half(other);
if (stolen) {
return stolen;
}
// 没偷到,换下一个目标
target = (target + 1) % n;
}
return NULL;
}
小技巧:窃取时不要全偷走,偷一半留一半。这样被偷的线程还有活干,不会马上又来偷你的。我试过全偷走,结果两个线程互相偷来偷去,性能反而下降。
线程安全的 yield 实现
yield 操作在多线程环境下要特别小心。协程在 A 线程 yield,可能被 B 线程 resume。
void coroutine_yield() {
coroutine_t *co = get_current_co();
thread_scheduler_t *local = get_thread_scheduler();
// 保存当前上下文
save_context(co);
// 判断协程是否允许迁移到其他线程
if (co->flags & CO_MIGRATABLE) {
// 可迁移:放入全局队列
enqueue_global(co);
} else {
// 不可迁移:放回本地队列
enqueue_local(local, co);
}
// 切换到调度器
switch_to_scheduler();
}
这里有个细节:可迁移标志。有些协程持有线程局部存储(TLS)或锁资源,不能随便换线程。我在项目中就遇到过协程持有 malloc 的线程缓存,换线程后直接内存泄漏。
避坑指南:协程如果使用了 pthread 的线程局部存储(如 errno、malloc 缓存),一定要标记为不可迁移。否则会出现各种诡异问题。
性能对比数据
我拿一个简单的 HTTP 压测工具测过,对比不同调度策略:
| 调度策略 | QPS(每秒请求数) | CPU 利用率 | 上下文切换/秒 |
|---|---|---|---|
| 单线程调度 | 12,000 | 25% | 8,000 |
| 多线程 + 全局队列 | 35,000 | 70% | 45,000 |
| 多线程 + 本地队列 + 工作窃取 | 52,000 | 92% | 12,000 |
看到没?加了本地队列和工作窃取后,QPS 提升了将近 50%,而且上下文切换次数反而大幅下降。这就是无锁本地队列的威力。
几个实战经验
最后分享几个我踩过的坑:
- 锁粒度要小:全局队列的锁只保护入队出队操作,不要在锁里做协程切换。我曾经把 resume 放在锁里,结果死锁了。
- 避免优先级反转:如果协程有优先级,工作窃取时优先偷高优先级的协程。否则低优先级协程可能被频繁窃取,高优先级反而饿死。
- 线程数不是越多越好:我试过 64 线程跑协程,结果大部分时间花在窃取和锁竞争上。一般建议线程数等于 CPU 核心数。
- 监控调度器状态:加一些统计计数器,比如每个线程的本地队列长度、窃取次数、全局队列等待时间。这些数据能帮你快速定位性能瓶颈。
嗯,多线程调度器就讲到这里。核心思想就是:本地优先,全局兜底,窃取平衡。你把这个原则记牢了,设计多线程协程调度器就不会跑偏。
公众号:蓝海资料掘金营,微信deep3321