一、十点夜晚的 Valgrind 日志

那是一个周四的晚上。我已经盯着终端看了一个小时,屏幕上是一份 Valgrind 报告——112 个错误,全是 use-after-free。

1
2
3
4
5
6
7
8
9
10
11
12
==3300263== Invalid read of size 1
==3300263== at 0x4852A10: memmove
==3300263== by 0x60818AD: basic_streambuf::xsputn
==3300263== by 0x6073B64: __ostream_insert
==3300263== by 0x15C2DB: operator<<
==3300263== by 0x15C2DB: MqttClient::publish (mqttClient.cpp:382)
==3300263== by 0x1935E7: LogPublisher::publish (LogPublisher.cpp:68)
==3300263== by 0x191540: Logger::workerThread() (csLog.cpp:315)
==3300263== Address 0x6a308de is 14 bytes inside a block of size 31 free'd
==3300263== at 0x484BB6F: operator delete
==3300263== by 0x1936E5: LogPublisher::~LogPublisher() (LogPublisher.h:9)
==3300263== by 0x62B1494: __run_exit_handlers (exit.c:113)

翻译成人话:程序退出时,LogPublisher 先析构了——它内部的 topic 字符串(31 字节)被释放。但 Logger 的后台线程还在运行,继续调用 LogPublisher::publish(),访问了那块已经归还给堆的内存。

更糟的是,日志输出也在印证这个混乱的时序:

1
2
3
4
5
6
7
8
9
09:13:34.869 I Waiting for MQTT thread to exit...
09:13:34.870 I MQTT thread joined successfully!
09:13:34.871 E Publish message failed: Client not connected or stopped!
09:13:34.871 E Publish message failed: Client not connected or stopped!
09:13:34.872 I Reconnect thread joined successfully!
09:13:34.894 W MQTT client stopped successfully!
09:13:34.904 E Publish message failed: Client not connected or stopped! ← 还在报错!
09:13:34.905 E Publish message failed: Client not connected or stopped!
09:13:34.906 E Publish message failed: Client not connected or stopped!

MqttClient 明明已经 Stop 了,Logger 的 worker 线程还在疯狂尝试 publish。

当你看到这种日志,第一反应可能是"MQTT 库有 bug"。但 Valgrind 不会骗人——问题在自己代码里。

二、表面现象:use-after-free

Valgrind 说得很清楚:一块 31 字节的内存,先被 freed,然后被 read。

谁释放的?LogPublisher::~LogPublisher(),在程序退出时被 __run_exit_handlers 调用。这是 C++ 静态对象析构的标准流程——main 返回之后,运行时逐个调用静态对象的析构函数。

谁还在读?Logger::workerThread(),一个 std::thread 启动的后台线程。它还在 while 循环里处理日志队列,每次取出一条日志就调用 LogPublisher::publish()

问题是——为什么 LogPublisher 析构了而 Logger 的线程还在跑?

三、追查:缺失的 stop()

翻开 SystemFactory::stop(),一眼就看到问题:

1
2
3
4
5
6
7
8
9
10
void SystemFactory::stop()
{
stopTickerLocked();
_rtsp_server = nullptr;

EmergencyFactory::getInstance().stop();
DeviceManager::getInstance().stop();
MqttClient::getInstance().Stop(); // ← MQTT 停了
// 但是 Logger 呢???
}

Logger 根本没被停。

看看调用链:

1
2
3
Logger::workerThread()
→ LogPublisher::publish()
→ MqttClient::publish()

Logger 依赖 LogPublisher,LogPublisher 依赖 MqttClient。这是一条完整依赖链。SystemFactory 的 stop() 停了 MqttClient,但没停 Logger。Logger 的 worker 线程继续跑,LogPublisher 继续被调用——直到静态析构顺序的骰子掷出来,LogPublisher 先析构了。Boom。

这不是 MQTT 库的问题,不是 Valgrind 误报,是关机逻辑漏了一个环节。

四、根因:C++ 的"无声炸弹"

C++ 标准不保证跨编译单元的静态对象析构顺序。不同 .cpp 文件里定义的静态对象,谁先析构、谁后析构,完全看编译器和链接器的心情。

在这个项目里:

  • LogPublisherLogPublisher.cpp 中定义为函数内 static
  • LoggercsLog.cpp 中定义为函数内 static
  • 它们在同一个编译单元里吗?不是。它们的析构顺序有保证吗?没有。

所以你永远不知道哪一天、哪台机器上、哪次编译,析构顺序会突然翻转。这就是 C++ 静态对象的"无声炸弹"——平时跑得好好的,一退出就 crash,而且每次 crash 的位置可能不一样。

但这只是开始。

五、扩散:不止一个 bug

既然存在一个线程生命周期管理的问题,就必须怀疑还有更多。于是做了一次系统性审查。结果触目惊心——11 个问题

# 风险 类别 位置 简述
1 无界容器 mqttClient.h:90 _task_queue 无大小限制,高频消息下无限增长导致 OOM
2 无界容器 ai_node.h:163 _ais_trackers 只增不删,每个见过的 MMSI 永久保留
3 无界容器 ai_node.cpp:163 _track_associations locked 条目永不清理,fight target 异常时永久滞留
4 悬空引用 ai_node.cpp:23 main_node.cpp:34 lambda 捕获 this 注册到 MqttClient 单例,Node 析构后回调可能被触发
5 短期膨胀 ai_node.cpp:308 _fused_targets 120s 窗口内在繁忙海域可能增长到数千条目
6 逻辑泄漏 mqttClient.cpp:693 Stop 时未清空 _task_queue,异常路径下任务和 payload 不会被释放
7 protobuf planning_control_node.cpp:92 thread_local protobuf 内部缓冲区不释放(小对象,影响极微)
8 protobuf StateMachine.cpp:6 shared_ptr protobuf 对象内部 arena 永不缩小
9 设计缺陷 Singleton.h:18 getSharedInstance()getInstance() 返回不同实例
10 脆弱所有权 AisNmeaParseApi.cpp:388 AisVdmMessage** 双裸指针手动传递所有权,任何一层忘记 delete 就泄漏

这不是一个 bug,是系统性工程债务。

高风险的几个问题有一个共同特征:资源创建时有明确逻辑,但销毁(清理)的条件要么缺失、要么不完整。

六、修复:逐个击破

6.1 关机顺序(核心修复)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void SystemFactory::stop()
{
stopTickerLocked();
_rtsp_server = nullptr;

EmergencyFactory::getInstance().stop(); // 1. 先停数据源
DeviceManager::getInstance().stop(); // 2. 再停设备层

csLog::Logger::getInstance().stop(); // 3. 停 Logger(排空队列 + join worker)
// 此时 MqttClient 还活着,
// 确保队列中的日志能正常发出

MqttClient::getInstance().Stop(); // 4. 最后停 MQTT
// Logger 已 join,不会再有新的 publish
}

为什么是这个顺序?因为依赖链是:

1
数据源 → Logger → LogPublisher → MqttClient
  • 先停数据源(EmergencyFactory、DeviceManager):停止产生新数据,减少队列积压
  • 再停 Logger:stop() 内部设置 exitFlag = true,worker 线程排空队列后退出,join() 等待线程结束。排空期间 MqttClient 还活着,正常 publish
  • 最后停 MqttClient:Logger 已 join,不再有日志需要 publish,安全关闭

6.2 容器防御

_task_queue:加硬上限 MQTT_MAX_TASK_QUEUE_SIZE = 10000,满时丢弃最旧任务,防止 OOM。

_ais_trackers:新增 pruneExpiredAisTrackers(),60s 无更新的跟踪器自动清理。

_track_associations:locked 条目原来被无条件跳过,现在加上 FIGHT_TARGET_LOCK_TIMEOUT_MS 超时兜底。

_fused_targets:加硬上限 MAX_FUSED_TARGETS = 500。淘汰策略两轮:

  • 第一轮:优先淘汰纯 AIS 条目(无云台数据),保留有云台的目标
  • 第二轮:如果所有条目都有云台数据,淘汰最旧的

6.3 回调生命周期

原来 Node 通过 TopicChannel::registerTopic() 注册回调,lambda 捕获 this。Node 析构函数是空的——如果 MqttClient 比 Node 活得久,回调就变成悬空指针。

修复:TopicChannel 析构时自动调用 unregisterAll()

1
2
3
4
TopicChannel::~TopicChannel()
{
unregisterAll(); // 遍历 _registeredTopics,逐个调用 MqttClient::unregisterCallback
}

6.4 所有权修复

AisNmeaParseApi 中原有的三层裸指针传递:

1
2
3
4
// 修复前:手动 new/delete,所有权混乱
auto* m = new AisVdmMessage_1_2_3();
if (!parse(s6, m)) { delete m; return false; } // 每条失败路径都要记得 delete
*message = m; // 所有权丢给调用方

重构为:

1
2
3
4
// 修复后:unique_ptr 从创建点就接管所有权
auto m = std::make_unique<AisVdmMessage_1_2_3>();
if (!parse(s6, m.get())) { return false; } // 失败时自动析构
message = std::move(m); // 所有权显式转移

七、最终输出

修复完成后再次运行 Valgrind:

1
2
3
4
5
6
7
==3300263== HEAP SUMMARY:
==3300263== in use at exit: 0 bytes in 0 blocks
==3300263== total heap usage: 19,450 allocs, 19,450 frees
==3300263==
==3300263== All heap blocks were freed -- no leaks are possible
==3300263==
==3300263== ERROR SUMMARY: 0 errors from 0 contexts

All heap blocks were freed. ERROR SUMMARY: 0.

这是 C++ 程序员能看到的最美的日志。

八、教训

  1. 不要依赖 C++ 静态对象的析构顺序。 跨编译单元无保证。显式 stop() + join() 是你唯一可靠的朋友。

  2. 每一条 new 都欠一条 delete 每一条 push 都可能欠一条 pop。每一条 registerCallback 都欠一条 unregisterCallback。代码 review 时,对每一个"创建"操作,找到它的"销毁"操作在哪里——如果找不到,那就是 bug。

  3. Valgrind 不会骗你。 如果它说有 112 个错误,那就是有 112 个错误。不要花时间怀疑工具,花时间修代码。

  4. 你的 stop() 是降落伞。 平时不打开,但打开的时候必须没问题。