个月前,我让 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
点击展开 main.cpp
2.2 环境信息
OS:Ubuntu 虚拟机
$ 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++ 版本:
$ g++ --version
g++ (Ubuntu 15.2.0-15ubuntu122ppa2) 15.2.0
Copyright © 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 调试信息
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()。
_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的实现是:
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 修复
对应 commit
修复后代码:
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,是一个性能优化。否则,代码就需要这样写:
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 中的代码段
点击展开 atomic_wait.h 中的代码段
5.2 关键信息
结合上述代码段,以及 gdb 打印的栈帧,可以发现以下关键信息:
信息1:worker 线程等在哪里
worker 线程 #1 栈帧:
#1 0x0000558ba2cb9fc9 in std::__detail::__platform_wait (__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_