X86 架构上乐呵呵,ARM 架构下炸了锅


背景

ARM (aarch64) 生产环境偶发 core dump,栈上是 glibc 的 _int_free,报错固定:

*** glibc detected *** double free or corruption (fasttop): 0x... ***

被重复释放的是一个 TAILQ 双向链表中的一个元素。X86 环境相同代码下没有出现过这个 coredump,ARM 环境在特殊情况下偶现。

问题调查

遇到 coredump,首先让 AI 通过我之前搞的 gdb_mcp 看看。AI 经过几轮分析,给出了它第一次的分析结论:在遍历 TAILQ 链表释放链表中的元素时,没加锁导致线程间并发访问,从而进一步导致了 double free 问题。因为之前并未看过协议栈链接管理这一块的代码实现,我刚开始还信了 AI 的分析结论。但在仔细阅读并分析了一下业务代码后,我发现事情并没有这么简单...

涉及的对象与时序

由于业务本身的代码逻辑比较复杂,为了更好的表述问题和方便理解,笔者这里对流程做了一下简化,如下图所示。首先定义参与者 为 IO 和 Proxy 两个线程。共享的对象和基本作用如下:

  • ah:共享对象,被 IO / proxy 两个线程各持有一份引用。
  • ah.refcnt:引用计数,__atomic_sub_fetch(RELEASE) 递减,减到 0 由最后一个持有者析构。
  • ah.event_listah 上挂的事件队列(TAILQ)。
  • ah.lock:保护 ah.event_list 的自旋锁。
  • event_channel:事件通道,内部有 "active ah 链表",poll 从这里拿 ah

前置状态refcnt == 2ah 挂在 event_channel 的 active ah 链表上;event_list 空。

断连时的两线程时序如下:

  1. proxy 线程收到对端断连事件:走 CM 回调,进入 event 生成流程。
  2. proxy 加锁把 DISCONNECTED 事件 E 插入 ah.event_listah.lock 保护下 TAILQ_INSERT_TAIL。之后 proxy 继续往下走,最终会做一次 ah.refcnt --(图右侧)。
  3. IO 线程 poll 到这个 ah 上有事件:在 ah.lock 下从 ah.event_list 取出 E,出锁后 free(E)
  4. IO 线程紧接着做断链清理:加锁把 ahevent_channel 的 active ah 链表里摘掉(今后 poll 循环再也拿不到这个 ah),然后 ah.refcnt --
  5. 两个线程各自判断 refcnt == 0?:谁把计数减到 0,谁就负责走中间那个红色的 析构 ah 框 —— 遍历 ah.event_listfree未加锁),再释放 ah。另一侧发现 refcnt != 0,直接返回主循环。

图里两处关键点已经用颜色标了出来:event_list 的所有正常入队 / 出队都在 ah.lock 保护下(蓝、绿框里的"加锁"字样),而汇聚的红色析构框里"遍历 event_list 并 free" 这一步刻意省掉了 ah.lock

代码逻辑为什么"看起来是对的"

第一遍看代码,会觉得这个流程没漏洞:

  1. event_list 的入队 / 出队都在 ah.lock 下,生产者、消费者互相看到的是一致的链表。
  2. refcnt 是原子递减,只有把它减到 0 的那一方进析构,析构不会被并发执行。
  3. 走到析构时,ah 已经从 event_channel 的 active ah 链表被摘掉,未来不会再有第三方拿到它。
  4. 既然"未来没人再来动",析构里遍历 event_list 时省掉了 ah.lock —— 看起来是合理的优化。

问题就是第 4 条。x86 上这个省略无害,ARM 上是致命的。在提示 AI 这是在 ARM 架构下出现的 coredump 后,AI + gdb_mcp 给出了它的第二次分析结论。

根因

关键在于 析构一侧对 event_list 的读,没有与对端"最后一次锁内写"配对的同步

  • refcnt --__atomic_sub_fetch(RELEASE) 只把 refcnt 这条 cache line 的写发布出去,不会顺带发布 event_listtqh_first / tqh_last
  • 程序里能跨线程发布 event_list 的手段只有 ah.lock。而析构一侧刻意省掉了这次加锁。
  • ARM 弱内存模型下,缺一次 acquire,析构线程读 event_list看不到对端锁内 REMOVE(E) 的结果,仍然读到指向已释放 E 的旧 tqh_first
  • 随后 TAILQ_REMOVE(E) + free(E) 就是对 fastbin 里同一块内存的二次释放,glibc _int_free 检测 fastbinsY[idx] == p,abort。

x86 是 TSO,写基本按程序序尽快对其他核可见,这条陈旧读几乎观测不到;ARM 没有这个"免费保护"。

一句话:"从公共集合摘掉,以后没人再访问它" 只解决了未来的并发路径,没有替代当前析构与对端上一次写之间的同步

一个不对称的细节:IO 线程走到析构不会炸

这个 bug 只在 proxy 线程负责析构 时出现。如果 refcnt 减到 0 的是 IO 线程,反而不会 double free:

  • IO 侧的 REMOVE(E) + free(E)unbindrefcnt --、进析构、遍历 event_list全部发生在同一个线程的程序序里
  • event_list 时读到的是自己 CPU cache 里刚刚在锁下更新过的值,没有跨线程发布问题。

也就是说,只有 "对端锁内写" 与 "本端无锁读" 落在两个不同线程上,才需要一次跨线程的 acquire —— 而这个流程恰恰漏掉了。

修复

析构里遍历 event_list 前也走一次 ah.lock(或至少在析构入口做一次能与对端 release 配对的 acquire),让所有路径统一到 "析构时一定有过一次 acquire"。或者做更好的设计,不在 ah 结构体中放事件,事件作为一个独立的机制,与 ah 结构体解耦。


实锤:最小复现程序

只靠 AI 和读代码得出的结论还不够(AI 会不会糊弄我、会不会有哪里的流程理解得不对),需要一个能稳定复现的最小程序,把根因钉死。需要说明的是,上面第二节里给出的流程本身已经是从生产代码里抽出来的简化模型 —— 真实链路要跑 CM、event_channel、connection 管理等好几层,还夹杂着别的字段读写。之所以不直接拿生产代码复现,有两个原因:

  • 触发窗口极窄,需要 IO / proxy 两个线程的具体交错,业务里其它路径经常把状态"顺带刷正",把 bug 藏起来;
  • 生产链路每次跑起来涉及连接建立、断连、事件通知等一整套流程,跑一次成本高、可控性差。

所以直接按第二节的时序图,让 AI 帮我写一个只含 事件入队 / 出队 + refcnt / 析构 这几个必要动作的最小程序(源代码见附件 1 的 C 程序) —— 它就是那张图的可执行版本。为了把 bug 触发条件全部显式化:

  • 同一把自旋锁 + 同一个链表,对应 ah.lock / ah.event_list
  • 两线程绑核,A→core 0,B→core 1,避免同核 L1 天然一致把 bug 藏掉。
  • __ATOMIC_RELAXED 握手:用 ref 变量卡住时序("consumer 先做完、destroyer 后进析构"),但故意用 RELAXED —— 只发布 ref 自己的 cache line,不搭车发布 event_list,精确对应生产代码里"析构没再拿锁"的状态。
  • event_listref / stop 拉到不同 cache linenode 里塞 10 MB padding,ref / stop 各自 alignas(64)。防止无关字段搭同一条 cache line、被原子操作顺带刷新。
  • good 模式对照:加 good 参数后析构也拿锁,其它完全一样,用于证伪"是不是别的原因造成的"。

线程角色贴合真实业务:A 同时是 producer + destroyer(对应 proxy),B 是 consumer(对应 IO)。这也对应第四节里"不对称"的那半边 —— A 侧的 producer 写 event_list 和 destroyer 读 event_list 分处两个动作,中间只靠一次 RELAXED 握手接力,B 侧锁内的 REMOVE 结果并没有被发布给 destroyer;如果反过来让 B 同时担任 consumer + destroyer(REMOVE + free + FOREACH_SAFE 全在一个线程的程序序里),bug 无法被触发。复现程序刻意选择前者,就是为了把生产上出问题的那条路径钉住。

Thread A (core 0, proxy)                 Thread B (core 1, IO)
────────────────────────                 ──────────────────────
lock → INSERT_TAIL(E) → unlock
ref = 1 (RELAXED)          ─────────►    spin 直到 ref == 1
                                         lock → REMOVE(E) → unlock
                                         free(E)
spin 直到 ref == 0           ◄─────────  ref = 0 (RELAXED)

(无锁)FOREACH_SAFE(event_list)         回到 spin ref == 1
   TAILQ_REMOVE(ev)
   free(ev)          ← 二次 free

实锤小故事

复现程序的反反复复,像一场迟迟等不到天亮的夜。一次次的编译、运行、等待,换来的却是一次次的沉默——程序安安静静地跑着,仿佛那个 bug 从未存在过。

就在几乎要放弃的至暗时刻,carterfan(鹅滴卡神)如人生灯塔般降临。他不仅带我系统梳理了缓存一致性那些散落在各处的知识碎片,还亲手补全了复现程序的最后一环。临了,卡神笃定地说:“这一把,一定能复现!”

我把程序上传到 ARM 服务器上,编译、运行,然后回家。那一夜,没什么特别的,像所有平平无奇的夜晚一样。第二天清晨,重新登录服务器,终端上赫然躺着一行字——double free or corruption (fasttop)。第一次觉得 double free 这两个字竟也可以如此可爱,像是跋涉许久之后,终于等到的回响。


总结

  1. 原子操作只同步它自己那条 cache line。 想跨线程发布别的共享结构,必须走一次配对的锁 / release-acquire,不能指望 refcnt 的 RELEASE 把整个对象都带过来。
  2. "以后不会再有人访问它" 不等于 "现在读到的是最新值"。 前者只关未来的并发路径;后者需要与对端最后一次写有过一次同步。析构、清理、回收这类"最后一次访问"的地方最容易踩这个坑。
  3. 同一线程连续操作是免费的顺序保证,跨线程不是。 排查时先分清哪条链路是"同线程内"、哪条是"跨线程",跨线程处才是要查的位置。
  4. x86 TSO 会白送很多正确性。 在把代码搬到 ARM 之前,凡是"这里不加锁应该也行"的地方,都值得再问一遍:"我和对端最后一次写之间是不是漏了一次 acquire?"
  5. 复现类似 bug,靠猜没用。 剥离最小模型 + 绑核 + 用 RELAXED 阻断误打误撞的同步 + 显式对照模式,是把这类内存模型 bug 钉死的可复用套路。

声明:一丁点儿的网络日志|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - X86 架构上乐呵呵,ARM 架构下炸了锅


勿在浮沙筑高台,每天进步一丁点儿!