详解调度器
调度器可以理解成:
决定“现在谁运行、谁暂停、谁等待、谁恢复”的管理者。
如果没有调度器,协程、线程、事件循环都只是一堆零件。调度器负责把它们组织起来。
一、最直观的理解
假设有三个任务:
任务 A:等网络数据
任务 B:处理计算
任务 C:等定时器
如果 A 暂时没数据,继续让 A 占着 CPU 就浪费了。
调度器会做:
A 没数据 -> 挂起 A
运行 B
C 定时器到了 -> 恢复 C
A 网络数据到了 -> 恢复 A
所以调度器的核心职责是:
让能运行的任务运行;
让不能运行的任务等待;
当等待条件满足后,把任务重新放回可运行队列。
二、调度器有很多层
“调度器”这个词不只一种。
常见有三类:
| 类型 | 谁实现 | 调度对象 |
|---|---|---|
| 操作系统调度器 | 内核 | 线程 / 进程 |
| 事件循环调度器 | 程序 / 框架 | fd 事件、定时器、回调 |
| 协程调度器 | 程序 / 协程库 | 协程 |
操作系统调度器负责调度线程。
epoll 事件循环负责调度 I/O 事件。
ucontext 或 C++ coroutine 调度器负责调度协程。
你前面问的这些东西拼起来,通常是:
epoll 负责发现 I/O 就绪
ucontext 负责保存/恢复协程上下文
调度器负责决定恢复哪个协程
三、操作系统线程调度器
操作系统调度器是内核的一部分。
它调度的是线程或进程。
比如有多个线程:
Thread A
Thread B
Thread C
但是 CPU 核心有限,操作系统要决定:
现在让哪个线程跑?
跑多久?
什么时候切换?
优先级怎么处理?
这是抢占式调度。
也就是说,线程自己不主动让出 CPU,操作系统也可以强行把它切走。
例如:
Thread A 正在运行
时间片到了
内核暂停 A
恢复 Thread B
这个切换对用户代码通常是透明的。
四、协程调度器
协程调度器通常是用户态实现的。
它调度的是协程,不是线程。
比如:
Coroutine A
Coroutine B
Coroutine C
它们可能都运行在同一个 OS 线程里。
协程调度通常是协作式的:
协程主动 yield
调度器才切换到其他协程
也就是说,协程如果不主动让出执行权,调度器通常不能强行切走它。
比如:
void taskA() {
while (true) {
// 如果这里永远不 yield
// 其他协程就没机会运行
}
}
这就是协作式调度的风险:某个协程如果写坏了,会饿死其他协程。
五、调度器最基本的数据结构
一个协程调度器通常需要这些东西:
struct Task {
ucontext_t ctx;
std::vector<char> stack;
bool finished;
};
以及:
std::queue<Task*> ready_queue;
Task* current;
ucontext_t scheduler_ctx;
其中:
ready_queue:可运行队列,里面放着可以马上运行的协程。
current:当前正在运行的协程。
scheduler_ctx:调度器自己的上下文。
协程状态一般有:
enum class State {
Ready, // 可以运行
Running, // 正在运行
WaitingIO, // 等 I/O
Sleeping, // 等定时器
Finished // 已结束
};
六、 最简单的调度逻辑
一个最小调度器可以这样:
while ready_queue 不为空:
取出一个协程
切换到它
它 yield 或结束后,回到调度器
伪代码:
void Scheduler::run() {
while (!ready_queue.empty()) {
Task* task = ready_queue.front();
ready_queue.pop();
current = task;
task->state = State::Running;
swapcontext(&scheduler_ctx, &task->ctx);
if (task->finished) {
destroy(task);
} else if (task->state == State::Ready) {
ready_queue.push(task);
}
}
}
这里的关键是:
swapcontext(&scheduler_ctx, &task->ctx);
从调度器切到协程。
协程里如果调用:
yield();
就会:
swapcontext(&task->ctx, &scheduler_ctx);
从协程切回调度器。
七、yield 在调度器里的作用
yield 就是协程主动说:
我先不跑了,把执行权交回调度器。
例如:
void yield() {
current->state = State::Ready;
ready_queue.push(current);
swapcontext(¤t->ctx, &scheduler_ctx);
}
这样当前协程会回到队列尾部,其他协程有机会运行。
执行过程像这样:
调度器 -> A
A yield -> 调度器
调度器 -> B
B yield -> 调度器
调度器 -> C
C yield -> 调度器
调度器 -> A
这叫轮转调度,round-robin。
八、调度器和 epoll 的结合
只会 yield 的调度器还不够强。
如果协程要等网络数据,不能一直空转:
while (true) {
int n = recv(fd, buf, size, 0);
if (n < 0 && errno == EAGAIN) {
yield();
}
}
这样虽然不会阻塞线程,但会不断被调度起来检查,浪费 CPU。
更好的做法是:
如果 fd 没数据:
把当前协程挂到 fd 的等待表里
用 epoll 监听这个 fd
yield 回调度器
等 epoll 通知 fd 可读:
把等待这个 fd 的协程放回 ready_queue
这样调度器就变成了:
协程调度器 + I/O 调度器
九、一个 co_recv 的逻辑
假设 socket 是非阻塞的。
协程里想读数据:
ssize_t co_recv(int fd, char* buf, size_t size) {
while (true) {
ssize_t n = recv(fd, buf, size, 0);
if (n >= 0) {
return n;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
scheduler.wait_read(fd);
scheduler.yield();
continue;
}
return -1;
}
}
这段代码表面看起来像同步:
auto n = co_recv(fd, buf, size);
但底层是:
recv 发现没数据
注册 epoll 读事件
挂起当前协程
调度其他协程
fd 可读后恢复当前协程
继续 recv
这就是协程调度器的威力。
十、调度器里通常有什么队列
一个完整调度器一般不止一个队列。
常见结构:
ready_queue:马上可以运行的协程
waiting_read:等待 fd 可读的协程
waiting_write:等待 fd 可写的协程
sleeping_tasks:等待定时器的协程
finished_tasks:需要回收的协程
比如:
std::deque<Task*> ready_queue;
std::unordered_map<int, Task*> read_waiters;
std::unordered_map<int, Task*> write_waiters;
std::priority_queue<Timer> timers;
如果任务调用:
co_sleep(1000);
调度器就把它放进定时器堆里:
1 秒后再恢复
如果任务调用:
co_recv(fd, ...)
但没数据,就放进:
read_waiters[fd]
等 epoll 通知可读。
十一、调度器主循环
一个带 epoll 和定时器的调度器主循环大概是:
while 还有活着的任务:
如果 ready_queue 不空:
取一个 ready task 运行
否则:
计算最近定时器还有多久到期
epoll_wait 等 I/O 或定时器超时
把就绪 fd 对应的协程放入 ready_queue
把到期定时器对应的协程放入 ready_queue
伪代码:
void Scheduler::run() {
while (!done()) {
while (!ready_queue.empty()) {
Task* task = ready_queue.front();
ready_queue.pop_front();
resume(task);
}
int timeout = compute_timer_timeout();
int n = epoll_wait(epfd, events, max_events, timeout);
handle_io_events(events, n);
handle_expired_timers();
}
}
这里的调度器就像一个总控台:
能跑的先跑;
没人能跑就睡在 epoll_wait;
I/O 或定时器来了再唤醒对应协程。
十二、调度器为什么需要 epoll_wait 的 timeout
如果没有 ready task,调度器不能空转。
它应该睡眠:
epoll_wait(epfd, events, 1024, timeout);
但如果有定时器,例如:
协程 A 要 100ms 后恢复
协程 B 要 500ms 后恢复
那么 epoll_wait 最多只能睡 100ms。
所以 timeout 应该取最近定时器的时间:
timeout = 最近一个 timer 的到期时间 - 当前时间
这样就能同时处理:
I/O 事件
定时器事件
十三、调度策略
调度器不只是“能跑就跑”,还涉及策略。
最简单的是 FIFO:
谁先进入 ready_queue,谁先运行。
也可以是 round-robin:
每个任务运行一小段,yield 后排到队尾。
还可以加优先级:
高优先级任务先运行。
或者加权调度:
重要任务多分配运行机会。
操作系统线程调度器会考虑很多复杂因素:
优先级
时间片
CPU 亲和性
I/O 等待
公平性
实时性
用户态协程调度器通常简单很多。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)