记一例 vibe coding + gcc bug 导致的线程池死锁问题 故事哦不事故是这样的。话说那还是本人没有广泛使用 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_$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。T2Worker A 进入 _M_acquire()调用 __atomic_wait_address_bare继而进入 _S_do_spin。T3 (快照定格)Worker A 执行 __atomic_load(__addr, _val, …)读取到 sem._M_counter当前值为 1该值作为快照信息被存到局部变量 __val中。T4Worker A 开始进入 __atomic_spin 循环调用 __pred()即 _S_do_try_acquire。T5Worker A 在 _S_do_try_acquire 中读取到 __old 1准备执行 compare_exchange_strong(expected1, desired0)。T6 (关键抢占)在 Worker A 执行 CAS 指令前的一瞬间Worker B 冲入并率先 CAS 成功将 _M_counter 减为 0。在线程池测试中主线程一次性投递 10W 个任务而每个任务极轻几乎瞬间就被执行完。因此两个 worker 线程会疯狂竞争信号量所以信号量被抢占的概率大大增加。T7Worker A 执行 CAS发现内存值已经是 0 而非预期的 1CAS 失败。__pred() 返回 false。T8Worker A 在剩下的 spin 循环中看到的值都是 0自旋周期耗尽_S_do_spin 彻底返回 false。5.3.2 Phase 2Lost WakeupT9Worker 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。因为此时旧值是 00 0 为 false主线程触发了 __atomic_notify_address_bare即 futex_wake。T12然而 Worker A 尚在用户态这次 futex_wake 扑空Lost Wakeup。5.3.3 Phase 3ABA 问题和 _M_release()优化导致永久睡死T13Worker A 终于陷入内核态执行真正的等价操作futex(addr, FUTEX_WAIT, expected1)。T14事实上为了避免错误地陷入睡眠不该睡眠却睡眠了内核是做了兜底的。在真正陷入睡眠的前一刻内核会再次读取 addr指向的内存值如果发现和 expected不相等就立即返回而不是陷入睡眠。但这一检查在这里也失效了因为此时 *addr真的是 1*addr expected 条件完美成立。于是 Worker A 彻底陷入睡眠。这里有一个经典的 ABA 现象Worker A 在调用 _M_acquire()时M_counter的值是 1T2。CAS 竞争时Worker B 将M_counter改为了 0T6。Worker A 陷入睡眠前主线程执行了 sem.release()M_counter加 1 后又变成了 1T11。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 模板、C20/C23 新特性等知识国内外主流模型都是了解的。并且在将这些知识落地方面尽管不同模型写的代码在简洁性、可读性、精确程度[[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 的结论正确但分析过程有瑕疵国产模型的结论就是错的按它的建议修改代码会导致死锁可见在需要“复

本月热点