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 的结论正确但分析过程有瑕疵国产模型的结论就是错的按它的建议修改代码会导致死锁可见在需要“复杂思考”的问题上国产模型还是差点意思。6.4 经验把问题分得再细点再小点给模型提供更加详尽的信息一次只解决一个具体问题。同一个复杂问题多问几个模型。