fmtlog 跨 DSO 崩溃排查记 & fmtlog 多线程日志无法写入:`free(): invalid pointer`
1. 错误描述
在某量化交易系统的 C++ 后端中使用 fmtlog 日志库时,遇到一个仅在特定环境下复现的启动崩溃。程序在成功输出两行工作线程的绑定日志后,立即触发 free(): invalid pointer 并 Aborted (core dumped)。更奇怪的是,该崩溃仅在将某个功能模块打包为共享库(.so)分发给其他人后出现,在开发机本机上完全正常。
2. 错误日志
08:46:47.442681 WorkerThread.cpp:29 INF pthread_setaffinityp success, cpu:3
08:46:47.442730 WorkerThread.cpp:29 INF pthread_setaffinityp success, cpu:4
free(): invalid pointer
Aborted (core dumped)
两行工作线程日志来自主程序的 logi() 调用——该工作线程模块通过 STATIC 库链接进主程序。崩溃发生在输出这两行之后,即程序即将进入 .so 中的业务逻辑时。
3. 环境信息
| 项目 | 详情 |
|---|---|
| 操作系统 | CentOS 7.9(远程运行环境) |
| 编译器 | GCC,支持 C++17 |
| 日志库 | fmtlog(header-only 实现,全局状态通过弱符号共享) |
| 崩溃模块 | DataModule.so(SHARED 库) |
| 关联模块 | DBModule.so(SHARED 库,同样有 hidden 可见性问题) |
4. 背景:为什么会有那段代码
在本次崩溃之前,已经存在一个多线程日志丢失问题:工作线程(如异步写入器中的线程池 worker)调用的 LOGI_SYNC/LOGE_SYNC 宏输出的日志无法写入文件。
经过多次尝试后,在 DataModule/Internal/IOBackend.cpp 中加入:
fmtlog::setLogFile(m_config->m_loggerFileHeader.c_str());
使得工作线程部分日志恢复写入,但仍然未写入全部多线程日志。
5. 尝试过的方法与效果
| 尝试 | 方法 | 效果 |
|---|---|---|
| 第一次 | 删除 IOBackend.cpp 的 setLogFile 调用 |
❌ 崩溃消除,但工作线程日志重新丢失 |
| 第二次 | 在 .so 内部独立启动轮询线程 | ❌ 两个轮询线程写同一文件,输出交叉混乱 |
| 第三次 | 同步移除 LOGI_SYNC 宏中的 fmtlog::poll(true) 调用 |
❌ 发现宏当前版本已不含 poll(true),但崩溃依旧 |
| 最终 | 移除 SHARED 库的 CXX_VISIBILITY_PRESET hidden |
✅ 崩溃消除,所有工作线程日志由主程序轮询线程统一输出 |
6. 最终解决方法
根因
问题有两层:
层一:直接崩溃原因 — 竞态条件(fclose / fwrite)
程序启动时 fmtlog::setLogFile() 被调用了三次:
| 次序 | 位置 | 关键影响 |
|---|---|---|
| ① | fmtlogDetailT 构造函数(全局初始化) |
初始化全局 impl 单例 |
| ② | AppRunner::configLogger() → 行 137 |
打开日志文件,然后启动轮询线程 |
| ③ | DataModuleTick::init() → IOBackend.cpp:22 |
再次调用 setLogFile,内部会 fclose(oldFp) |
竞态窗口:
- 主程序第②次
setLogFile打开日志文件,outputFp = F_A - 主程序
startPollingThread(1, cpuCore)— 轮询线程启动 - 轮询线程循环执行
poll()→flushLogFile()→fwrite(membuf, ..., F_A) - 主线程执行到第③次
setLogFile→closeLogFile()→fclose(F_A)— 关闭了轮询线程正在使用的FILE* - 轮询线程下一次
flushLogFile()→fwrite(..., F_A)写向已关闭的FILE*→ glibc 检测到无效指针 →free(): invalid pointer
层二:深层原因 — DSO 符号分裂
为什么第③次 setLogFile 不能被简单删除?因为 DataModule 的 CMakeLists.txt 中设置了:
SET_TARGET_PROPERTIES(DataModule PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
)
fmtlog 是 header-only 库,全局状态通过类静态成员 fmtlogWrapper<>::impl 以弱符号形式存在:
// fmtlog-inl.h:731-736
template<int __ = 0>
struct fmtlogWrapper {
static fmtlog impl;
};
template<int T>
fmtlog fmtlogWrapper<T>::impl;
在 CXX_VISIBILITY_PRESET hidden 下:
- 主程序编译时
impl为 default 弱符号 → 链接器合并为全局单例 - DataModule.so 编译时
impl为 hidden 弱符号 →.so内部持有独立的副本
这意味着:
- 主程序的
configLogger(setLogFile + startPollingThread)对 .so 内代码无效 .so内的logi()/LOGI_SYNC写入的是自己的独立队列.so内的日志必须自己调用setLogFile来打开文件——这就是IOBackend.cpp:22那行代码存在的真正原因
修复方案
移除所有 SHARED 库的 CXX_VISIBILITY_PRESET hidden,使 fmtlog 弱符号恢复跨 DSO 共享。
修改范围(共 4 个 CMakeLists.txt + 1 个 .cpp):
| 文件(已泛化) | 变更 |
|---|---|
DataModule/CMakeLists.txt |
移除 CXX_VISIBILITY_PRESET hidden(1 行) |
libs/cmake_data_module.txt |
移除 set_target_properties 块(4 行,预编译库模式) |
DBModule/CMakeLists.txt |
移除 CXX_VISIBILITY_PRESET hidden(1 行) |
libs/cmake_db_module.txt |
移除 set_target_properties 块(4 行,预编译库模式) |
DataModule/Internal/IOBackend.cpp |
删除注释的 setLogFile 调用(1 行) |
验证结果
- ✅ CentOS 7.9 clean build 成功(exit code 0)
- ✅ 运行不出现
free(): invalid pointer或Aborted (core dumped) - ✅ DataModule 工作线程日志完整出现在日志文件中
- ✅ DBModule 日志正常输出
7. 为什么可以解决
修复前(架构图)
┌──────────────────────────┐ ┌───────────────────────────┐
│ 主程序 (MainApp) │ │ DataModule.so (hidden) │
│ │ │ │
│ fmtlog::impl ───────────│─ ✗ ─│─ fmtlog::impl (独立副本) │
│ ├ outputFp = 日志文件A │ │ ├ outputFp = ??? │
│ ├ membuf (队列) │ │ ├ membuf (独立队列) │
│ └ 轮询线程 (消费队列) │ │ └ 无轮询线程 │
│ │ │ │
│ configLogger() 设置的 │ │ .so 日志无法进入主程序队列 │
│ 文件句柄对 .so 无效 │ │ 需要单独 setLogFile │
└──────────────────────────┘ └──────────────────────────┘
↑ 第③次 setLogFile 在此执行
时主程序轮询线程正在写 F_A
→ fclose/F_A 竞态崩溃
修复后(架构图)
┌─────────────────────────────────────────────┐
│ 主程序 (MainApp) │
│ │
│ fmtlog::impl (全局唯一) │
│ ├ outputFp = 日志文件A │
│ ├ membuf (统一队列) │
│ └ 轮询线程 (消费所有模块的日志) │
│ ▲ ▲ │
│ │ │ │
│ ┌────────┴──────┐ ┌─────┴──────────┐ │
│ │ DataModule.so │ │ DBModule.so │ │
│ │ (default 可见性) │ │ (default 可见性) │ │
│ │ 共享 impl ✓ │ │ 共享 impl ✓ │ │
│ └───────────────┘ └────────────────┘ │
└─────────────────────────────────────────────┘
移除 CXX_VISIBILITY_PRESET hidden 后:
- 弱符号恢复合并:主程序和 .so 共享同一个
fmtlogWrapper<>::impl实例 - 统一日志队列:所有模块的日志写入同一个 SPSC 队列
- 单一轮询线程:主程序的轮询线程消费所有日志,统一写入文件
- 无需重复
setLogFile:主程序的configLogger()对所有模块生效 - 竞态条件消失:不存在第二个
setLogFile调用,不会发生fclose/fwrite竞态
不影响范围
FileModule.so不使用 fmtlog,不修改ComputeModule是 STATIC 库,链接进主程序,不涉及 DSO 分裂.so内部符号全部导出无安全风险(在应用容器内使用,无外部攻击面)
教训: 对 header-only 库使用
CXX_VISIBILITY_PRESET hidden时,必须考虑其全局状态(如单例)在跨 DSO 边界时的行为。弱符号的 hidden 可见性会导致 DSO 获得独立副本,所有全局状态初始化(如configLogger、startPollingThread)对不同 DSO 独立生效,极易产生"调试机正常、发布后崩溃"的时序相关 Bug。

浙公网安备 33010602011771号