个月前我让 AI 帮我写了一个线程池GitHub Repo。不得不说就最终结果而言确实惊艳和 github 上几个同类线程池项目相比在多个评估维度上明显领先[1]。但就中间过程而言也并不全是“眩晕瘫坐就像看到原子弹爆炸”的既视感在个别环节上AI 也会犯错甚至不知道错在了哪里。故事哦不事故是这样的。话说那还是本人没有广泛使用 Agent 的落后时代也是可以在 arena.ai 上与 claude opus 4.6 无限对话的美好时代。有一天我突发奇想让 opus 4.6 帮我实现一个 C 线程池于是它“背”出了那个“C11 100 行实现线程池”的经典代码progschj/ThreadPool[2]。我自然是不满意的于是让 opus 4.6 分析当前实现有否有性能优化的空间。“啪”地一下很快啊它指出当前实现采用“单一队列 mutex cv”的模式高并发下会存在激烈的锁争用和严重的系统调用开销并提出使用“无锁队列 任务窃取”的优化方案。我一看哎呦不错哦虽说是线程池优化的基操但 AI 能很快地给出来说明基本的推理能力以及知识的广度还是在线的。那还等什么麻溜的开干用 C23测试用例也安排上于是又是“啪”地一下很快啊代码都吐出来了。我先在 Windows 试了一下代码无需任何修改直接就能跑丝滑真丝滑厉害真厉害完啦感觉明天我就要被淘汰了激动的心颤抖的手我点开了虚拟机想在 Linux 上再感受一波 AI 的暴击。然而不出意外地出意外了——直接卡死[1] 仅基于我的 benchmark不代表 AI 版本优于所有同类项目也不代表在所有方面“完胜”参与对比的其它项目。[2] 不光是 opus 4.6其它 AI 也是如此。2 死锁现场那时的我还没有用上 Agent也还没有懒到“帮我解决这个bug”的地步。于是我 gdb attach 上去很快获得了现场。下面我将提供此次事故的源码、环境、dgb 信息。2.1 源码点击展开 thread_pool.h点击展开 main.cpp2.2 环境信息OSUbuntu 虚拟机$ uname -aLinux user1 5.15.0-171-generic #181-Ubuntu SMP Fri Feb 6 22:44:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linuxg 版本$ g --versiong (Ubuntu 15.2.0-15ubuntu122ppa2) 15.2.0Copyright © 2025 Free Software Foundation, Inc.This is free software; see the source for copying conditions. There is NOwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.编译命令g -stdc23 -O0 -g -fno-omit-frame-pointer -o bench main.cpp执行命令./benchstd::thread::hardware_concurrency()输出22.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() 的已知 bugGCC 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();// 注意这里按引用捕获 __valauto 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 成功已减 1acquire()直接返回。要么发现 _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 __curauto 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 打印的栈帧可以发现以下关键信息信息1worker 线程等在哪里worker 线程 #1 栈帧#1 0x0000558ba2cb9fc9 in std::__detail::__platform_wait (__addr0x7ffee66d4a00, __val1) at /usr/include/C/15/bits/atomic_wait.h:114sem_地址(gdb) p sem_$1 (std::counting_semaphore2147483647 *) 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_