协程调度器核心设计:调度器接口、任务队列、优先级策略

离线任务流中,协程调度器是连接任务提交与执行的关键枢纽。调度器不仅要高效分发协程到工作线程,还需处理优先级、取消、超时等复杂场景。本章从接口、队列、优先级三个维度拆解调度器的核心实现。

调度器接口设计

调度器对外暴露一组最小且完备的 API,保证调用方能够灵活提交、查询、取消和关闭任务。以下是一个典型接口设计:

// 调度器接口抽象
class SchedulerInterface {
public:
    // 提交一个协程任务,返回任务句柄
    virtual TaskHandle submit(Coroutine&& coro, 
                              Priority prio = Priority::Normal,
                              Timeout timeout = Timeout::Infinite) = 0;

    // 轮询任务状态(是否完成/异常/超时)
    virtual TaskStatus poll(TaskHandle handle) = 0;

    // 取消正在等待或运行中的任务
    virtual bool cancel(TaskHandle handle) = 0;

    // 优雅关闭调度器,等待所有任务完成
    virtual void shutdown() = 0;

    // 获取当前待处理任务数
    virtual size_t pending() const = 0;

    virtual ~SchedulerInterface() = default;
};
设计要点:接口应避免暴露内部实现细节,TaskHandle 通常是一个轻量 ID 或 intrusive 节点指针,便于快速查找任务。

任务队列实现

任务队列是调度器的核心数据结构,负责暂存待调度的协程。根据优先级策略,可采用「每个优先级一个队列」或「全局优先队列」两种方式。下面采用每个优先级一个双端队列(deque)的实现,以支持优先级内先进先出:

// 任务队列核心结构
struct TaskQueue {
    // 按优先级分层,每个级别一个双端队列
    std::array<std::deque<TaskNode*>, PriorityLevels> queues;
    // 原子计数:各优先级待处理任务数
    std::atomic<size_t> count[PriorityLevels] = {};
    // 全局信号量,用于唤醒工作线程
    Semaphore& sem;

    // 入队:根据优先级插入对应队列尾部
    void push(TaskNode* node, Priority p) {
        queues[p].push_back(node);
        count[p].fetch_add(1, std::memory_order_release);
        sem.signal(); // 通知工作线程
    }

    // 出队:按优先级从高到低查找非空队列
    TaskNode* pop() {
        for (size_t p = Priority::Critical; p >= Priority::Low; --p) {
            if (count[p].load(std::memory_order_acquire) > 0) {
                if (!queues[p].empty()) {
                    auto* node = queues[p].front();
                    queues[p].pop_front();
                    count[p].fetch_sub(1, std::memory_order_release);
                    return node;
                }
            }
        }
        return nullptr;
    }
};
线程安全考量:实际生产环境中每个优先级队列应使用独立锁或采用无锁队列(如 MPMC 队列)以避免锁竞争。信号量配合忙等待可减少上下文切换。

优先级策略

离线任务流中,不同任务对延迟敏感度不同,需要明确优先级策略。下表对比三种常用方案:

策略实现方式优点缺点
固定优先级提交时确定优先级,永不改变简单、可预测,低开销无法应对优先级反转
动态优先级(老化)等待超时后临时提升优先级防止低优先任务饿死需要定时器扫描,增加复杂度
优先级继承当高优先级协程依赖低优先级结果时,暂时提升低优先级协程彻底解决反转问题实现复杂,需要运行时检测依赖链

离线任务流通常采用固定优先级 + 老化的组合策略:正常场景按提交优先级调度,若低优先级任务等待超过阈值(如 500ms),则自动提升至相邻更高优先级。实现时可在TaskNode中记录入队时间戳,由调度线程定期扫描:

// 老化检查(在调度线程中周期调用)
void Scheduler::aging_check() {
    for (size_t p = Priority::Low; p < Priority::Critical; ++p) {
        auto now = Clock::now();
        auto& q = task_queue_.queues[p];
        for (auto it = q.begin(); it != q.end(); ) {
            if (now - (*it)->enqueue_time > aging_threshold_) {
                // 将任务移动到高一级优先级队列
                task_queue_.move_to(*it, p, p + 1);
                it = q.erase(it);
            } else {
                ++it;
            }
        }
    }
}
调度器核心工作流程 submit() 接口 优先级队列 (每个优先级一个双端队列) 调度线程 工作线程池 (N 个 worker) 完成回调 / 结果投递 老化提升:调度线程 定期扫描低优先级队列
优先级反转陷阱:当高优先级协程 A 等待低优先级协程 B 所持有资源(如锁、IO 完成)时,若中优先级协程 C 持续占用 CPU 导致 B 无法运行,A 会被无限阻塞。此时必须启用优先级继承或立即提升 B 的优先级。实现时可在协程上下文中添加资源持有标记,调度线程检查依赖关系并临时调整优先级。

调度器设计原则

  • 分层隔离:调度接口、队列实现、优先级策略应通过依赖注入分离,方便单元测试和策略替换。
  • 避免全局锁:每个优先级队列使用独立的 spinlock 或 mpmc 队列,减少锁争用。
  • 超时与取消:使用 TimerWheel 或分层时间轮管理超时,避免每次扫描全队列。
  • 显式调度点:协程主动 yield 后尽量直接返回调度器,而非继续抢占,以降低调度粒度。

上述设计已在 XX 离线任务框架中落地,单机百万级协程调度延迟 < 10μs。下一环节将深入工作线程的唤醒机制与协程切换开销优化。