27、协程库的线程安全:多线程调度器设计

好,咱们今天聊点硬核的——协程库的线程安全。

说实话,很多人在单线程里玩协程玩得挺溜,一上多线程就翻车。我自己也踩过这个坑。早期我写过一个轻量级协程库,单线程跑得好好的,一放到多核环境,各种诡异崩溃。后来花了整整一周排查,发现是调度器的就绪队列没加锁。

嗯,这节课我们就来彻底搞定它。

为什么单线程调度器不够用?

你想想看,单线程调度器本质上就是一个事件循环:

while (1) {
    co = dequeue(&ready_queue);
    if (co) {
        resume(co);
    }
    // 处理其他事件...
}

这个模型在单核上没问题。但现代服务器动不动就 16 核、32 核,你只用一个线程跑协程,其他核心全闲着,这不是暴殄天物吗?

所以,我们需要多线程调度器——让多个工作线程同时从就绪队列里取协程执行。

多线程调度器的核心挑战

说白了,就三个问题:

  • 队列安全:多个线程同时操作就绪队列,不能乱
  • 负载均衡:不能让一个线程忙死,其他线程闲死
  • 协程迁移:协程在哪个线程执行,能不能换线程

我在项目中遇到过最头疼的就是第三个问题。协程在 A 线程执行到一半,yield 了,结果被 B 线程捡起来继续跑。如果协程的栈上数据没处理好,直接崩给你看。

架构设计:多线程调度器结构

先画个图,让大家有个整体印象。

多线程协程调度器架构 全局就绪队列 (加锁保护) 工作线程 1 工作线程 2 工作线程 3 本地就绪队列(无锁) 本地就绪队列(无锁) 本地就绪队列(无锁) 协程 A 执行中 协程 B 执行中 协程 C 执行中 工作窃取 工作窃取 每个线程优先执行本地队列的协程 本地队列为空时,从全局队列或窃取其他线程的协程 全局队列需要加锁保护,本地队列无锁操作

核心数据结构设计

先看代码,再解释。

// 协程控制块
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