AIGC标识 空闲线程从自旋改成阻塞等待

空闲线程从自旋改成阻塞等待

线程池,条件变量,绑核。

计算池的 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:

\[8 \times 421 = 3368 \]

单位是核·秒。以 \(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();
    }
}
posted @ 2026-09-21 17:23  Ke_scholar  阅读(3)  评论(0)    收藏  举报