一、十点夜晚的 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 字符串被释放。但 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 不会骗人——问题在自己代码里。

二、追查:缺失的 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。

每个后台线程都有一个 stop() 债务。不还,迟早出问题。

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

上面的事故直接原因是 stop() 漏掉了 Logger。但深层原因更根本——C++ 标准不保证跨编译单元的静态对象析构顺序

在这个项目里:

  • LogPublisherLogPublisher.cpp 中定义为函数内 static
  • LoggercsLog.cpp 中定义为函数内 static
  • 它们在不同的编译单元里——谁先析构、谁后析构,完全看编译器和链接器的心情

所以说它是"无声炸弹":平时跑得好好的,退出时才 crash,而且每次 crash 的位置可能不一样。换一台机器、换一个编译器版本、甚至加一行无关代码,析构顺序都可能翻转。

有人会问:C++ 有 RAII,析构函数自动清理资源,为什么还要手写 stop()

因为析构函数解决的是单对象清理问题——一个对象销毁时释放自己持有的资源。但它解决不了跨对象依赖顺序问题。

看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class B {
std::thread _worker;
~B() {
_worker.join(); // B 知道要 join 自己的线程
}
};

// 如果 B 的 worker 线程使用了 A 的对象:
void B::workerLoop() {
while (!_exit) {
A::getInstance().doSomething(); // B 依赖 A
}
}

B 的析构函数要 join worker,但 worker 访问 A。如果 A 在 B 之前析构,~B() 尝试 join → worker 还在访问已析构的 A → use-after-free。

你无法控制这个顺序。依赖析构函数 = 依赖编译器掷骰子。

所以规则很简单:

析构函数负责释放资源,但不负责编排释放顺序。顺序由显式的 stop() 函数控制。

析构函数是你的安全网——在 stop() 没被调用或异常退出时兜底。但正常流程下,stop() 必须按你定义的顺序执行。

四、全景:不止一个 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 条目永不清理,异常状态下永久滞留
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 就泄漏

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

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

五、核心修复一:关机顺序的依赖链法则

5.1 依赖链分析

一个服务的组件之间一定存在依赖关系。依赖有方向——A 依赖 B 意味着:A 工作的时候,B 必须在运行。

从这个项目里,可以提取出三层依赖结构:

1
2
3
4
5
6
7
数据源(Ticker、Device、Emergency)
↓ 产生消息
消息队列(Logger、EventBus)
↓ 消费、转发
传输层(MqttClient、gRPC Channel)
↓ 发送到外部
外部系统(Broker、数据库)

停止顺序 = 依赖顺序的反向。

  • 数据源在最上层(只产出,不消费外部服务)
  • 消息队列在中间(消费数据源,依赖传输层)
  • 传输层在最下层(被所有人依赖)

所以 stop() 必须按这个顺序:

1
2
3
1. 停止数据源    → 不再有新消息产生
2. 停止消息队列 → 排空已有消息(此时传输层还活着)
3. 停止传输层 → 安全关闭(队列已空,不会有新消息到来)

5.2 修复后的 stop()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void SystemFactory::stop()
{
stopTickerLocked(); // 1. 停止定时器(数据源之一)
_rtsp_server = nullptr;

EmergencyFactory::getInstance().stop(); // 2. 停止紧急事件(数据源之二)
DeviceManager::getInstance().stop(); // 3. 停止设备(数据源之三)

csLog::Logger::getInstance().stop(); // 4. 停止日志队列
// stop() 内部执行:
// ① 设置 exitFlag = true
// ② worker 线程排空队列中剩余日志
// ③ 调用 join() 等待线程退出
// 排空期间 MqttClient 还活着,
// 确保队列中的日志能正常发出

MqttClient::getInstance().Stop(); // 5. 最后停止传输层
// Logger 已 join,不会再有新的 publish
}

5.3 线程池的两个陷阱

Logger 只有一个后台 worker,但 MqttClient 内部有线程池——更复杂的场景需要更多防御。

陷阱一:队列无限增长。

_task_queue 是一个 std::queue<std::function<void()>>,没有大小限制。如果 MQTT 消息到达速度超过线程池处理速度,队列无限增长,最终 OOM。

1
2
3
4
5
6
7
8
// 修复:硬上限 + 满时丢弃最旧任务
if (client->_task_queue.size() >= MQTT_MAX_TASK_QUEUE_SIZE) {
client->_task_queue.pop(); // 丢弃最旧
LOG_WARN << "MQTT task queue full, dropping oldest task";
}
client->_task_queue.push([cb, topic, payload]() {
cb(topic, payload);
});

陷阱二:Stop 后队列中的任务没有被释放。

Join 完所有 worker 线程后,_task_queue 里可能还残留着任务。这些任务中捕获的 topic 和 payload 字符串仍然占用内存,直到 MqttClient 单例最终析构。

1
2
3
4
5
// 修复:显式清空队列,立即释放捕获的资源
{
std::unique_lock<std::mutex> lock(_task_mutex);
std::queue<std::function<void()>>().swap(_task_queue);
}

六、核心修复二:所有权设计——从裸指针到智能指针

6.1 两种裸指针,两种命运

关机顺序修好了,但问题 #10 指向了一个更深层的设计问题。翻看 AisNmeaParseApi.cpp 中的 AIS 消息解析代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
bool ais_vdm_decode_payload(const AisVdmSentence& vdm_sentence,
AisVdmMessage** message) { // 双裸指针
*message = nullptr;

switch (msg_type) {
case 1: {
auto* m = new AisVdmMessage_1_2_3(); // 手动 new
if (!parse(s6, m)) { delete m; return false; } // 失败路径手动 delete
*message = m; // 所有权丢给调用方
return true;
}
case 5: {
auto* m = new AisVdmMessage_5();
if (!parse(s6, m)) { delete m; return false; } // 又是手动 delete
*message = m;
return true;
}
// ... 还有 3 个 case,每个都要手写 delete ...
}
}

// 调用方
AisVdmMessage* msg_ptr = nullptr;
if (!ais_vdm_process_message(raw, &msg_ptr, _aisFrag))
return false;
if (!msg_ptr)
return false;
std::unique_ptr<AisVdmMessage> msg(msg_ptr); // 调用方也觉得不安全,赶紧包一层

这段代码有 6 个 new,散布在 5 个 case 分支中。每个分支都有独立的失败路径,每条失败路径必须手写 delete。如果一个新人加了一个 case 6,忘记在失败路径写 delete?泄漏。如果中间某处抛了异常?泄漏。调用方忘了包 unique_ptr?泄漏。调用方包了 unique_ptr,但中间某层函数提前 return 了?泄漏。

这不是"精细控制",这是"人为制造泄漏点"。

6.2 修复:所有权从源头就显式化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool ais_vdm_decode_payload(const AisVdmSentence& vdm_sentence,
std::unique_ptr<AisVdmMessage>& message) {
message.reset();

switch (msg_type) {
case 1: {
auto m = std::make_unique<AisVdmMessage_1_2_3>();
if (!parse(s6, m.get())) { return false; } // 失败,m 自动析构
message = std::move(m); // 所有权显式转移
return true;
}
// ... 其他 case 同理,不再需要手动 delete ...
}
}

// 调用方
std::unique_ptr<AisVdmMessage> msg;
if (!ais_vdm_process_message(raw, msg, _aisFrag))
return false;
// msg 直接可用,无需再包一层

所有权从创建点就用 unique_ptr 管理,在任何执行路径下都一致:成功 → 移动给调用方;失败 → 自动析构。

6.3 裸指针也有正确用法

同一个项目里,MainNode 的这几个裸指针完全没有问题:

1
2
3
4
5
class MainNode {
BeiDou* _beidouDevice = nullptr; // 指向北斗设备
ZD* _zdDevice = nullptr; // 指向 ZD 设备
SSPC* _sspcDevice = nullptr; // 指向 SSPC 设备
};

这些指针指向的是 Singleton<T> 静态实例,生命周期是整个程序。MainNode 只是"借用"它们来读取数据,不拥有、不负责销毁。

裸指针在这里是正确的——因为它准确传达了"非所有权引用"的语义。 如果换成 shared_ptr,反而会误导读者:难道 MainNode 参与了设备对象的生命周期管理?

6.4 决策框架

同一个项目,裸指针有时是 bug,有时是最佳实践。差别不在指针类型,在回答这个问题:

谁创建、谁销毁、有没有共享?

你创建了对象? 你负责销毁? 别人也共享? 用什么 例子
unique_ptr 工厂函数返回新对象,调用方独占
shared_ptr 多个模块持有同一配置对象
裸指针 / 引用 观察者模式、指向静态单例的指针
是(特殊内存) placement new / 自定义删除器 内存池、共享内存、GPU 显存
跨 DLL 边界 裸指针 + 工厂销毁函数 ABI 兼容性要求

使用这个框架的方法很简单:

  1. 看一眼你的代码,找到所有 new
  2. 问:这个 new 出来的对象,谁负责 delete
  3. 如果答案"不明确"或"看情况"——那就是 bug 埋藏的地方。
  4. 如果答案明确——unique_ptr(独占)、shared_ptr(共享)、裸指针(借用),选哪一个自然就知道了。

6.5 "智能指针是黑盒"的幻觉

unique_ptr 是黑盒的人,通常有一个隐含假设:手动 new/deleteunique_ptr 更"可控"。

但实际项目里——对象经历 3 层调用链,从 decode_payloadprocess_messageprocessRaw,每一层都可能提前 return、都可能抛异常、都可能被新来的同事插入一个分支。你觉得你能追踪每一条路径上的 delete

unique_ptr 的析构是确定性的、可预测的——离开作用域就释放。 这等价于你在作用域末尾写了一个永远不会被跳过的 delete。这不是"黑盒",这是"编译器帮你执行了你本来就应该写的 delete"。

编译器不会忘,你会。

C++ 真正的魅力不是"可以手动管理每一字节",而是 你可以选择用什么样的抽象来管理资源。选择什么不反映你对语言的熟悉程度,反映你对正在解决的问题的理解深度。

七、防线加固:容器清理与回调生命周期

除关机顺序和所有权两大主线外,还有两道需要加固的防线。

7.1 有进必有出:容器的对称性

问题 #2、#3、#5 都是容器管理的变体——创建(插入)逻辑完善,清理(删除)逻辑要么缺失、要么有盲区:

容器 问题 修复
_ais_trackers (unordered_map) 每个见过的 MMSI 都插入,永不删除 新增 pruneExpiredAisTrackers(),60s 无更新自动清理
_track_associations locked 条目被 purge 无条件跳过,永久滞留 locked 条目加超时兜底(60s 强制解锁)
_fused_targets 120s 超时窗口内可能因繁忙海域增长到数千 硬上限 500,两轮淘汰:优先去除纯 AIS 条目,再淘汰最旧

你写 map[key] = value 的时候,有没有想过这个 key 什么时候被 erase?

7.2 有注册必有注销:回调的对称性

问题 #4 更为隐蔽:

1
2
3
4
5
6
7
8
9
// ai_node.cpp — init() 中注册回调
_channel.registerTopic("/reply/pose", [this, topic](const std::string& t, const std::string& p){
this->_channel.sendToUav(topic, p);
});

// 析构函数
AiNode::~AiNode() {
// 空的!回调没有注销!
}

Lambda 捕获了 this,注册到 MqttClient 单例的回调映射中。如果 AiNode 析构了但 MqttClient 还活着,下次收到 /reply/pose 消息时,回调就会通过悬空的 this 访问已析构的 AiNode。

每一个 register 都要有一个对应的 unregister 修复:让 TopicChannel 的析构负责清理:

1
2
3
4
5
TopicChannel::~TopicChannel() {
for (const auto& topic : _registeredTopics) {
MqttClient::getInstance().unregisterCallback(topic);
}
}

这又呼应了同一原则:如果不显式做注销,析构的时候没人替你兜底。

八、最终输出

修复全部完成后再次运行 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++ 程序员能看到的最美的日志。

九、教训

9.1 四条原则

  1. 不要依赖 C++ 静态对象的析构顺序。 跨编译单元无保证。显式 stop() + join() 是你唯一可靠的朋友。析构函数负责释放资源,但不负责编排释放顺序。

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

  3. 智能指针的问题不是"用哪个",而是"谁拥有这个对象"。 回答三个问题——谁创建、谁销毁、有没有共享——指针类型自然明确。不要因为"C++ 的魅力在于手动控制"而拒绝用 unique_ptr,真正的控制力是你知道每个对象归谁管、什么时候销毁,不是你去手写每一条 delete

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

9.2 shutdown 检查清单

如果你正在维护一个 C++ 多线程服务,拿出你的 stop() 函数,逐条检查:

  1. 数据源是否最早停? Ticker、设备管理器、事件生产者——它们停了,才不会在关机过程中继续产生新数据。
  2. 队列/缓冲区是否在消费者之前排空? stop() 里的 join() 之前,有没有设置退出标志 + 唤醒等待线程?
  3. 传输层是否最后停? MQTT、gRPC、数据库连接——它们被所有人依赖,必须最后关闭。
  4. 每个后台线程都 join 了吗? 有没有遗漏的 std::threadstd::async 的 future?
  5. 容器有上限吗? 每一个 push/insert 的位置,有没有考虑过"如果容器无限增长会怎样"?
  6. 每个 register/addListener 对应的 unregister/removeListener 在哪里? 析构函数里有吗?还是依赖静态对象析构顺序赌运气?
  7. 静态对象之间有没有依赖关系? 如果有,你的 stop() 函数有没有显式控制析构顺序?

9.3 所有权检查

下次写 new 的时候,停一秒,回答三个问题:

  1. 谁拥有这个对象?(哪个模块、哪个类、哪个函数)
  2. 它什么时候被销毁?(请求结束?程序退出?引用计数归零?)
  3. 销毁操作由谁触发?是显式调用,还是自动析构?

如果你答不出第 1 个——停下来,先想清楚设计。
如果你答得出第 1 个但答不出第 2、3 个——用 unique_ptr,让编译器帮你答。
如果你全部答得出——裸指针还是智能指针,你自然知道。


stop() 不是写完 start() 之后随便补的那几行代码。它是你服务的降落伞。平时不打开,打开的时候必须没问题。

如果你从来没关注过 stop()——现在去看一眼。很可能已经有 112 个 Valgrind 错误在等着你了。