AIGC标识 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)

竞态窗口:

  1. 主程序第②次 setLogFile 打开日志文件,outputFp = F_A
  2. 主程序 startPollingThread(1, cpuCore) — 轮询线程启动
  3. 轮询线程循环执行 poll()flushLogFile()fwrite(membuf, ..., F_A)
  4. 主线程执行到第③次 setLogFilecloseLogFile()fclose(F_A) — 关闭了轮询线程正在使用的 FILE*
  5. 轮询线程下一次 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 pointerAborted (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 后:

  1. 弱符号恢复合并:主程序和 .so 共享同一个 fmtlogWrapper<>::impl 实例
  2. 统一日志队列:所有模块的日志写入同一个 SPSC 队列
  3. 单一轮询线程:主程序的轮询线程消费所有日志,统一写入文件
  4. 无需重复 setLogFile:主程序的 configLogger() 对所有模块生效
  5. 竞态条件消失:不存在第二个 setLogFile 调用,不会发生 fclose/fwrite 竞态

不影响范围

  • FileModule.so 不使用 fmtlog,不修改
  • ComputeModule 是 STATIC 库,链接进主程序,不涉及 DSO 分裂
  • .so 内部符号全部导出无安全风险(在应用容器内使用,无外部攻击面)

教训: 对 header-only 库使用 CXX_VISIBILITY_PRESET hidden 时,必须考虑其全局状态(如单例)在跨 DSO 边界时的行为。弱符号的 hidden 可见性会导致 DSO 获得独立副本,所有全局状态初始化(如 configLoggerstartPollingThread)对不同 DSO 独立生效,极易产生"调试机正常、发布后崩溃"的时序相关 Bug。

posted @ 2026-07-27 10:30  Ke_scholar  阅读(8)  评论(0)    收藏  举报