为什么线程不能说睡就睡?看懂等待与唤醒机制

上一篇文章解决了"任务怎么被 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 提供底层线程阻塞能力

一个成熟调度器最终追求的状态很简单:

有任务时尽可能快,没有任务时尽可能安静,新任务到来时又能可靠醒来。

线程的睡眠与唤醒看起来只是调度器中的辅助逻辑,但它直接决定了系统的空闲功耗、任务响应延迟以及高负载下的调度效率。

相关推荐
云边有个稻草人1 小时前
SQL Server数据迁移不只是“搬过去”:金仓如何让复杂查询越迁越快
后端
桦说编程1 小时前
盘点并发集合里那些容易误判的行为
java·后端·性能优化
云边有个稻草人1 小时前
时序数据库如何告别手工分片?看金仓“超表”怎样简化海量数据管理
后端
Java编程爱好者1 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
后端
程序员与背包客_CoderZ1 小时前
Linux D-Bus通信协议详解:从入门到C/C++编码实战
linux·服务器·c语言·c++·嵌入式硬件·嵌入式软件·dbus
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-35有理数均值(C++语言实现)
开发语言·数据结构·c++·算法·均值算法
wangchen_02 小时前
C++正则表达式
开发语言·c++·正则表达式
苍何3 小时前
偷偷分享 DeepSeek Harness 热榜挖到的 2 个实⽤插件
后端
神奇小汤圆3 小时前
面试官问:"你们上线前怎么测Agent系统?上线后怎么知道它好不好?"
后端