协程调度器核心设计:调度器接口、任务队列、优先级策略
离线任务流中,协程调度器是连接任务提交与执行的关键枢纽。调度器不仅要高效分发协程到工作线程,还需处理优先级、取消、超时等复杂场景。本章从接口、队列、优先级三个维度拆解调度器的核心实现。
调度器接口设计
调度器对外暴露一组最小且完备的 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;
}
}
}
}
优先级反转陷阱:当高优先级协程 A 等待低优先级协程 B 所持有资源(如锁、IO 完成)时,若中优先级协程 C 持续占用 CPU 导致 B 无法运行,A 会被无限阻塞。此时必须启用优先级继承或立即提升 B 的优先级。实现时可在协程上下文中添加资源持有标记,调度线程检查依赖关系并临时调整优先级。
调度器设计原则
- 分层隔离:调度接口、队列实现、优先级策略应通过依赖注入分离,方便单元测试和策略替换。
- 避免全局锁:每个优先级队列使用独立的 spinlock 或 mpmc 队列,减少锁争用。
- 超时与取消:使用
TimerWheel或分层时间轮管理超时,避免每次扫描全队列。 - 显式调度点:协程主动 yield 后尽量直接返回调度器,而非继续抢占,以降低调度粒度。
上述设计已在 XX 离线任务框架中落地,单机百万级协程调度延迟 < 10μs。下一环节将深入工作线程的唤醒机制与协程切换开销优化。