空闲线程从自旋改成阻塞等待
空闲线程从自旋改成阻塞等待
线程池,条件变量,绑核。
计算池的 worker 没活干时会回到循环顶部再取一次任务,取不到就 continue。这个写法本身没什么错,代价是线程一直留在 runqueue 里,绑定的那几个核在整个“没有任务”的阶段里都是满的。
空转吞掉的核
旧循环大致是这样:
while (m_running.load(std::memory_order_acquire)) {
if (m_dynamicRunning.load(std::memory_order_acquire)) {
const size_t index = m_dynamicNextIndex->fetch_add(1, std::memory_order_relaxed);
if (index >= m_dynamicItems.size()) {
m_dynamicRunning.store(false, std::memory_order_release);
continue;
}
if (m_dynamicAction) {
m_dynamicAction(m_dynamicItems[index]);
} else {
processOne(m_dynamicItems[index]);
}
m_dynamicDoneCount->fetch_add(1, std::memory_order_relaxed);
continue;
}
Item* item = m_task.load(std::memory_order_acquire);
if (item == nullptr) {
// spin-wait
continue;
}
processOne(item);
m_task.store(nullptr, std::memory_order_release);
m_done.store(true, std::memory_order_release);
}
问题不在 continue,在它让线程保持 runnable。池里有 \(8\) 个 worker,绑在 \(17 \sim 24\),机器一共 \(25\) 个核,所以没任务的时候,\(8\) 个核等于被占着,\(32\%\) 的算力打水漂。把 continue 换成 _mm_pause() 或者 std::this_thread::yield() 都不算解决:前者还在自旋,后者只是让出当前时间片,线程仍是 runnable,会和另一个池的线程分时,核收不回来。只有真正睡眠,才会把它从 runqueue 里摘出去。
浪费的量级,看一组实测更直观。这批数据走的是“重活集中在批量计算阶段”的那种模式:批量阶段一次算完,进到逐个分片的阶段,主线程只剩一轮很轻的登记,\(2277\) 个分片、中位约 \(3\) ms,也就是说这段时间池里基本上没有任务。三组配置里,这个阶段主线程的计算墙钟分别是 \(4.4\) s、\(9.9\) s、\(2.7\) s,而阶段本身长 \(421\) s、\(351\) s、\(184\) s:
单位是核·秒。以 \(4.4\) s 那组为例,就算拿它乘 \(8\) 当作池内计算量的上界,也只有 \(35\) 核·秒左右,两者差两个数量级。这里有口径要说清楚:上面那种模式里,池在逐个分片的阶段里一个任务都没领到;如果换成每个分片都要现算的模式,写阶段它确实要干活,空转只出现在喂不上任务的间隙里,收益不会有这么夸张。
等待条件要覆盖三类工作
池里的工作有三种:主线程单个提交、主线程投一批分片让 worker 用共享索引自己领、以及同样按批领取但执行调用方给的动作(构建期用)。它们共用同一个循环,所以等待条件必须能同时被三者唤醒,不能只盯着单个提交那一支。
Item* singleItem = nullptr;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] {
return !m_running.load(std::memory_order_acquire) ||
m_dynamicRunning.load(std::memory_order_acquire) ||
m_task.load(std::memory_order_acquire) != nullptr;
});
if (!m_running.load(std::memory_order_acquire)) {
return;
}
if (!m_dynamicRunning.load(std::memory_order_acquire)) {
singleItem = m_task.exchange(nullptr, std::memory_order_acq_rel);
}
}
谓词里三个条件分别对应停止位、批次标志、单个提交指针。谓词在持锁状态下求值,wait 睡着时会放开锁、被唤醒后重新拿到,所以醒过来看到的状态是自洽的。单个提交在锁内直接 exchange 取走:旧写法是“读到指针、处理、再置回空”,处理期间如果有人新投一个分片,处理完那次置空会把它抹掉;改成领取即清空,新提交的留在原地等下一轮。
丢唤醒出现在置位与通知之间
改成阻塞之后最容易踩的是丢唤醒:任务已经提交,线程却睡过去了。它发生在“谓词刚判完是假、还没真正进入 wait”这个窗口里,此时通知发出来没有等待者接收,之后就没人再通知了。
把置位和通知放进同一把锁就没有这个窗口:worker 要么在谓词里看到标志为真,要么它在 wait 里让出了锁,提交方拿到锁置位后再通知。
m_dynamicItems = items;
m_dynamicNextIndex = &nextIndex;
m_dynamicDoneCount = &doneCount;
m_dynamicAction = nullptr;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(true, std::memory_order_release);
}
m_cv.notify_all();
批次相关的字段(分片列表、索引指针、计数指针)仍然在锁外写好,靠 m_dynamicRunning 的 release 写与 acquire 读给出可见性;锁只用来保证“置位”和“唤醒”这两步之间不会被等待方插进来。单个提交同理,完成位和任务指针一起在锁内交换,免得等待方看到任务已经给了、完成位还是上一轮的真值。
析构也要按同样的顺序来,先置停止位并通知,再 join:
{
std::lock_guard<std::mutex> lock(m_mutex);
m_running.store(false, std::memory_order_release);
}
m_cv.notify_all();
if (m_worker && m_worker->joinable()) {
m_worker->join();
}
否则 join 会一直停在那个睡着的 worker 上。
两种领取路径保持不变
睡眠只加在“没活”的那一步,批次内的领取照旧,不引入新的锁竞争:
const size_t index = m_dynamicNextIndex->fetch_add(1, std::memory_order_relaxed);
if (index >= m_dynamicItems.size()) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(false, std::memory_order_release);
}
continue;
}
if (m_dynamicAction) {
m_dynamicAction(m_dynamicItems[index]);
} else {
processOne(m_dynamicItems[index]);
}
m_dynamicDoneCount->fetch_add(1, std::memory_order_relaxed);
一个分片的计算量在毫秒级,索引本身用 fetch_add 领就够了,\(8\) 个线程抢同一条缓存行也不值得换成锁;领到越界索引的线程把批次标志清掉,回到等待点,等下一批。整个循环里持锁的只有等待、置完成位、清批次标志这三类动作,每处都只做几个字的原子写。
调用方那三个等待没动:等自己刚投的那个分片算完、等一个批次退出、等一个共享计数到达总数,仍是自旋或 yield。它们等的时间短,而且等的是自己刚提交的活。
改完之后的几组数据
三组配置(写池分别 \(16\)、\(24\)、\(24\) 线程,比对内联)里:
- 读取阶段 \(10.6\) s、\(10.1\) s、\(11.3\) s,改之前是 \(10.8 \sim 11.4\) s。这条最要紧,因为计算池和写池都是在读数据之前就创建并绑核的,改之前它们靠自旋占着核,改之后只是睡着,读取和排序的核数一点没被抢。
- 批量计算阶段 \(47.3 \sim 49.8\) s,改之前 \(46.3 \sim 49.1\) s。从睡眠到被唤醒的延迟没有把计算拖慢,中位仍然稳定。
- 写入的 \(24\) 个线程绑 \(0 \sim 13\)、\(15 \sim 24\),和计算池的 \(17 \sim 24\) 完全重叠。写盘单任务均值 \(6964\) ms,另一种核清单不与第二组池重叠的配置测到 \(6933\) ms,两者基本一样,看不出重叠带来的额外代价。
- 三个轮的整轮时间是 \(305.3\) s、\(326.3\) s、\(330.9\) s。
第三条是这次改动真正的意义:只要空闲线程真的睡着,核清单重叠就不再是问题,同一批核可以在不同阶段换给不同的池用。
让出的核去了哪里
逐个分片的阶段让出来的 \(8\) 个核直接给了写盘池,写线程从 \(16\) 个扩到 \(24\) 个,核清单就和计算池完全叠上了。作为参照,带自旋时“\(16\) 写 + 比对内联”的整轮是 \(436\) s,现在是 \(321\) s 上下——不过这里混了写线程扩容的贡献,不全是这一项的账。
还留着没动的两处。一处是调用方那几个等待,仍是自旋或 yield,批量计算那 \(47\) s 里主线程会空转够一个核,我没有单独量过它的占比,只是从代码看它确实全程在转。另一处更麻烦:把同一个池的线程数从 \(8\) 加到 \(23\),批量计算反而从 \(47\) s 涨到 \(101\) s,线程多了总吞吐却掉了一半,这个还没有查明白,怀疑在分配和缺页那一段,等把阶段计时分开再看。
完整的 worker 等待与唤醒实现:
点击查看代码
// 取任务:单个提交与动态批次共用一个等待点,无活时阻塞,不再自旋占核
while (true) {
Item* singleItem = nullptr;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] {
return !m_running.load(std::memory_order_acquire) ||
m_dynamicRunning.load(std::memory_order_acquire) ||
m_task.load(std::memory_order_acquire) != nullptr;
});
if (!m_running.load(std::memory_order_acquire)) {
return;
}
if (!m_dynamicRunning.load(std::memory_order_acquire)) {
singleItem = m_task.exchange(nullptr, std::memory_order_acq_rel);
}
}
if (singleItem != nullptr) {
processOne(singleItem);
{
std::lock_guard<std::mutex> lock(m_mutex);
m_done.store(true, std::memory_order_release);
}
continue;
}
// 防御:批次字段未就绪时退回等待,避免空指针与忙等
if (m_dynamicNextIndex == nullptr || m_dynamicDoneCount == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(false, std::memory_order_release);
continue;
}
const size_t index = m_dynamicNextIndex->fetch_add(1, std::memory_order_relaxed);
if (index >= m_dynamicItems.size()) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(false, std::memory_order_release);
}
continue;
}
if (m_dynamicAction) {
m_dynamicAction(m_dynamicItems[index]);
} else {
processOne(m_dynamicItems[index]);
}
m_dynamicDoneCount->fetch_add(1, std::memory_order_relaxed);
}
// 单个提交:完成位与任务指针同锁交换
void submit(Item* item) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_done.store(false, std::memory_order_release);
m_task.store(item, std::memory_order_release);
}
m_cv.notify_one();
}
// 动态批次:字段先写、锁内置位、通知
void submitDynamic(const std::vector<Item*>& items, std::atomic<size_t>& nextIndex,
std::atomic<size_t>& doneCount) {
m_dynamicItems = items;
m_dynamicNextIndex = &nextIndex;
m_dynamicDoneCount = &doneCount;
m_dynamicAction = nullptr;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(true, std::memory_order_release);
}
m_cv.notify_all();
}
// 构建期动作:与 submitDynamic 相同,只是登记一个动作
void submitDynamicAction(const std::vector<Item*>& items, std::atomic<size_t>& nextIndex,
std::atomic<size_t>& doneCount, std::function<void(Item*)> action) {
m_dynamicItems = items;
m_dynamicNextIndex = &nextIndex;
m_dynamicDoneCount = &doneCount;
m_dynamicAction = std::move(action);
{
std::lock_guard<std::mutex> lock(m_mutex);
m_dynamicRunning.store(true, std::memory_order_release);
}
m_cv.notify_all();
}
// 析构:先置停止位并唤醒,再 join
~Worker() {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_running.store(false, std::memory_order_release);
}
m_cv.notify_all();
if (m_worker && m_worker->joinable()) {
m_worker->join();
}
}

浙公网安备 33010602011771号