上一篇文章解决了"任务怎么被 Worker 安全取走"。这一篇继续往下一层:当没有任务时,Worker 怎么停下来,又怎么在新任务到来时及时恢复?
一个成熟调度器不仅要会"跑",也要会"停"。停得太晚,CPU 被空转吃满;停得太早,又会频繁陷入睡眠和唤醒。真正困难的地方,是在响应速度、CPU 利用率和同步正确性之间找到平衡。
1. 空闲线程该做什么?
工作线程通常会不断尝试获取任务:
cpp
while (running) {
if (auto task = try_get_task()) {
execute(task);
}
}
如果系统持续有任务,这种方式没有问题。
当队列长时间为空时,线程会不断重复检查,这就是 Busy-Waiting(忙等待)。它响应快,但会持续占用 CPU。
另一种方式是 Blocking Wait(阻塞等待)。Worker 在确认没有工作后进入睡眠,让出 CPU,等新任务出现时再被唤醒。
于是问题变成了:
线程准备睡眠时,怎样保证不会刚好错过一次唤醒?
2. Lost Wakeup
最简单的实现可能是:
cpp
if (queue.empty()) {
sleep();
}
Worker 刚确认队列为空,但还没有真正进入等待,此时另一个线程提交任务并执行唤醒。
如果通知没有被保存,Worker 随后仍然进入睡眠,就可能出现队列中已有任务、Worker 却继续阻塞的情况。
这就是 Lost Wakeup(丢失唤醒)。
同步原语真正需要保证的,是条件检查与进入等待之间没有危险的竞态窗口。
3. Condition Variable
C++ 中最经典的方案是:
cpp
std::mutex
std::condition_variable
典型写法:
cpp
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [&] {
return !queue.empty();
});
真正决定线程是否继续等待的是:
cpp
!queue.empty()
也就是 predicate(条件谓词)。
notify_one() 只是提示等待线程"状态可能发生变化",线程醒来之后仍然需要重新检查 predicate。
Condition Variable 的优势是成熟、通用,而且能够很好地表达复杂条件。对于调度器中简单的 Worker 状态,还可以使用更直接的原子等待方式。
4. C++20 的 atomic::wait
C++20 为原子变量增加了:
cpp
wait()
notify_one()
notify_all()
例如:
cpp
std::atomic<int> state{0};
state.wait(0);
当 state 仍然等于 0 时,线程等待它发生变化。
另一个线程可以:
cpp
state.store(1);
state.notify_one();
这样就可以直接围绕一个原子状态建立等待协议。
C++ 标准只规定 atomic::wait 的行为,不规定具体实现。不同平台可能使用 Futex、WaitOnAddress 或其他底层等待机制。
5. Parker:把一次唤醒记下来
调度器中常见一种专门负责 Worker 睡眠与唤醒的结构,通常称为 Parker。
一种典型设计包含三个状态:
EMPTY:没有待处理通知PARKED:Worker 正准备等待或已经等待NOTIFIED:已经存在一次尚未消费的通知
Worker 准备睡眠时,通过 CAS 尝试:
text
EMPTY → PARKED
如果状态已经是 NOTIFIED,说明通知提前到达,Worker 会直接继续执行。
Producer 提交任务后,可以执行:
cpp
state.exchange(NOTIFIED);
如果旧状态是 PARKED,再调用:
cpp
state.notify_one();
将 Worker 唤醒。
Parker 最关键的设计是:
通知会保存在状态里。
即使 unpark() 比 park() 更早发生,这次通知仍然不会消失。

6. Futex
Linux 上的线程阻塞通常会涉及 Futex(Fast Userspace Mutex)。
它的设计目标是:状态判断尽量留在用户态,只有线程确实需要阻塞时才进入内核。
假设线程准备等待:
text
state == PARKED
在真正挂起之前,内核会再次确认对应内存位置仍然等于期望值。
如果状态已经变成 NOTIFIED,等待操作就不会继续。
因此 Futex 为上层同步原语提供了一个非常重要的能力:
在睡下去之前,再确认一次"我现在是否真的应该睡"。
std::atomic::wait 在 Linux 上可以利用类似机制,而 C++ 程序本身不需要直接依赖 Futex。
7. binary_semaphore
C++20 还提供:
cpp
std::binary_semaphore
它最多保存一个 permit。
一个线程通过:
cpp
semaphore.release();
提供许可,另一个线程通过:
cpp
semaphore.acquire();
消费许可。
这种语义和 Parker 中的 NOTIFIED 很接近:即使 release() 提前发生,后续的 acquire() 也能直接消费已有许可。
binary_semaphore 更偏通用同步抽象;Parker 则通常与 Worker 状态结合,可以针对调度器内部行为做更细的定制。
8. Worker 醒了,任务也必须"看得见"
唤醒线程只是恢复执行的第一步。
Producer 通常会先把任务写入队列,再通知某个等待中的 Worker。Worker 恢复运行后,会立即尝试从队列中读取任务。
这里存在一条完整的数据传递链:
text
Producer 写入任务
↓
任务被发布
↓
Worker 被唤醒
↓
Worker 读取任务
这条链必须满足正确的内存同步关系。
如果任务已经写入,但对应的发布关系没有建立完整,Worker 即使已经醒来,也可能无法按照程序期望观察到相关数据。
因此调度器通常要同时处理两件事:
| 目标 | 负责的问题 |
|---|---|
| Wakeup | 让 Worker 从等待状态恢复 |
| Memory Ordering | 保证 Worker 能看到已经发布的任务 |
具体同步点取决于实现。有些队列会在 Push 与 Pop / Steal 中完成 release/acquire 配对,此时 Parker 只负责线程状态;也有实现会让 Parker 的状态变量参与这条同步链。
设计时应该把 任务入队 → 状态变化 → Worker 唤醒 → 任务读取 看成一个整体,而不是四个彼此独立的操作。

9. 高性能调度器不会"一空就睡"
假设 Worker 第一次发现队列为空就立刻 Park。
如果几十微秒后新任务马上到来,就会经历完整的:
睡眠 → 唤醒 → 重新参与调度
这次切换反而可能比短暂等待更昂贵。
因此调度器通常会采用 分阶段退避(Backoff)。
第一阶段:继续尝试
Worker 先检查自己的本地队列,也可以继续尝试从其他 Worker Steal。
这一阶段优先追求低延迟。
第二阶段:短暂自旋
连续几次没有找到任务后,可以进行少量 Spin。
如果任务很快到达,Worker 无需经历真正的线程阻塞。
第三阶段:让出执行机会
空闲持续更久时,可以调用 yield(),让操作系统优先运行其他可执行线程。
第四阶段:进入 Park
确认系统已经持续空闲后,再真正进入阻塞状态。
可以把整个策略压缩成:
text
Work
↓
Steal
↓
Spin
↓
Yield
↓
Park
越往下,CPU 消耗越低;代价是恢复执行的成本逐渐升高。
这也是调度器等待策略真正需要调节的地方:
短暂空闲追求响应速度,长期空闲追求资源效率。
实际工程里,Spin 次数、Steal 次数以及进入 Park 的阈值通常都会影响吞吐、延迟和功耗表现。
总结
线程等待机制可以归结为三个问题:
什么时候睡?
短暂空闲可以继续尝试或自旋;空闲持续较久后再进入阻塞。调度器需要在响应速度和 CPU 消耗之间取平衡。
怎样保证不会错过通知?
Condition Variable 使用 predicate 与等待协议保证状态正确;Parker 则把一次尚未消费的通知保存在原子状态中。
醒来后能不能正确看到任务?
线程恢复执行之后,还需要依靠正确的内存同步关系读取已经发布的数据。
把这些层次放在一起,可以得到一个比较清晰的关系:
| 机制 | 主要职责 |
|---|---|
condition_variable |
围绕条件进行阻塞等待 |
atomic::wait |
等待原子状态变化 |
| Parker | 管理 Worker 的 Park / Unpark 状态 |
binary_semaphore |
保存和消费许可 |
| Futex | 提供底层线程阻塞能力 |
一个成熟调度器最终追求的状态很简单:
有任务时尽可能快,没有任务时尽可能安静,新任务到来时又能可靠醒来。
线程的睡眠与唤醒看起来只是调度器中的辅助逻辑,但它直接决定了系统的空闲功耗、任务响应延迟以及高负载下的调度效率。