news 2026/7/22 19:26:45

vibe coding + gcc bug 导致的线程池死锁问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vibe coding + gcc bug 导致的线程池死锁问题

个月前,我让 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_

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/22 19:22:23

3499.操作后最大活跃区段数 I:一次遍历(脑筋急转弯)

【LetMeFly】3499.操作后最大活跃区段数 I&#xff1a;一次遍历(脑筋急转弯) 力扣题目链接&#xff1a;https://leetcode.cn/problems/maximize-active-section-with-trade-i/ 给你一个长度为 n 的二进制字符串 s&#xff0c;其中&#xff1a; 1 表示一个 活跃 区段。0 表示…

作者头像 李华
网站建设 2026/7/22 19:21:50

Point Transformers开发者指南:Hydra配置系统与模型调参技巧

Point Transformers开发者指南&#xff1a;Hydra配置系统与模型调参技巧 【免费下载链接】Point-Transformers Point Transformers 项目地址: https://gitcode.com/gh_mirrors/po/Point-Transformers Point Transformers是一个基于Transformer架构的点云处理项目&#x…

作者头像 李华
网站建设 2026/7/22 19:18:44

深度学习基础知识

深度学习基础知识回归1. 线性回归2. Softmax回归3. 其他常见回归模型a. 逻辑回归b. 岭回归 和 Lasso回归c. 多项式回归d. 泊松回归e. Cox回归&#xff08;比例风险模型&#xff09;总结与对比回归 1. 线性回归 线性回归是回归问题中最基础、最直观的模型。 核心思想&#xf…

作者头像 李华
网站建设 2026/7/22 19:16:32

排水管网流量监测系统辅助城市运行管理平台调度决策

每到汛期城市内涝、雨水积淹、污水溢流等问题总会牵动大众关注。在不少人的固有认知里&#xff0c;城市排水治理的核心&#xff0c;无非是新增排水管道、扩建排涝泵站这类硬件建设。但事实上&#xff0c;城市地下排水管网体系错综复杂&#xff0c;管线交错、工况多变&#xff0…

作者头像 李华