1 导言
几个月前,我让 AI 帮我写了一个线程池,GitHub Repo。不得不说,就最终结果而言,确实惊艳,和 github 上几个同类线程池项目相比,在多个评估维度上明显领先[1]。但就中间过程而言,也并不全是"眩晕瘫坐,就像看到原子弹爆炸"的既视感,在个别环节上,AI 也会犯错,甚至不知道错在了哪里。
故事,哦不,事故,是这样的。
话说,那还是本人没有广泛使用 Agent 的落后时代,也是可以在 arena.ai 上与 claude opus 4.6 无限对话的美好时代。
有一天,我突发奇想,让 opus 4.6 帮我实现一个 C++ 线程池,于是,它"背"出了那个"C++11 100 行实现线程池"的经典代码(<progschj/ThreadPool>)[2]。
我自然是不满意的,于是让 opus 4.6 分析当前实现有否有性能优化的空间。"啪"地一下,很快啊,它指出,当前实现采用"单一队列 + mutex + cv"的模式,高并发下,会存在激烈的锁争用和严重的系统调用开销,并提出使用"无锁队列 + 任务窃取"的优化方案。
我一看,哎呦,不错哦!虽说是线程池优化的基操,但 AI 能很快地给出来,说明基本的推理能力以及知识的广度还是在线的。那还等什么,麻溜的,开干!用 C++23!测试用例也安排上!
于是,又是"啪"地一下,很快啊,代码都吐出来了。我先在 Windows 试了一下,代码无需任何修改,直接就能跑!
丝滑,真丝滑!厉害,真厉害!完啦,感觉明天我就要被淘汰了!
激动的心,颤抖的手,我点开了虚拟机,想在 Linux 上再感受一波 AI 的暴击。然而,不出意外地出意外了------直接卡死!
1 仅基于我的 benchmark,不代表 AI 版本优于所有同类项目,也不代表在所有方面"完胜"参与对比的其它项目。
2 不光是 opus 4.6,其它 AI 也是如此。
2 死锁现场
那时的我,还没有用上 Agent,也还没有懒到"帮我解决这个bug"的地步。于是,我 gdb attach 上去,很快获得了现场。下面,我将提供此次事故的源码、环境、dgb 信息。
2.1 源码
点击展开 thread_pool.h
cpp
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <atomic>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <semaphore>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(size_t n) : thread_count_(n)
{
queues_.reserve(n);
for (size_t i = 0; i < n; ++i)
queues_.emplace_back(std::make_unique<WorkQueue>());
threads_.reserve(n);
for (size_t i = 0; i < n; ++i)
threads_.emplace_back(&ThreadPool::worker, this, i);
}
~ThreadPool()
{
stop_.store(true, std::memory_order_release);
for (size_t i = 0; i < thread_count_; ++i)
sem_.release();
for (auto& t : threads_)
t.join();
}
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
template <typename F, typename... Args>
requires std::invocable<F, Args...>
void execute(F&& f, Args&&... args)
{
enqueue(std::move_only_function<void()>(
[f = std::forward<F>(f),
...a = std::forward<Args>(args)]() mutable {
std::invoke(std::move(f), std::move(a)...);
}));
}
template <typename F, typename... Args>
auto push(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>>
{
using R = std::invoke_result_t<F, Args...>;
std::packaged_task<R()> pt(
[f = std::forward<F>(f),
...a = std::forward<Args>(args)]() mutable -> R {
return std::invoke(std::move(f), std::move(a)...);
});
auto future = pt.get_future();
enqueue(std::move_only_function<void()>(std::move(pt)));
return future;
}
private:
// ================================================================
// 无锁 MPMC 有界队列 ------ 基于 Dmitry Vyukov 算法
//
// 每个槽位 (Cell) 持有一个原子序列号 seq:
// · seq == pos → 槽位可写入(生产者可用)
// · seq == pos + 1 → 槽位已写入、待消费
// · seq == pos + kCap → 槽位已消费、可供下一轮写入
//
// head_: 生产者竞争的写入游标
// tail_: 消费者竞争的读取游标
// 二者分别对齐到独立缓存行以消除伪共享
// ================================================================
struct alignas(64) WorkQueue {
// 容量必须为 2 的幂;可根据负载调大
static constexpr size_t kCapacity = 4096;
static constexpr size_t kMask = kCapacity - 1;
struct Cell {
std::atomic<size_t> seq;
std::move_only_function<void()> data;
};
WorkQueue() : buf_(new Cell[kCapacity])
{
for (size_t i = 0; i < kCapacity; ++i)
buf_[i].seq.store(i, std::memory_order_relaxed);
}
~WorkQueue() { delete[] buf_; }
WorkQueue(const WorkQueue&) = delete;
WorkQueue& operator=(const WorkQueue&) = delete;
// ---------- 多生产者入队(队满时自旋让步)----------
void push(std::move_only_function<void()> task)
{
size_t pos = head_.load(std::memory_order_relaxed);
for (;;) {
Cell& cell = buf_[pos & kMask];
size_t seq = cell.seq.load(std::memory_order_acquire);
std::ptrdiff_t d = static_cast<std::ptrdiff_t>(seq)
- static_cast<std::ptrdiff_t>(pos);
if (d == 0) {
// 槽位可用------尝试占位
if (head_.compare_exchange_weak(
pos, pos + 1, std::memory_order_relaxed))
{
cell.data = std::move(task);
cell.seq.store(pos + 1, std::memory_order_release);
return;
}
// CAS 失败:pos 已被 compare_exchange_weak 更新为最新值
} else if (d < 0) {
// 队列已满,让出 CPU 后重试
std::this_thread::yield();
pos = head_.load(std::memory_order_relaxed);
} else {
// 其他生产者已推进 head_,重新加载
pos = head_.load(std::memory_order_relaxed);
}
}
}
// ---------- 多消费者出队 ----------
bool try_pop(std::move_only_function<void()>& out)
{
size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
Cell& cell = buf_[pos & kMask];
size_t seq = cell.seq.load(std::memory_order_acquire);
std::ptrdiff_t d = static_cast<std::ptrdiff_t>(seq)
- static_cast<std::ptrdiff_t>(pos + 1);
if (d == 0) {
// 槽位有数据------尝试占位
if (tail_.compare_exchange_weak(
pos, pos + 1, std::memory_order_relaxed))
{
out = std::move(cell.data);
cell.seq.store(pos + kCapacity,
std::memory_order_release);
return true;
}
// CAS 失败:pos 已被更新
} else if (d < 0) {
return false; // 队列为空
} else {
// 其他消费者已推进 tail_,重新加载
pos = tail_.load(std::memory_order_relaxed);
}
}
}
// ---------- 窃取 = 出队(MPMC 只有一个消费端)----------
bool try_steal(std::move_only_function<void()>& out)
{
return try_pop(out);
}
private:
Cell* buf_;
alignas(64) std::atomic<size_t> head_{0}; // 生产者游标
alignas(64) std::atomic<size_t> tail_{0}; // 消费者游标
};
// ===================== 任务分发 =====================
void enqueue(std::move_only_function<void()> task)
{
if (stop_.load(std::memory_order_acquire)) [[unlikely]]
throw std::runtime_error("thread pool has stopped");
static thread_local size_t local_next =
std::hash<std::thread::id>{}(std::this_thread::get_id());
const size_t target =
(local_pool_ == this)
? local_id_
: local_next++ % thread_count_;
queues_[target]->push(std::move(task));
if (sleeping_.load(std::memory_order_seq_cst) > 0)
sem_.release();
}
// ============ 先查自己队列,再窃取邻居 ============
bool try_pop_or_steal(size_t id,
std::move_only_function<void()>& task)
{
if (queues_[id]->try_pop(task)) return true;
for (size_t i = 1; i < thread_count_; ++i)
if (queues_[(id + i) % thread_count_]->try_steal(task))
return true;
return false;
}
// ================ 工作线程循环 ================
void worker(size_t id)
{
local_pool_ = this;
local_id_ = id;
while (true) {
std::move_only_function<void()> task;
if (try_pop_or_steal(id, task)) { task(); continue; }
if (stop_.load(std::memory_order_acquire)) return;
sleeping_.fetch_add(1, std::memory_order_seq_cst);
if (try_pop_or_steal(id, task)) {
sleeping_.fetch_sub(1, std::memory_order_relaxed);
task();
continue;
}
if (stop_.load(std::memory_order_acquire)) {
sleeping_.fetch_sub(1, std::memory_order_relaxed);
return;
}
sem_.acquire();
sleeping_.fetch_sub(1, std::memory_order_relaxed);
}
}
// ================ 成员变量 ================
const size_t thread_count_;
std::vector<std::unique_ptr<WorkQueue>> queues_;
std::vector<std::thread> threads_;
alignas(64) std::atomic<bool> stop_{false};
alignas(64) std::atomic<int> sleeping_{0};
alignas(64) std::counting_semaphore<> sem_{0};
static inline thread_local ThreadPool* local_pool_ = nullptr;
static inline thread_local size_t local_id_ = 0;
};
#endif // THREAD_POOL_H
点击展开 main.cpp
cpp
#include "thread_pool.h"
#include <atomic>
#include <cmath>
#include <iostream>
#include <cassert>
#include <random>
#include <chrono>
#include <format>
#include <functional>
#include <string>
#include <algorithm>
#include <latch>
void run_bench(const std::string& name,
std::function<void()> fn,
int warmup = 2,
int repeats = 10)
{
for (int i = 0; i < warmup; ++i) fn();
std::vector<double> samples(repeats);
for (int i = 0; i < repeats; ++i) {
auto t0 = std::chrono::steady_clock::now();
fn();
auto t1 = std::chrono::steady_clock::now();
samples[i] = std::chrono::duration<double, std::milli>(t1 - t0).count();
}
double sum = std::accumulate(samples.begin(), samples.end(), 0.0);
double mean = sum / repeats;
double sq = 0;
for (auto s : samples) sq += (s - mean) * (s - mean);
double stddev = std::sqrt(sq / repeats);
std::cout << std::format(
" {:>20s} | mean {:>10.3f} ms | std {:>8.3f} ms |"
" min {:>10.3f} ms | max {:>10.3f} ms | iters {:>3d}\n",
name, mean, stddev,
*std::min_element(samples.begin(), samples.end()),
*std::max_element(samples.begin(), samples.end()),
repeats);
}
void bench_fire_and_forget(int task_count) {
std::atomic<int> counter{0};
{
ThreadPool pool(std::thread::hardware_concurrency());
for (int i = 0; i < task_count; ++i) {
[[maybe_unused]] auto f = pool.push([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
});
}
}
}
int main() {
run_bench("Fire-and-forget", [&] { bench_fire_and_forget(100'000); }, 2, 10);
return 0;
}
2.2 环境信息
- OS:Ubuntu 虚拟机
bash
$ uname -a
Linux user1 5.15.0-171-generic #181-Ubuntu SMP Fri Feb 6 22:44:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
- g++ 版本:
bash
$ g++ --version
g++ (Ubuntu 15.2.0-15ubuntu1~22~ppa2) 15.2.0
Copyright (C) 2025 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
- 编译命令:
g++ -std=c++23 -O0 -g -fno-omit-frame-pointer -o bench main.cpp - 执行命令:
./bench std::thread::hardware_concurrency()输出:2
2.3 gdb 调试信息
点击展开 gdb 调试信息
plain
(gdb) info thread
Id Target Id Frame
* 1 Thread 0x7f41af8f4780 (LWP 1410) "bench" __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:57
2 Thread 0x7f41af891640 (LWP 1423) "bench" syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
(gdb) bt 6
#0 __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:57
#1 __futex_abstimed_wait_common (cancel=true, private=128, abstime=0x0, clockid=0, expected=1423, futex_word=0x7f41af891910) at ./nptl/futex-internal.c:87
#2 __GI___futex_abstimed_wait_cancelable64 (futex_word=futex_word@entry=0x7f41af891910, expected=1423, clockid=clockid@entry=0, abstime=abstime@entry=0x0, private=private@entry=128) at ./nptl/futex-internal.c:139
#3 0x00007f41af98f624 in __pthread_clockjoin_ex (threadid=139920094598720, thread_return=0x0, clockid=0, abstime=0x0, block=<optimized out>) at ./nptl/pthread_join_common.c:105
#4 0x00007f41afd27a1b in std::thread::join() () from /lib/x86_64-linux-gnu/libstdc++.so.6
#5 0x0000558ba2cb5f14 in ThreadPool::~ThreadPool (this=0x7ffee66d4940) at /home/xxx/code/thread/thread_pool.h:33
(More stack frames follow...)
(gdb) t 2
[Switching to thread 2 (Thread 0x7f41af891640 (LWP 1423))]
#0 syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
38 ../sysdeps/unix/sysv/linux/x86_64/syscall.S: No such file or directory.
(gdb) bt 6
#0 syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
#1 0x0000558ba2cb9fc9 in std::__detail::__platform_wait<int> (__addr=0x7ffee66d4a00, __val=1) at /usr/include/c++/15/bits/atomic_wait.h:114
#2 0x0000558ba2cbb0f2 in std::__atomic_wait_address_bare<std::__atomic_semaphore::_M_acquire()::{lambda()#1}>(int const*, std::__atomic_semaphore::_M_acquire()::{lambda()#1}) (__addr=0x7ffee66d4a00, __pred=...) at /usr/include/c++/15/bits/atomic_wait.h:451
#3 0x0000558ba2cbd052 in std::__atomic_semaphore::_M_acquire (this=0x7ffee66d4a00) at /usr/include/c++/15/bits/semaphore_base.h:202
#4 std::counting_semaphore<2147483647l>::acquire (this=0x7ffee66d4a00) at /usr/include/c++/15/semaphore:77
#5 0x0000558ba2cb7105 in ThreadPool::worker (this=0x7ffee66d4940, id=0) at /home/xxx/code/thread/thread_pool.h:228
(More stack frames follow...)
(gdb) f 5
#5 0x0000558ba2cb7105 in ThreadPool::worker (this=0x7ffee66d4940, id=0) at /home/xxx/code/thread/thread_pool.h:228
228 sem_.acquire();
(gdb) p sleeping_
$4 = std::atomic<int> = { 1 }
(gdb) p stop_
$5 = std::atomic<bool> = { true }
(gdb) p &sem_
$1 = (std::counting_semaphore<2147483647> *) 0x7ffee66d4a00
(gdb) p sem_
$6 = {_M_sem = {static _S_max = 2147483647, _M_counter = 52993}}
3 诊断过程
很明显,一个 worker 线程卡在了 sem_.acquire(),导致主线程也卡在了析构函数的 std::thread::join()。但 worker 线程为什么会卡住,我却不知道了。那问 AI 呗,我把栈都抓出来了,剩下的交给 AI,还不是手拿把掐?
然而,问题喂给 opus 4.6 后,它开始疯狂思考,"等等,我再看一遍","让我再检查 xxx",......,直到平台返回错误码。(我猜测是思考太多,触发了 claude 或者 arena.ai 的限制)
我又把同样的问题丢给 GPT 和 Gemini,它们倒是给出了答案,但一试,全都不对。
最后,您猜怎么着?谁解决了这个问题?
是 Grok!
惊不惊喜?意不意外?"啪"地一下,Grok 告诉我,代码没问题,是 libstdc++ std::counting_semaphore::acquire() 的已知 bug,GCC PR104928!
眩晕瘫坐,原子弹爆炸!
作为事后诸葛亮,我忽然明白了,为什么 opus 4.6 "卡死"了,因为它和我一样,压根没往"gcc 自身 bug"方面去想,反而在一遍遍疯狂审查一份压根就不存在逻辑问题的代码!
为什么 Grok 做对了?它是这样想的:
- 代码逻辑没有问题!
sem_._M_counter = 52993(非 0 值),但sem_.acquire()却陷入了wait,这不正常!- 我去找找是否有
std::counting_semaphore::acquire()的已知 bug。 - 找到了!和当前问题对得上,就是它!
现在回过头来想想,这不就是排查这类问提的正常套路吗?只要注意到了第 2 步的异常,剩下的不是顺理成章,水到渠成吗?很可惜,我没有注意到,更没敢怀疑是 GCC 自己的问题。我真傻,真的。
一个 AI 有一个 AI 的长处,联网搜索这一块,不得不说,Grok 还是能打的。
4 PR104928 bug 分析
4.1 背景知识
为帮助读者理解后文的 bug 分析,这里简单介绍必要知识。
std::counting_semaphore配合其 release()和 acquire()方法,可以实现一种事件通知机制。
release():- 逻辑上相当于"发放通行证",只有获得通行证的线程,才可以做某种动作,比如访问共享资源。
- 底层实现上,会对计数器
_M_counter原子加 1,表示"发放 1 张通行证"。同时,如果_M_counter加 1 前的值是 0,意味着可能有其它线程正在等待通行证(陷入了睡眠),因此,会执行 notify 操作以唤醒正在等待的线程。
acquire():- 逻辑上相当于"获取通行证"。
- 底层实现上,会通过 CAS 操作对计数器
_M_counter原子减 1,表示"抢占 1 张通行证"。如果 CAS 操作之前_M_counter == 0,表明"没有可用通行证",线程就会wait(),等待生产者发放通行证时被唤醒。换言之,在没有 bug 的前提下,若线程在调用acquire()时陷入睡眠,必然有_M_counter == 0。
以上只是 std::counting_semaphore的冰山一角,其它内容因与本文主题无关,故不做介绍,感兴趣的读者请自行学习。
4.2 修复前代码
acquire()的底层实现是 _M_acquire()。
cpp
_GLIBCXX_ALWAYS_INLINE void
_M_acquire() noexcept
{
auto const __vfn = [this]{ return _S_get_current(&this->_M_counter); };
auto const __pred = [this](__count_type __cur) {
return _S_do_try_acquire(&this->_M_counter, __cur); // 关键:predicate 里做 CAS
};
std::__atomic_wait_address(&_M_counter, __pred, __vfn, true); // 直接等待
}
_S_do_try_acquire的实现是:
cpp
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__count_type* __counter, __count_type __old) noexcept
{
if (__old == 0)
return false;
return __atomic_impl::compare_exchange_strong( // CAS
__counter, __old, __old - 1,
memory_order::acquire, memory_order::relaxed);
}
不难看出:
-
_M_acquire()的核心就是执行std::__atomic_wait_address,根据源码注释的描述,如果__pred(__vfn)的结果是false,那么std::__atomic_wait_address就会wait在&_M_counter这个地址上。 -
__vfn就是用来加载_M_counter的。 -
__pred是一个基于_M_counter做判断的谓词(Predicate):- 如果
_M_counter的旧值(__vfn读到的那个)已经是 0 了,不能再减,直接返回false。 - 否则,通过 CAS 操作(
__atomic_impl::compare_exchange_strong)尝试将_M_counter减 1,返回 CAS 结果(如果减 1 成功返回true,否则返回false)。
- 如果
-
std::__atomic_wait_address根据__pred返回结果决定是否wait。
4.3 bug 触发
根因:
bug 出在 __pred的实现上。作为一个 Predicate,__pred理论上应该是一个 Pure Function,并且应该是 No Side Effects 的,即除了返回 true/false外,它不应该修改输入或者全局状态。但这里,GCC 犯了一个教科书级别的错误:在 __pred中使用 CAS 修改计数器 _M_counter!
过程:
- 在高并发场景下,假设线程 A 在执行 CAS 操作前的一瞬间,另一个线程改了
_M_counter的值(生产者线程执行了sem_.release(),或者其它执行sem_.acquire()的 worker 线程 CAS 成功),导致线程 A 的 CAS 失败。 - 于是,
false沿std::__atomic_compare_exchange_strong-->_S_do_try_acquire-->__pred一路返回给std::__atomic_wait_address。 - 线程 A 陷入睡眠。如果此后再没有线程触发 notify,线程 A 将永久睡死!
4.4 bug 修复
修复后代码:
cpp
void _M_acquire() noexcept
{
auto const __vfn = [this]{ return _S_get_current(&this->_M_counter); };
auto __val = __vfn();
// 注意,这里按引用捕获 __val
auto const __pred = [&__val](__count_type __cur) {
if (__cur > 0)
{
__val = __cur; // 一个很有意思的细节,后面解释
return true;
}
return false;
};
while (!_S_do_try_acquire(&_M_counter, __val))
if (__val == 0)
std::__atomic_wait_address(&_M_counter, __pred, __vfn, true);
}
// 另外的修改是,_S_do_try_acquire 的第二个参数 __old 由传值改为传引用
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__count_type* __counter, __count_type& __old) noexcept
{ /* ... */}
对于不想深究细节的读者,只需明白,核心修复就是让 __pred恢复一个 Predicate 该有的样子,把 CAS 操作移出去。(注:__val是局部变量,__val = __cur不违背"不应该修改输入或者全局状态"的约束)。
想要深入了解的读者,请接着往下看。
要更好地理解这个修复,需要了解两点信息:
- 抛开 bug 不谈,按照设计预期,只要调用了
std::counting_semaphore::acquire(),就一定会对计数器_M_counter减 1:- 要么
_M_counter大于 0 时 CAS 成功(已减 1),acquire()直接返回。 - 要么发现
_M_counter等于 0,睡眠等待,被唤醒后再减 1。
- 要么
std::__atomic_wait_address中,线程被唤醒后,还会再调用__pred(__vfn())(逻辑上是这样,实际代码不是这么写的),详见源码。
修复前的逻辑(没意识到 bug 的视角):
- 如果
__pred返回true,说明 CAS 中减 1 成功,_M_acquire()直接返回。 - 如果
__pred返回false:- 说明
_M_counter为 0,陷入睡眠。(命中 bug: 写这份代码的人没有意识到,并发竞争可能导致 CAS 失败,返回false,但此时_M_counter > 0) - 在
std::__atomic_wait_address内部,线程被唤醒后会再次执行__pred,此时会再次通过 CAS 做减 1 操作。若_pred返回false,就继续睡;否则,acquire()结束,从用户视角看,线程真的被唤醒。
- 说明
修复后的逻辑:
- 先执行
while循环中的条件_S_do_try_acquire,注意两个关键事实,它们保证了_S_do_try_acquire函数退出后,__val一定保存了_M_counter的最新值。_S_do_try_acquire中,__val按引用传递。- 对于
__atomic_impl::compare_exchange_strong(__counter, __old, __old - 1, ...),如果 CAS 失败,__old会被更新为__counter指向的内存(即_M_countdr)的最新值,这是 C++ 下 CAS 操作的一个特性。
- 如果
_S_do_try_acquire返回true,说明通过 CAS 减 1成功,while (!_S_do_try_acquire(&_M_counter, __val))不命中,_M_acquire()直接结束。 - 否则,进入到
while循环的内部。如前所述,此时__val保存了_M_counter的最新值。- 如果
_val == 0,说明_M_counter可能一开始就是 0(根本没进入 CAS);或者别的线程 CAS 成功,将其由 1 改成了 0,当前线程 CAS 失败。但不管哪种情况,当前线程不得不进入睡眠(通行证为 0,啥也干不了)。- 当线程被唤醒,会再次进入
while循环,再次执行_S_do_try_acquire,如果_S_do_try_acquire返回true,说明减 1 成功,_M_acquire()直接结束;否则接着进入if (__val == 0)的逻辑,继续睡眠......
- 当线程被唤醒,会再次进入
- 否则,说明当前线程在 CAS 竞争中失败了(不然
_S_do_try_acquire不会返回false),被别人抢先拿走了通行证,但剩余通行证数量不为0,于是再次进入while循环,继续争抢下一张通行证。
- 如果
一个细节:
修改后的代码,lambda表达式 __pred按引用捕获了 __val,并且当 __cur(即 _M_counter的最新值)大于 0 时,将其赋值给 __val。这有什么作用呢?
前面说过,在 std::__atomic_wait_address中,当线程被唤醒时,会再次执行调用 __pred(__vfn())。在 __pred内部将 _M_counter最新值赋值给 __val,意味着,当线程从 std::__atomic_wait_address中退出,回到 while (!_S_do_try_acquire(&_M_counter, __val))时,__val的值就是 _M_counter的最新值,这省去了一次 atomic load,是一个性能优化。否则,代码就需要这样写:
cpp
void _M_acquire() noexcept
{
// 其它保持不变...
// 如果 __pred 内部不执行 __var = __cur
auto const __pred = [](__count_type __cur) {
if (__cur > 0) {
return true;
}
return false;
};
while (!_S_do_try_acquire(&_M_counter, __val))
if (__val == 0) {
std::__atomic_wait_address(&_M_counter, __pred, __vfn, true);
__val = __vfn(); // 那么,这里就必须重新加载 _M_counter
}
}
5 线程池死锁分析
5.1 Ubantu libstdc++ 代码段
我的代码是在 Ubantu 系统上构建的,与 PR104928 在细节上有些不一样。下面,我将提供 Ubantu 上 libstdc++ 相关代码段,这些代码与 gdb 调试信息中引用的代码完全一致。
点击展开 semaphore_base.h 中的代码段
cpp
static _GLIBCXX_ALWAYS_INLINE bool
_S_do_try_acquire(__detail::__platform_wait_t* __counter) noexcept
{
auto __old = __atomic_impl::load(__counter, memory_order::acquire);
if (__old == 0)
return false;
return __atomic_impl::compare_exchange_strong(
__counter, __old, __old - 1,
memory_order::acquire,
memory_order::relaxed);
}
_GLIBCXX_ALWAYS_INLINE void
_M_acquire() noexcept
{
auto const __pred =
[this] { return _S_do_try_acquire(&this->_M_counter); };
std::__atomic_wait_address_bare(&_M_counter, __pred);
}
_GLIBCXX_ALWAYS_INLINE void
_M_release(ptrdiff_t __update) noexcept
{
// 注:fetch_add 返回的是自增前的值
if (0 < __atomic_impl::fetch_add(&_M_counter, __update, memory_order_release))
return;
if (__update > 1)
__atomic_notify_address_bare(&_M_counter, true);
else
__atomic_notify_address_bare(&_M_counter, true);
// FIXME - Figure out why this does not wake a waiting thread
// __atomic_notify_address_bare(&_M_counter, false);
}
点击展开 atomic_wait.h 中的代码段
cpp
template<typename _Pred,
typename _Spin = __default_spin_policy>
static bool
_S_do_spin(const __platform_wait_t* __addr,
_Pred __pred,
__platform_wait_t& __val,
_Spin __spin = _Spin{ })
{
__atomic_load(__addr, &__val, __ATOMIC_ACQUIRE);
return __atomic_spin(__pred, __spin);
}
template<typename _Pred>
void
__atomic_wait_address_bare(const __detail::__platform_wait_t* __addr,
_Pred __pred) noexcept
{
#ifdef _GLIBCXX_HAVE_PLATFORM_WAIT
do {
__detail::__platform_wait_t __val;
if (__detail::__bare_wait::_S_do_spin(__addr, __pred, __val))
return;
__detail::__platform_wait(__addr, __val);
} while (!__pred());
#else // !_GLIBCXX_HAVE_PLATFORM_WAIT
__detail::__bare_wait __w(__addr);
__w._M_do_wait(__pred);
#endif
}
5.2 关键信息
结合上述代码段,以及 gdb 打印的栈帧,可以发现以下关键信息:
信息1:worker 线程等在哪里
worker 线程 #1 栈帧:
#1 0x0000558ba2cb9fc9 in std::__detail::__platform_wait
<int>(__addr=0x7ffee66d4a00, __val=1) at /usr/include/C++/15/bits/atomic_wait.h:114
sem_地址:
(gdb) p &sem_
$1 = (std::counting_semaphore<2147483647> *) 0x7ffee66d4a00
结合 std::__atomic_wait_address_bare源码,不难看出,死锁发生时,worker 线程卡在 __platform_wait(&sem_._M_counter, 1)上。
信息2: sem_计数值
当形成死锁局面(worker 线程在 wait(),主线程在 join())时,sem_._M_counter值为 52993。
(gdb) p sem_
$6 = {_M_sem = {static_S_max = 2147483647,_M_counter = 52993}}
5.3 死锁时间线
结合线程池代码、Ubantu libstdc++ 代码段、gdb 调试信息、PR14928,让我们来看看具体的死锁时间线。
5.3.1 Phase 1:快照读取与 CAS 失败
- T1 :假设线程池运行到某个时刻时,刚好
_M_counter == 1。 - T2 :Worker A 进入
_M_acquire(),调用__atomic_wait_address_bare,继而进入_S_do_spin。 - T3 (快照定格) :Worker A 执行
__atomic_load(__addr, &__val, ...),读取到sem_._M_counter当前值为 1,该值作为快照信息被存到局部变量__val中。 - T4 :Worker A 开始进入
__atomic_spin循环,调用__pred(),即_S_do_try_acquire。 - T5 :Worker A 在
_S_do_try_acquire中读取到__old = 1,准备执行compare_exchange_strong(expected=1, desired=0)。 - T6 (关键抢占) :在 Worker A 执行 CAS 指令前的一瞬间,Worker B 冲入并率先 CAS 成功,将
_M_counter减为0。(在线程池测试中,主线程一次性投递 10W 个任务,而每个任务极轻,几乎瞬间就被执行完。因此,两个 worker 线程会疯狂竞争信号量,所以,信号量被抢占的概率大大增加。) - T7 :Worker A 执行 CAS,发现内存值已经是
0而非预期的1,CAS 失败。__pred()返回false。 - T8 :Worker A 在剩下的 spin 循环中,看到的值都是
0,自旋周期耗尽,_S_do_spin彻底返回false。
5.3.2 Phase 2:Lost Wakeup
- T9 :Worker A 退出自旋,准备执行
__detail::__platform_wait(__addr, __val)。注意,此时__val依然是 T3 时期快照下来的1。 - T10 :就在 Worker A 准备发起
futex系统调用但尚未进入内核态 的刹那,主线程(Producer) 推入了一个新任务,并调用了sem_.release()。 - T11 :主线程执行
_M_release(),调用fetch_add将_M_counter从0变回1。因为此时旧值是0(0 < 0为false),主线程触发了__atomic_notify_address_bare(即futex_wake)。 - T12 :然而 Worker A 尚在用户态,这次
futex_wake扑空(Lost Wakeup)。
5.3.3 Phase 3:ABA 问题和 _M_release()优化导致永久睡死
-
T13 :Worker A 终于陷入内核态,执行真正的等价操作:
futex(addr, FUTEX_WAIT, expected=1)。 -
T14 :事实上,为了避免错误地陷入睡眠(不该睡眠却睡眠了),内核是做了兜底的。在真正陷入睡眠的前一刻,内核会再次读取
addr指向的内存值,如果发现和expected不相等,就立即返回而不是陷入睡眠。但这一检查在这里也失效了,因为此时*addr真的是 1,*addr == expected条件完美成立。于是 Worker A 彻底陷入睡眠。这里有一个经典的 ABA 现象:- Worker A 在调用
_M_acquire()时,_M_counter的值是 1(T2)。 - CAS 竞争时,Worker B 将
_M_counter改为了 0(T6)。 - Worker A 陷入睡眠前,主线程执行了
sem_.release(),_M_counter加 1 后又变成了 1(T11)。
- Worker A 在调用
-
T15 :主线程继续推入剩余的任务,由于此时 Worker A 在睡眠,
sleeping_值为 1,满足sleeping_ > 0的条件,于是,主线程不断调用sem_.release()。 -
T16 (优化帮倒忙) :每次主线程调用
_M_release(),fetch_add返回的旧值依次是 1, 2, 3... 直到 52992。因为所有旧值都> 0,主线程每次都命中if (0 < __atomic_impl::fetch_add(...)) return;这条快速路径,彻底跳过了__atomic_notify_address_bare。 -
T17 :现在只剩 Worker B 在工作,它完成上一个任务后,几乎总是能拿到下一个任务,不会再调用
sem_.acquire()[3],即使偶尔调用,也不能把_M_counter从几万降到 0。因此,所有的sem_.release(),包括析构函数中的两次,都会在_M_release()内部走到快速返回路径,根本不会触发 notify, Worker A 永久睡死。
3 注意:在线程池的设计中,sem_.acquire()与 sem_.release()并不是 1:1 的。只要有 worker 在 sleep,生产者线程在 push 任务时就会调用 sem_.release()来唤醒 worker 线程;而对 worker 线程而言,只有在拿不到任务(从自己的队列 & 从别人的队列)时,才会执行 sem_.acquire()。这种设计,在 _M_acquire()没有 bug 时,是没有问题的,即 _M_acquire()只有在 _M_counter_为 0 时才会陷入睡眠,这样,后序的 _M_release()一定会唤醒它。
6 总结
基于实现线程池的整个过程(不限于这个死锁 bug),总结以下个人主观体验。
6.1 LLM 擅长干什么?
超级浏览器,知识的搬运工 。LLM 掌握了远超人类的知识,并能基本正确地运用这些知识。在知识的搜集与运用方面,LLM 远胜人类,当然,效率也远胜人类。
例如,对于线程池的常规优化方案(无锁队列、工作窃取等)、常见的无锁队列(Vyukov's MPMC Queue 和 Chase-Lev Deque 等)、复杂的 C++ 模板、C++20/C++23 新特性等知识,国内外主流模型都是了解的。并且,在将这些知识落地方面,尽管不同模型写的代码在简洁性、可读性、精确程度([[nodiscard]],noexcept的使用等)上存在细微差距,但都能跑,不需要人工反复调试。
6.2 LLM 在哪些地方做的还不够好?
6.2.1 精细化和 100% 正确
线程池这样的项目,看起来不大,连测试代码算上,也不过 1000 来行,更没有复杂的业务逻辑或者创新的技术。但是,你依然不能说它简单。
- 你需要考虑各种并发场景以避免 Lost Wakeup 或其它形式的死锁;
- 你需要仔细斟酌每一处内存序(Memory Order)的使用以便在安全的前提下最大化效率;
- 你需要了解 C++ 并发编程中的 memory visibility and execution order (Sequenced-Before, Happens-Before, Synchronizes-With, etc.);
- 你需要了解编译器重排以及不同架构(x86 / ARM)下的 CPU 指令重排、缓存一致性协议、Store Buffer、Invalidate Queue。
要真正写好一个线程池,需要处理好上述每一个问题,这需要 LLM 做精细的控制和复杂的推理。比如,将 xxx 的内存序由 memory_order_acquire改为 memory_order_relaxed行不行?会不会破坏内存可见性保证?会不会引发死锁?是否考虑了所有的并发情况?
以上这些,即便是头部模型,也依然做得不够好。换言之,写出一个能 run 的线程池,主流模型都可以做到。但是,写出一个没有死锁隐患、内存序控制得恰到好处的线程池,鲜有模型可以做到。
6.2.2 LLM 有和人类一样的通病
6.2.2.1 过度强化知识点
不知读者是否有过和我一样的经历:哎,这题我会,这不是那个 xxx 知识点嘛!结果,题做错了。
这一点上,LLM 的表现很像人类,在"脑海"中过度强化了某些"划重点"的知识并在以下两种情况下错误地运用它们:
- 这个场景我熟,看已知信息,不就是 xxx 嘛!结果,忽略了题目的细节和差异性,用错了知识点,答错了问题。
- 这题我真不会了,但我掌握的知识是 xxx,没办法,死马当活马医吧,生搬硬套,凑个答案吧。
举个例子,Gemini 3.1 Pro Priview,在解决 memory order 相关问题时,过分强化了" Dekker 算法解决 Store-Load 重排"的知识点,错误地识别了场景,导致选择了过于严格的 memory order。
6.2.2.2 不敢质疑权威
本文所述的事件就是一个典型案例,Opus 4.6 疯狂审查自己的代码,也没有怀疑是标准库的 bug。
6.3 哪些方面能拉开不同模型的差距?
- 编码 :小有差距。如前所述,这不是对错的差距,只是质量的差距。同样的编码任务(比如写一个通用的 benchmark 框架,把对不同线程池的调用统一起来),Opus 4.6 可以用最新的 C++ 特性,用最简洁的方法实现;而 Gemini 3.1 Pro Priview 和 GLM 5.1(那时还没有 GLM 5.2)也能完成任务,但代码要复杂很多。
- 推理 :大有差距。这是对与错的差距。比如,我说"依次审查所有内存序的使用,看是否有死锁隐患,或者可以安全降级的地方"。Opus 4.6 的分析和结论全部正确;Gemini 3.1 Pro Priview 的结论正确,但分析过程有瑕疵;国产模型的结论就是错的,按它的建议修改代码,会导致死锁!可见,在需要"复杂思考"的问题上,国产模型还是差点意思。
6.4 经验
- 把问题分得再细点,再小点,给模型提供更加详尽的信息,一次只解决一个具体问题。
- 同一个复杂问题,多问几个模型。
- 传统艺能不能丢,AI 时代需要克服思维惰性,多动脑子,想想"假如没有 AI,我该如何解决这个问题"。