一次 CPU Profiler 引发的 loader lock 重入死锁
太长不看版
某 C++ 服务在运行期启用 gperftools CPU profiling 后停止响应:profile 文件创建成功,但一直保持 0 字节;服务心跳不再推进,监控进程随后因心跳超时生成 core。看起来像 profiler 没写出文件,实际是服务线程已经卡死在一次采样里。
根因方向是 profiler 在信号处理函数里做了不完全信号安全的调用栈采样,和业务线程当时正在执行的异常处理路径撞到一起,最终形成同一线程重入同一把内部锁的死锁。
规避上有完全规避和部分规避两类手段。本文结论是优先评估 GCC/libgcc 运行时升级;unwind_safeness_helper 只能作为特定构建下的定制兜底,不建议当成通用推荐方案。
现象:开启 CPU profile 后服务停住
触发入口很普通:服务运行中通过管理命令启用 gperftools CPU profiler。预期是 profiler 周期性采样并写出 profile;实际是 profile 文件创建后一直是 0 字节,进程心跳不再推进,监控进程心跳超时后生成 core。
两次生产 core 的共同形态很一致:
- 只有一个线程真正进入了 gperftools profiler callback。
- 进入 callback 的线程卡在
GetStackTraceWithContext_libunwind -> dl_iterate_phdr -> pthread_mutex_lock(_rtld_global+2352)。 - 同一个线程被
SIGPROF打断前,原业务栈也已经在_Unwind_Find_FDE -> dl_iterate_phdr -> pthread_mutex_lock(_rtld_global+2352)。
这些现象指向同一个方向:SIGPROF 信号打断了正在业务异常展开的线程,并在 signal handler 里再次走到同一把 loader lock。
必要背景:SIGPROF、两条展开路径和 loader lock
先把三个概念放清楚:SIGPROF 负责异步触发 Profiler 采样展开;业务异常展开是被打断前正在执行的业务侧路径;dl_iterate_phdr / loader lock 是两条路径在旧运行时下的汇合点。
这里有两个容易混在一起的 “unwind”,本文固定按下表的含义使用:
| 名称 | 含义 | 这次问题里的位置 |
|---|---|---|
| 业务异常展开 | throw 后,运行时沿调用栈寻找 catch,并查找每个栈帧的 unwind metadata |
被 SIGPROF 打断前的原业务栈 |
| Profiler 采样展开 | CPU profiler 为了记录一次采样,调用 libunwind 还原当前调用栈 | SIGPROF handler 内 |
SIGPROF:这是 CPU profiler 常用的采样信号,也是 Profiler 采样展开路径的入口。gperftools 启动后会安装SIGPROFhandler,并通过 profiling timer 周期性触发。这个信号不是业务安全点;它可能打断任意正在运行的线程,handler 随后在被打断线程的上下文里进入 Profiler 采样展开。业务异常展开:
throw不是简单跳到catch。运行时要沿调用栈寻找处理点,并为栈帧查找 unwind metadata。生产栈中能看到典型链路:__cxa_throw -> _Unwind_RaiseException -> _Unwind_Find_FDE。loader lock:动态链接进程里,unwind metadata 查找经常要知道“当前加载了哪些 ELF 对象”。glibc 的枚举接口是
dl_iterate_phdr,它会遍历主程序和各个.so的 loaded-object 列表。这个列表由 dynamic loader 维护,dlopen/dlclose会修改它,dl_iterate_phdr会读取它,所以 glibc 会拿 loader lock。生产 core 中的_rtld_global+2352就是 Ubuntu 20.04 glibc 2.31 里的_dl_load_write_lock。
先画成机制图:在旧运行时下,两条路径会逐步汇到同一个 dl_iterate_phdr / loader lock。
1 | 业务异常展开路径(旧运行时) Profiler 采样展开路径 |
单独看这些行为都合理,问题出在它们组合在 signal handler 里。
死锁链路与 core 证据:同一 LWP 上的两次 dl_iterate_phdr
这次的核心链路可以压成一行:
1 | 业务异常展开 -> dl_iterate_phdr -> SIGPROF -> Profiler 采样展开 -> dl_iterate_phdr |
关键是“同一个线程”。signal handler 运行在被打断线程的上下文里。如果被打断点已经处在 loader/unwind 路径中,SIGPROF handler 再次进入 dl_iterate_phdr,就会在同一个 LWP 上第二次走到 _dl_load_write_lock。
这张图是整篇文章最重要的心智模型:
1 | 按发生顺序看: |
图里的两段最终都指向 _rtld_global+2352 对应的同一把 loader lock。
这不是只靠机制推导。生产 core 的关键线程里,<signal handler called> 之前是 Profiler 采样展开,之后是被打断前的业务异常展开;两边都出现了 dl_iterate_phdr,并最终指向 _rtld_global+2352 对应的同一把 loader lock。完整 gdb 栈见 附录 A,_rtld_global+2352 的字段确认见 附录 B。
因此这条链路的关键是:Profiler 采样展开侧和业务异常展开侧走到了同一把 glibc loader lock。在旧运行时下,业务侧高频异常会放大命中这条旧路径的机会:配置刷新循环会周期性进入业务异常展开,平时主要影响自己的线程;CPU profiler 开启后,这些业务异常展开窗口增加了 SIGPROF 切进 loader/展开路径的机会。确认这一点后,规避思路就自然落到两处:Profiler 侧跳过危险采样,以及业务侧升级运行时移除旧路径。
规避方案:GCC/libgcc 运行时升级和 unwind_safeness_helper
gperftools wiki 对这个问题的表述很直接:CPU profiler 需要在 signal handler 中做 Profiler 采样展开;libunwind 实践中会依赖 dl_iterate_phdr 枚举已加载 ELF 模块,而没有 libc 提供 async-signal-safe 的 dl_iterate_phdr。如果 profiling signal 打断了已经在 dl_iterate_phdr 中的线程,比如旧运行时下的业务异常展开、dlopen 或 dlclose,就可能出问题。
这次链路的规避方案不是并列推荐的。优先推荐的是 GCC/libgcc 运行时升级:它把业务异常展开侧从 dl_iterate_phdr 切到 _dl_find_object,完全移除本次事故里的业务侧 loader-lock 路径。unwind_safeness_helper 更适合作为验证工具或特定构建下的定制兜底:它可以处理 Profiler 采样展开侧和 SIGPROF 打断 dlopen/dlclose/dl_iterate_phdr 等 loader 不安全区的风险,但落地条件比较苛刻。
1 | 旧故障链路: |
fully static linking 从机制上更彻底,但工程成本和适用条件更苛刻;只要仍有动态依赖、插件或 NSS,就不能认为风险完全消失。本文把它作为长期方向说明,不进入 demo 验证。
GCC/libgcc 运行时升级:完全规避业务异常展开侧旧路径
这里的 GCC/libgcc 运行时升级,指的是 Ubuntu 24.04 / GCC 13 这类组合:glibc 提供 _dl_find_object,libgcc 的 _Unwind_Find_FDE 会优先使用它。对本次事故里的业务异常展开侧,这是优先推荐的完全规避方案。
先回到旧运行时生产 core 里的下半段:
1 | 业务线程触发 throw |
这条链路里,_Unwind_Find_FDE 来自 GCC 运行时库。它的工作是根据当前指令地址找到对应的 unwind metadata。旧 GCC 运行时为了知道“这个地址属于哪个 ELF 对象”,会调用 dl_iterate_phdr 去遍历动态链接器维护的 loaded-object 列表。于是普通 C++ throw 也会稳定走到 loader lock 附近。
GCC 后来改过这条路。GCC 12 起,在 glibc 提供 _dl_find_object 时,_Unwind_Find_FDE 会优先用这个接口。于是业务异常展开侧从“遍历 dynamic loader 的 live 列表”变成“查一个为 unwind 准备的地址索引”:
1 | 旧运行时路径,例如 Ubuntu 20.04 / GCC 9: |
这不是“窗口变小”。对业务异常展开侧来说,_Unwind_Find_FDE -> dl_iterate_phdr -> loader lock 这半条旧链路被移除,业务异常展开 -> loader lock 不再存在。它没有让 Profiler 采样展开里的 libunwind 变成完全 async-signal-safe;那是 unwind_safeness_helper 试图覆盖、但存在落地边界的另一侧风险。
_dl_find_object 能做到这一点,是因为老的 dl_iterate_phdr 读的是 dynamic loader 正在维护的 live 列表;这个列表会被 dlopen/dlclose 修改,所以读取时必须和写入互斥。_dl_find_object 则给 unwinder 准备了更适合按 PC 查询的地址索引,读取时可以基于快照和版本校验完成查询,而不是为了找一个 FDE 反复遍历 loader live list。
这个实现把两类工作分开:写入仍然串行,保证 loader 状态一致;读取走查询快照,避免为了查一个 PC 所属的 ELF 对象而进入 loader lock。于是 SIGPROF 即使打断业务异常展开,业务异常展开侧也不再走到 dl_iterate_phdr -> _dl_load_write_lock。这正是它能完全规避本次死锁链路中业务异常展开侧触发路径的原因。_dl_find_object 的读写结构见 附录 D。
unwind_safeness_helper:可验证但不推荐作为通用方案
GCC/libgcc 运行时升级解决的是业务异常展开侧;它不改变 Profiler 采样展开侧 libunwind 可能调用 dl_iterate_phdr 的事实,也不覆盖 SIGPROF 正好打断 dlopen/dlclose/dl_iterate_phdr 等 loader 不安全区的情况。
unwind_safeness_helper 的作用是在当前线程已经处于 loader 不安全区域时,让 libunwind 展开提前结束。它通常通过 LD_PRELOAD 包装 dlopen/dlclose/dl_iterate_phdr 和 libunwind 的 unw_* API:业务侧进入不安全区域时设置标记,Profiler 采样展开看到标记后停止本次展开。对 CPU profiler 来说,丢掉少量危险样本通常比卡死服务更可接受。
但它不是开箱即用的通用修复。本文 demo 中,原版 GitHub helper 拦截的是 _Ux86_64_* 这组 libunwind 符号;而 gperftools 2.7 在当前构建里实际调用的是 UNW_LOCAL_ONLY 对应的 _ULx86_64_* 符号,所以原版 helper 不生效,必须给 helper 打 local-only 兼容 patch。更麻烦的是,如果 libunwind 被静态链接进可执行文件或 profiler 依赖里,LD_PRELOAD 无法再 hook 这些 libunwind 符号;这时要让 helper 参与判断,就需要改 libunwind 代码,让展开路径主动走 helper 的安全检查。因此 helper 更适合做风险验证或短期定制兜底,不建议作为通用治理方案。
如何验证规避方案:纯 gperftools/libunwind 最小 demo
这个 demo 只保留三件事:gperftools CPU profiler、Profiler 采样展开和业务异常展开。它的作用是验证这三者组合后足以复现同类风险,并作为 GCC/libgcc 运行时升级 和 unwind_safeness_helper 的对比基线。完整仓库结构和命令见 附录 C,正文只保留验证设计:
| 验证 | 改了什么 | 预期结果 | 说明什么 |
|---|---|---|---|
| 基线风险 | 不启用规避;使用旧 GCC 运行时、gperftools CPU profiler、动态 libunwind | worker 计数停滞;严重时 watchdog abort,core 中能看到 Profiler 采样展开和业务异常展开侧都进入 dl_iterate_phdr |
CPU profiler + libunwind + 旧 GCC 业务异常展开路径 足以复现风险 |
| GCC/libgcc 运行时升级 | 换到 Ubuntu 24.04 / GCC 13 这类组合 | 业务异常展开侧改走 _dl_find_object,不再调用 dl_iterate_phdr |
完全规避 业务异常展开 -> loader lock 这半条链路 |
| unwind_safeness_helper | 通过 LD_PRELOAD 加载 patched helper |
profiling 期间 worker 计数持续推进;危险区域里的采样提前结束 | 可验证 helper 思路,但依赖符号可 hook,不是通用推荐方案 |
对业务异常展开侧,GCC/libgcc 运行时升级是完全规避:它改走 _dl_find_object,不再调用 dl_iterate_phdr,因此 业务异常展开 -> loader lock 这半条链路不存在。helper 针对的是另一类风险:Profiler 采样展开侧的 libunwind,或 SIGPROF 打断 dlopen/dlclose/dl_iterate_phdr 等 loader 不安全区;但它的有效性依赖具体链接方式和符号路径,不能直接当成通用修复。
结论
这次问题的根因在 SIGPROF handler 中使用了不完全 async-signal-safe 的 unwinder。libunwind 会依赖 dl_iterate_phdr,而 dl_iterate_phdr 需要 loader lock;如果 SIGPROF 打断的正是业务异常展开或动态加载路径,SIGPROF handler 再次进入 Profiler 采样展开就可能同线程重入同一把 loader lock。
在旧运行时下,业务高频异常是概率放大器。它让 _Unwind_Find_FDE -> dl_iterate_phdr 的窗口更常出现;GCC/libgcc 运行时升级不是降低这个概率,而是完全移除该业务异常展开路径。
规避优先级上,先评估 GCC/libgcc 运行时升级,完全规避业务异常展开侧对 dl_iterate_phdr 的依赖。unwind_safeness_helper 可以用于验证思路或特定构建下的短期兜底,但因为依赖符号 interpose,遇到 local-only 符号或静态链接 libunwind 时都需要额外 patch,不建议作为通用治理方案。fully static linking 从机制上更彻底,但成本和适用条件更苛刻,适合作为长期方向评估。
附录 A:生产 core 关键 gdb 栈
这个附录选取一份代表 core,把正文里压缩过的生产栈展开。读法仍然按 gdb 顺序:#0 是线程当前停住的位置;<signal handler called> 之前是 SIGPROF handler 内部栈,之后是被信号打断前的原始业务栈。另一份 core 的形态一致,这里不重复展开。
这个代表 core 的关键线程是 LWP 353185。它同时包含 Profiler 采样展开栈和业务异常展开栈,两段都走到了 _rtld_global+2352:
1 | Thread 55 (Thread 0x7f3791664700 (LWP 353185)): |
附录 B:_rtld_global+2352 字段确认
这个附录解释为什么正文把 _rtld_global+2352 判断为 loader lock。生产 core 来自 Ubuntu 20.04 glibc 2.31。用带 glibc debuginfo 的 gdb 查看 struct rtld_global 字段偏移:
1 | p/x &((struct rtld_global *)0)->_dl_load_lock -> 0x908 |
0x930 十进制就是 2352,因此生产栈里的 _rtld_global+2352 对应 _dl_load_write_lock。
这里还有一个容易误读的点:__rtld_lock_recursive_t 的名字里有 recursive,但在这个 glibc 构建里它实际展开为包含 pthread_mutex_t 的结构。生产栈里同一个 LWP 在 #1 和 #13 都进入 pthread_mutex_lock(_rtld_global+2352),没有返回到 handler 外继续执行,因此现象上就是同线程重入等待同一把 loader 写锁。
附录 C:最小 demo 下载、编译、运行命令
最小 demo 放在 sigprof-libunwind-loader-lock-repro 仓库中。这个名字对应本文的三个关键点:SIGPROF、libunwind 和 loader lock;repro 表示它是一个复现和验证工程。
GitHub 地址:https://github.com/tedcy/sigprof-libunwind-loader-lock-repro
仓库结构如下:
1 | sigprof-libunwind-loader-lock-repro/ |
其中 third_party/CMakeLists.txt 下载并本地构建三份依赖:
1 | gperftools 2.7 |
demo 保持 gperftools 和 libunwind 源码不变:本地构建 libprofiler.a 并静态链接进可执行文件,libunwind 则构建为 libunwind.so 并动态链接。仓库根目录带一份旧 libgcc_s.so.1,运行时通过 LD_LIBRARY_PATH="$PWD:..." 优先加载它,让业务异常展开侧稳定暴露旧路径。这样 LD_PRELOAD 加载的 helper 才有机会拦到 libunwind 的展开 API。
unwind_safeness_helper 基于 upstream helper,只加一个 local-only 符号兼容 patch。原因是 gperftools 2.7 在这个构建里调用的是 libunwind 的 _ULx86_64_* 符号;helper 需要以 UNW_LOCAL_ONLY 编译,并用同一组符号名做 dlsym(RTLD_NEXT, ...)。这个 patch 不改 gperftools,也不改 libunwind,只是让 helper 拦到动态链接 libunwind 时的实际调用路径。
这个边界很重要:如果 libunwind 被静态链接,LD_PRELOAD 已经 hook 不到 libunwind 的 unw_* / _ULx86_64_* 调用,单独 patch helper 也没有意义。那种构建下要验证 helper 思路,就需要给 libunwind 自身打 patch,让展开代码主动检查 helper 维护的不安全区标记。因此本文 demo 故意使用动态 libunwind,只验证 helper 在“可 interpose”条件下是否有效。
helper 做两类 wrapper:一类标记 loader 不安全区域,另一类在不安全区域里让 libunwind 展开提前结束:
1 | dlopen(...) -> unsafeness_depth++ -> real dlopen -> unsafeness_depth-- |
编译和运行命令:
1 | git clone https://github.com/tedcy/sigprof-libunwind-loader-lock-repro.git |
第一条运行命令不启用 helper,用于观察基线风险;第二条通过 LD_PRELOAD 加载 patched unwind_safeness_helper.so,用于验证危险窗口里的 libunwind 展开会提前结束;第三条加载不打 patch 的 upstream helper,用于观察 _Ux86_64_* 和 _ULx86_64_* 符号不匹配时的失败效果。三条命令都使用 gperftools 默认采样频率,让 profiler 使用定时 SIGPROF 做采样。
demo 的核心代码只有两个线程:worker 循环触发真实业务异常展开,watchdog 在 worker 停滞时 abort 留 core。业务代码不直接调用 dl_iterate_phdr;业务侧进入 loader lock 的路径来自业务异常展开本身。
1 | std::atomic<bool> g_stop{false}; |
预期现象:
1 | 基线命令 |
验证新版 GCC 运行时路径时,先确认环境版本即可:
1 | lsb_release -a |
附录 D:_dl_find_object 的实现细节
这一节聚焦 glibc 里 _dl_find_object 的实现模型。它的核心不是把 dl_iterate_phdr 换个名字,而是为“按 PC 找 ELF 对象”这类读取请求准备了一套更适合 unwinder 的查询结构。
_dl_find_object 处理的是一个很具体的问题:给定一条指令地址 pc,返回它所在 ELF 对象的地址范围、link_map 和 unwind 需要的 .eh_frame 信息。为了让这个查询不依赖遍历 dynamic loader 的 live list,glibc 把对象分成几类保存:
1 | 主程序: |
读路径先处理最稳定的部分:如果 pc 落在主程序范围内,直接返回 _dlfo_main;如果落在启动期不可卸载对象覆盖的地址范围里,就在 _dlfo_nodelete_mappings 里查。真正需要并发控制的是第三类:dlopen 后可能被 dlclose 的对象。
这第三类对象使用的是“双副本 + 版本校验”的读写结构。先把共享状态看成三个量会更清楚:一个版本号,两份映射表,版本号最低位指向当前可读的那份。
1 | 共享状态: |
这里的版本号可以理解成一个很小的软件事务边界。读取开始时记下版本,读取中不拿 loader lock;如果 dlopen 刚好发布了新版本,读取结束时的版本校验会失败,读路径重试即可。写入方仍然只有一个,因为 dlopen 本来就受 loader lock 串行保护,所以发布新副本时不需要多写者协调。
dlclose 的处理也服务于这个模型:它不会要求读路径马上释放或重建整份查询结构,而是把对应 entry 标成不可用,后续 dlopen 可以复用槽位。这样读路径面对的始终是可校验的查询副本,而不是正在被 loader 修改的 live link_map 链表。
所以 _dl_find_object 的关键实现点是:把“loader 状态怎么被修改”和“unwinder 如何按 PC 查询对象”解耦。写入侧继续由 loader lock 保证一致性;读取侧通过按地址排序的查询表、双副本和版本校验获得稳定视图。这也是正文里说它能移除业务异常展开侧旧路径的底层原因。
附录 E:外部资料链接
- gperftools stacktrace wiki: https://github.com/gperftools/gperftools/wiki/gperftools%27-stacktrace-capturing-methods-and-their-issues
unwind_safeness_helper: https://github.com/alk/unwind_safeness_helper- gperftools releases: https://github.com/gperftools/gperftools/releases
- libunwind releases: https://github.com/libunwind/libunwind/releases
- GCC commit
Use _dl_find_object in _Unwind_Find_FDE: https://gnu.googlesource.com/gcc/+/790854ea7670f11c14d431c102a49181d2915965 - glibc
elf/dl-find_object.c: https://codebrowser.dev/glibc/glibc/elf/dl-find_object.c.html - glibc
_dl_find_objectpatch discussion: https://sourceware.org/pipermail/libc-alpha/2021-December/133604.html - Ubuntu 20.04
libc6/ glibc 2.31: https://launchpad.net/ubuntu/focal/amd64/libc6/2.31-0ubuntu9 - Ubuntu 24.04
libc6/ glibc 2.39: https://packages.ubuntu.com/noble/libc6 - Ubuntu 24.04
gcc-13: https://packages.ubuntu.com/source/noble/gcc-13
-
2018-08-16
习惯了golang的net/http/pprof的便利,c++的性能分析就显得繁琐了一点。
不过大致上还是一致的。
安装
包管理安装
1
2
3
4
5centos:
yum install google-perftools google-perftools-devel
ubuntu:
apt-get install google-perftools google-perftools-devel -
2025-01-12
截止25年1月12日,打算使用dlmopen来装载serverless平台第三方动态库的计划,在挣扎了2周后正式宣布破产
总的来说dlmopen虽然已经有几十年历史了,但是在当前还是非常不成熟的,有很多细节问题没有解决
想用上dlmopen,需要深入理解glibc的实现原理,和patches的可能bug斗智斗勇,这个投入产出比很低
背景
serverless平台的设计原理属于机密,略过不提
-
2025-09-08
自研serverless平台存在一个问题很多年了,引入cpython以后,就不能使用tcmalloc了
否则会直接coredump,这个问题不解决,使用平台的同学就没办法进行内存泄露分析
在一个多部门组成的python和C++的混合脚本上,问题爆发了,由于申请内存是一个部门的模块,释放内存又是另外一个部门的模块,跨部门协作下的内存排查太过困难了
因此还是需要从平台侧解决这个问题
coredump问题