112 个 Valgrind 错误,1 个根因:C++ 多线程服务的关机顺序复盘

说明 :本文案例来自一次真实的多线程 C++ 服务崩溃复盘。为遵守保密约定,代码中的文件名、类名与模块名均已替换为中性示意名 (如 BusClient / LogSink / ServiceManager),调用链结构、错误类型、修复思路与实测结果均为原样。

那天晚上我盯着终端看了一个小时。Valgrind 报 112 个错误,全是 use-after-free。

第一反应是"这得修到什么时候"。后来发现,112 个错误只对应 1 个根因 ------ServiceManager::shutdown() 里漏了一行 AsyncLogger::stop()。

但这个发现反而让事情变得更麻烦:修完 Valgrind 报 0 之后,我又人工审查出 10 个 Valgrind 根本抓不到的问题。

先把结论放前面:

  • Valgrind 的 ERROR SUMMARY 数的是「错误实例」,不是「缺陷数」。 一个 use-after-free 落在循环里,访问多少次就报多少次。112 这个数字的体量,来自"后台线程还在跑"这件事,不是来自"有 112 处代码写错了"。
  • RAII 救不了这个问题,而且原因比"编译器心情"更硬。 跨编译单元的静态对象析构顺序 = 构造顺序的逆序 ,而函数内 static 的构造时机取决于"第一次执行到那一行"的时刻------多线程启动时,这本身就未必稳定。
  • 停止顺序 = 依赖图的逆拓扑序。 不能靠每个类在自己析构函数里 join 自己的线程:join() 只保证"我的线程退出了",不保证"我的线程依赖的对象还活着"。
  • join() 是有方向的,方向弄反了比不 join 更糟------你会在一个已经关掉的通道上疯狂发消息。
  • 修完之后 ERROR SUMMARY: 0 只是及格线。 无界容器、逻辑泄漏、单例模板缺陷、裸指针所有权------这四类问题 Valgrind 一个都报不出来,但它们都能在生产上要命。
  • 最终验收看的是这一行 :in use at exit: 0 bytes in 0 blocks + 19,450 allocs, 19,450 frees。分配数与释放数相等,才是真的干净。

下面按"看到什么 → 怎么读 → 根因在哪 → 怎么修 → 修完还漏了什么"走一遍。


一、先看那份日志

ini 复制代码
==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: BusClient::publish (bus_client.cpp:382)
==3300263==    by 0x1935E7: LogSink::flush (log_sink.cpp:68)
==3300263==    by 0x191540: AsyncLogger::worker (async_log.cpp:315)
==3300263==  Address 0x6a308de is 14 bytes inside a block of size 31 free'd
==3300263==    at 0x484BB6F: operator delete
==3300263==    by 0x1936E5: LogSink::~LogSink (log_sink.h:9)
==3300263==    by 0x62B1494: __run_exit_handlers (exit.c:113)

同一时刻的普通日志,比 Valgrind 更直白:

arduino 复制代码
09:13:34.869 I Waiting for bus thread to exit...
09:13:34.870 I bus 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 bus 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!

这两份日志放一起,信息量远大于各自单独看。下面拆。


二、这份日志怎么读

Valgrind 的报错格式是固定的,读懂三个字段就能直接定位:

2.1 第一段:谁在读,读到哪一步

csharp 复制代码
Invalid read of size 1
   at memmove
   by ...xsputn
   by ...__ostream_insert
   by operator<<
   by BusClient::publish (bus_client.cpp:382)
   by LogSink::flush (log_sink.cpp:68)
   by AsyncLogger::worker (async_log.cpp:315)

调用栈是从上往下的"深入",从下往上是"因果"。 最底下的 AsyncLogger::worker 是源头,最上面的 memmove 是真正崩的那条指令。

关键在于:只有最后两三层是你自己的代码 ,中间全是标准库。所以读 Valgrind 报告的技巧是------从下往上,跳过所有 std:: 和 0x60... 开头的系统库帧,找第一个"你的文件:你的行号" 。

这里是 log_sink.cpp:68。这一行在做 operator<<,把一个字符串塞进流。

2.2 第二段:那块内存发生了什么

vbnet 复制代码
Address 0x6a308de is 14 bytes inside a block of size 31 free'd
   at operator delete
   by LogSink::~LogSink (log_sink.h:9)
   by __run_exit_handlers (exit.c:113)

这一段的三个数字都很重要:

字段 值 含义
偏移 14 bytes inside 读的位置不在块头,在块内第 14 字节------说明访问的是一个结构体的某个字段,不是整个对象
块大小 block of size 31 被释放的是一块 31 字节的小内存,典型的小字符串缓冲区
释放者 LogSink::~LogSink (log_sink.h:9) 是析构函数释放的
释放时机 __run_exit_handlers 是程序退出阶段,不是运行时

__run_exit_handlers 这一帧是决定性的。它告诉你:这块内存不是在业务逻辑里被释放的,是在进程退出、静态对象批量析构时被释放的。

2.3 两段的交点,就是根因

把两段并排看:

rust 复制代码
   读的一方                        释放的一方
   ---------                       ---------
   AsyncLogger::worker             __run_exit_handlers
     -> LogSink::flush               -> LogSink::~LogSink
       -> BusClient::publish           -> operator delete
         -> ... memmove

同一个 LogSink:一个线程还在调它的 flush(),而它的析构函数已经在跑了。

这不是"某处指针忘了置空",是两个生命周期重叠了。指针置空解决不了这个------你不可能在校验和使用之间锁住另一个线程的析构。

2.4 普通日志补上的那一半

Valgrind 只告诉你"读了已释放内存",它不告诉你"为什么那时候还在读"。第二份日志补上了:

makefile 复制代码
09:13:34.870 I bus thread joined successfully!      ← 通信线程已退出
09:13:34.871 E Publish message failed ...           ← 但还在 publish
09:13:34.894 W bus client stopped successfully!     ← 客户端已停止
09:13:34.904 E Publish message failed ...           ← 距关机开始 34 ms,还在 publish

"bus client stopped successfully" 之后 10 毫秒还在报 publish 失败------这句话就是铁证:通信层关闭成功,但还有人在用它。

而且注意报错的密度:从 .871 到 .906,35 毫秒里 5 条 publish 失败 。这不是"偶发一次",是一个循环在高速空转。这就是 112 这个数字的来源。


三、追查:缺的那一行

翻开 ServiceManager::shutdown():

css 复制代码
void ServiceManager::shutdown()
{
    stopTickerLocked();
    _streamServer = nullptr;
​
    AlarmService::getInstance().stop();
    DeviceRegistry::getInstance().stop();
    BusClient::getInstance().Stop();   // ← 通信停了
    // 但是 AsyncLogger 呢???
}

AsyncLogger 根本没被停。

依赖链是完整的:

rust 复制代码
AsyncLogger::worker()
  -> LogSink::flush()
    -> BusClient::publish()

AsyncLogger 依赖 LogSink,LogSink 依赖 BusClient。shutdown() 停了链条最下端的 BusClient,却把最上端的 AsyncLogger 留在跑。

于是:worker 继续 flush → flush 发现通道关了 → 打一条错误日志 → 错误日志又走 flush → 再失败 → 再打...... 形成了一个失败自激循环 ,直到静态析构的骰子掷出来,LogSink 先析构,然后每一次 flush 都是一次 use-after-free。

每个后台线程都是一笔 stop() 债务。不还,迟早出问题------而且往往是在退出阶段,测试最不容易覆盖到的时候。


四、为什么 RAII 在这里失效

很多人的第一反应是:C++ 有 RAII,析构函数自动清理,为什么还要手写 stop()?

因为析构函数解决的是单对象清理 ,它解决不了跨对象依赖顺序。

4.1 静态析构顺序到底由什么决定

这一点常被含糊成"编译器心情",但实际机制是明确的,而且比"心情"更麻烦。

函数内 static 对象(static T& getInstance() { static T inst; return inst; })的构造时机是第一次执行到那一行 。构造时编译器会向 __cxa_atexit 注册一个析构回调。进程退出时,__run_exit_handlers 按注册顺序的逆序依次调用这些回调。

所以:

arduino 复制代码
析构顺序 = 构造顺序的逆序
构造顺序 = 首次执行到 static 声明那一行的顺序
首次执行顺序 = 运行时的初始化调用顺序

最后一行是关键。这不是编译期属性,是运行时属性。

同一个二进制、同一台机器、同一个编译器,只要初始化路径不同(多线程启动时哪个线程先摸到那个单例,本身就存在竞争),析构顺序就可能翻转。

所以"换个编译器才复现"这个说法太保守了。真实情况是:它可能只是某次发布后偶尔崩一次,重启就好了------这是最难查的那类 bug。

4.2 一个最小骨架

css 复制代码
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。

B 的析构函数做对了它该做的事,仍然崩。 因为它控制不了 A 什么时候死。

4.3 分工

scss 复制代码
析构函数:负责释放资源(我自己的)
stop()  :负责编排释放顺序(我和别人的关系)

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

把顺序交给析构函数,等于把一个有序问题 交给一个无序机制。


五、修 shutdown:依赖图的逆拓扑序

5.1 把依赖画出来

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

lua 复制代码
   +----------------------------------------------+
   |  数据源    Ticker / Device / Event            |   只产出
   +----------------------------------------------+
                      | 产生消息
                      v
   +----------------------------------------------+
   |  消息队列  Logger / EventBus                  |   消费上游,依赖下游
   +----------------------------------------------+
                      | 消费、转发
                      v
   +----------------------------------------------+
   |  传输层    BusClient / RPC Channel            |   被所有人依赖
   +----------------------------------------------+
                      | 发送
                      v
   +----------------------------------------------+
   |  外部系统  Broker / DB                        |
   +----------------------------------------------+

这个图有三层,边的方向是"依赖"。

停止顺序必须是这个图的逆拓扑序------先停没有出边的(最上游),最后停所有边都指向它的(最下游)。

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

为什么第二条必须夹在中间?因为排空需要下游还活着。你停了传输层再停队列,队列里剩下的消息就只能丢------日志丢了,等于关机这段时间的现场没了。

5.2 修复后的 shutdown

scss 复制代码
void ServiceManager::shutdown()
{
    stopTickerLocked();                          // 1. 停定时器(数据源之一)
    _streamServer = nullptr;

    AlarmService::getInstance().stop();          // 2. 停事件源(数据源之二)
    DeviceRegistry::getInstance().stop();        // 3. 停设备(数据源之三)

    AsyncLogger::getInstance().stop();           // 4. 停日志队列
                                                 //    stop() 内部:
                                                 //      ① 设置 exitFlag = true
                                                 //      ② worker 排空队列中剩余日志
                                                 //      ③ join() 等线程退出
                                                 //    排空期间 BusClient 还活着,
                                                 //    队列里的日志能正常发出

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

第 4 步和第 5 步的先后是整个修复的核心。原来这两步行序反了,代价是 112 个错误。

5.3 三个判据

回头看这个改动,判断顺序对不对可以用三句话:

  1. 每个停操作之前,问"它还会不会收到新输入" ------ AsyncLogger 的输入来自数据源,所以数据源必须先停。
  2. 每个停操作之后,问"它排空时还需要谁活着" ------ AsyncLogger 排空需要 BusClient,所以 BusClient 必须后停。
  3. 整条链上,问"有没有环" ------ 如果 A 排空需要 B、B 排空需要 A,那不是顺序问题,是设计问题,必须先打破循环(比如让排空变成单向的、或者引入一个旁路通道)。

第 3 条最容易被忽略。依赖图有环的时候,任何停止顺序都是错的。

5.4 线程池的两个附加陷阱

AsyncLogger 只有一个 worker,BusClient 内部有线程池,问题更复杂。

陷阱一:队列无界。

_pendingTasks 是 std::queue<std::function<void()>>,没有上限。消息到达速度超过线程池处理速度时,队列无限增长,最后 OOM。

scss 复制代码
// 修复:硬上限 + 满时丢弃最旧任务
if (client->_pendingTasks.size() >= MAX_PENDING_TASKS) {
    client->_pendingTasks.pop();  // 丢弃最旧
    LOG_WARN << "bus task queue full, dropping oldest task";
}
client->_pendingTasks.push([cb, topic, payload]() {
    cb(topic, payload);
});

丢弃最旧而不是拒绝最新,是个有意的选择:消息系统里旧消息的价值通常低于新消息(状态会覆盖),而拒绝最新会让上游阻塞。但这个选择要写进注释------否则下一个人会以为是 bug。

陷阱二:Stop 之后队列没被释放。

join 完所有 worker 后,_pendingTasks 里可能还残留着任务。这些 std::function 里捕获的 topic 和 payload 字符串仍然占着内存,直到单例最终析构------而单例析构在 __run_exit_handlers 阶段,也就是整个进程生命周期都不释放。

arduino 复制代码
// 修复:显式清空队列,立即释放捕获的资源
{
    std::unique_lock<std::mutex> lock(_pendingMutex);
    std::queue<std::function<void()>>().swap(_pendingTasks);
}

swap 一个临时空队列,比 while (!q.empty()) q.pop() 更彻底:它把底层容器的内存一起换走,而不只是清空元素。


六、全景:Valgrind 没报的 10 个问题

关机顺序修完之后,我意识到这不是孤立 bug------存在线程生命周期问题的地方,通常还有一批同类问题。于是做了一次系统性审查。

结果 10 个问题,而 Valgrind 一个都没报:

# 风险 类别 位置 简述
1 高 无界容器 bus_client.h 内部任务队列无大小限制,高频消息下无限增长导致 OOM
2 高 无界容器 会话表 每个新连接插入一条,断开时从不删除
3 高 无界容器 索引表 异常分支下跳过清理,条目永久滞留
4 中 悬空引用 节点注册回调处 lambda 捕获 this 注册到全局单例,节点析构后回调仍可能被触发
5 中 短期膨胀 结果缓存 超时窗口内在数据密集时可能增长到数千条目
6 中 逻辑泄漏 bus_client.cpp Stop 停机时未清空任务队列,残留任务与 payload 不被释放
7 低 protobuf 调用链上的 thread_local 缓冲 protobuf 内部缓冲区不释放
8 低 protobuf state_machine.cpp shared_ptr 持有的 protobuf 对象内部 arena 永不缩小
9 低 设计缺陷 单例模板 getSharedInstance() 与 getInstance() 返回不同实例
10 低 脆弱所有权 报文解码模块 MessageT** 双裸指针手动传递所有权,任何一层忘记 delete 就泄漏

为什么 Valgrind 一个都没报? 因为这 10 个问题的共同特征是:

资源在创建时有明确逻辑,但销毁(清理)的条件要么缺失、要么不完整。

它们的形态是"内存一直涨但程序不崩"、"条目一直留但没有指针悬空"、"两个实例都存在但都不算错"。Valgrind 检测的是非法访问 ,不是冗余占用。

一句话总结这张表:

  • 每一条 new 都欠一条 delete
  • 每一条 push 都可能欠一条 pop
  • 每一条 registerCallback 都欠一条 unregisterCallback

代码 review 时,对每一个"创建"操作,去找它的"销毁"操作在哪里------找不到,就是 bug。


七、修所有权:从双裸指针到 unique_ptr

问题 #10 指向一个更深的设计问题。解码代码长这样:

arduino 复制代码
bool decode_payload(const SentenceT& sentence,
                    MessageT** message) {    // 双裸指针
    *message = nullptr;

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

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

这段代码有 6 个 new,散布在 5 个 case 分支里 。每个分支都有独立的失败路径,每条失败路径都得手写 delete。

数一下有多少种泄漏方式:

  • 新人加一个 case 6,忘了在失败路径写 delete → 泄漏
  • 中间某处抛异常 → 泄漏
  • 调用方忘了包 unique_ptr → 泄漏
  • 调用方包了 unique_ptr,但中间某层函数提前 return → 泄漏

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

改成这样:

c 复制代码
bool decode_payload(const SentenceT& sentence,
                    std::unique_ptr<MessageT>& message) {
    message.reset();

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

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

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

7.1 裸指针也有正确用法

同一个项目里,下面这几个裸指针完全没问题:

ini 复制代码
class NodeLoop {
    BusClient*   _client = nullptr;   // 指向全局唯一的通信客户端
    AsyncLogger* _logger = nullptr;   // 指向全局唯一的日志器
};

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

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

7.2 决策框架

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

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

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

用法很机械:

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

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

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

但实际项目里,对象要过三层调用链,从 decode_payload 到 process_message 到 processRaw,每层都可能提前 return、都可能抛异常、都可能被新同事插入一个分支。你确定能追踪每一条路径上的 delete?

unique_ptr 的析构是确定性的------离开作用域就释放。 这等价于你在作用域末尾写了一个永远不会被跳过的 delete。

这不是"黑盒",这是"编译器帮你执行了你本来就应该写的 delete"。

编译器不会忘,你会。


八、修对称性:容器与回调

8.1 有进必有出

问题 #2、#3、#5 都是同一个变体------插入逻辑完善,删除逻辑要么缺失、要么有盲区:

容器 问题 修复
会话表(unordered_map) 每来一个连接插入一条,断开时不删,只增不减 显式增加过期清理,长期无更新即回收
索引表 异常分支下跳过清理,条目永久滞留 补上异常路径的清理,并加容量兜底
结果缓存 超时窗口内数据密集时可能涨到数千条目 增加容量上限与淘汰策略,优先回收价值最低的条目

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

8.2 有注册必有注销

问题 #4 更隐蔽:

c 复制代码
// worker_node.cpp ------ init() 中注册回调
_channel.subscribe("/topic/reply", [this, topic](const std::string& t, const std::string& p){
    this->_channel.sendTo(topic, p);
});

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

Lambda 捕获了 this,注册进 BusClient 单例的回调映射。如果 WorkerNode 析构了但 BusClient 还活着,下次收到该主题的消息,回调就会通过悬空的 this 访问已析构的 WorkerNode。

这个 bug 在 Valgrind 里也不一定立刻现形------只有那条消息恰好到达时才触发。测试环境里那个 topic 可能从来没人发。

修复:让持有注册的那个对象负责注销。

css 复制代码
TopicRouter::~TopicRouter() {
    for (const auto& topic : _registeredTopics) {
        BusClient::getInstance().unregisterCallback(topic);
    }
}

这里的要点是把"注册了什么"记下来 。很多实现注册完就不记 topic,导致析构时根本不知道该注销什么------所以 _registeredTopics 这个成员本身就是修复的一部分。


九、112 → 0:错误实例不是缺陷数

这是本文最想留下的一段。

初次看到 ERROR SUMMARY: 112 errors,人的直觉是"112 个 bug"。我按这个直觉估了工作量,估错了。

9.1 112 是怎么数出来的

Valgrind 的 ERROR SUMMARY 数的是错误事件:每一次非法的内存访问算一次。

看第一节的来源栈:

rust 复制代码
AsyncLogger::worker (async_log.cpp:315)   <- 这是个 while 循环
  -> LogSink::flush
    -> BusClient::publish
      -> ... invalid read

worker 是循环。BusClient 关了之后,每次循环都会 publish 失败、打印错误、然后继续下一轮------每一轮都触发一次 invalid read。

普通日志里 35 毫秒内 5 条 Publish message failed,就是这个循环的速度。所以 112 这个数字,大概率是"它在被杀死之前跑了多少轮"的时间量,不是代码量。

这一条我没法从日志反推出确切轮数------日志本身也被截断了。但"同一条栈被重复计数上百次"这件事,在单线程顺序执行的代码里不可能发生,只能是循环。

换句话说:112 计量的是时长,不是缺陷数。

9.2 折叠

把 112 个实例折回根因,是一张极短的表:

Valgrind 报的 实际根因 修法
112 × invalid read(全部来自同一条栈) shutdown() 漏了 AsyncLogger::stop(),worker 在 BusClient 停止后继续 publish 调换第 4、5 步的顺序

1 个根因,1 处修改。

而第十节那张 10 个问题的表,0 个是 Valgrind 报出来的。

两者合起来才是完整结论:

rust 复制代码
   Valgrind 能抓的             Valgrind 抓不到的
   ---------------             -------------------
   非法访问(崩溃类)           冗余占用(增长类)
   112 个实例 -> 1 个根因       10 个问题 -> 10 个根因
   有栈、有地址、可定位          没有信号、只能靠审查

这个划分决定了排查顺序:先用工具把"会崩的"清零,再靠人工把"不崩但会烂的"列出来。 反过来做没有意义------工具会把你带到崩溃点,但不会告诉你"这个容器没有上限"。

9.3 验收指标

修完再跑 Valgrind:

ini 复制代码
==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

注意 total heap usage: 19,450 allocs, 19,450 frees 这一行。

光看 ERROR SUMMARY: 0 不够。 它只说明"没有非法访问",不说明"没有泄漏"。真正的干净要三个条件同时成立:

条件 这一行 说明
退出时零占用 in use at exit: 0 bytes in 0 blocks 没有遗留
分配释放配平 19,450 allocs, 19,450 frees 次数相等才说明每次都还了
零错误 ERROR SUMMARY: 0 errors 没有非法访问

三个都对,才叫修完。少一个都还在漏。


十、怎么机械地找出所有债务点

第十节的 10 个问题不是靠灵感发现的。有一套可重复的方法:每一类"创建"操作,都对应一个可以 grep 的符号。

perl 复制代码
# 1) 线程债务:每个匹配都是一个必须被 join 的线程
grep -rn "std::thread|std::async|.detach()" src/

# 2) 回调债务:每个匹配都应该有一处对应的注销
grep -rn "registerCallback|unregisterCallback|subscribe|addListener" src/

# 3) 所有权债务:每个 new 都要能回答"谁 delete"
grep -rn "\bnew |make_shared|make_unique" src/

# 4) 容器增长点:每个插入位置都要能回答"上限是多少"
grep -rn ".push_back(|.push(|.insert(|.emplace" src/

每条 grep 的结果,按这张表判:

匹配到的 必须能找到 找不到的后果
std::thread 同一对象的 join() 或 detach() 程序退出时 std::terminate
subscribe / register* 对应的 unsubscribe / unregister* 悬空 this 回调
new(裸) 明确的所有者,或改 make_unique 泄漏点
push / insert 容量上限 + 淘汰策略 无界增长 → OOM

这套方法的威力在于它不依赖你理解业务 。你不需要知道这个模块干什么,只要数 thread 有没有对应的 join。

按这张表过一遍第十节那 10 个问题,会看到一个明确的重心: #1、#2、#3、#5 四个问题全部落在第四类(容器增长点) ------这一类最容易被 review 忽略,因为它不崩、不报错,只是慢慢涨。


十一、四条原则与两张清单

11.1 四条

  1. 不要依赖 C++ 静态对象的析构顺序。 跨编译单元无保证,而且顺序是"首次调用顺序的逆序"------运行时属性。显式 stop() + join() 才是可靠的。析构负责释放资源,不负责编排释放顺序。
  2. 资源的创建和销毁必须对称。 每一条 new 欠一条 delete,每一条 push 可能欠一条 pop,每一条 registerCallback 欠一条 unregisterCallback。
  3. 智能指针的问题不是"用哪个",而是"谁拥有"。 回答"谁创建、谁销毁、有没有共享",指针类型自然明确。真正的控制力是你知道每个对象归谁管,不是手写每一条 delete。
  4. Valgrind 不会骗你。 它说 112 个错误就是 112 个错误。但要记住它数的是实例 ,不是缺陷------而且它报不出的那一类(冗余占用)同样能要命。

11.2 shutdown 检查清单

拿出你正在维护的多线程服务的 stop(),逐条过:

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

11.3 所有权检查

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

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

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


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

如果你从来没认真看过自己的 stop(),现在去看一眼。可能已经有 112 个 Valgrind 错误在等着了。

而且记得:把 112 修成 0,只是把"会崩的"清掉了。剩下的"不崩但会烂的",还得你自己去数。


更多技术文章在我的博客:hespethorn.github.io

相关推荐
小兔子21 分钟前
new Date('2024-01-01') 和 new Date('2024/01/01') 差在哪
后端
Oiiouui2 小时前
linux服务器部署Logto后端与对应前端的实现
linux·服务器·前端·后端
HelloWorld0012 小时前
首屏加载慢如蜗牛?基于 Rust SWC + Module Federation + 资源预加载打造极致丝滑微前端架构实战
架构
shenlongproxy2 小时前
代理IP故障演练怎么做:注入怎么分档、恢复怎么判定
后端
知识搬运临时工2 小时前
Kafka 发 1 GiB 数据,用户态 CPU 只花 0.016 秒,总耗时反而慢了 11%
后端·kafka
wt95272 小时前
VMware 虚拟机磁盘扩容实战文档(Ubuntu 22.04 + LVM)
linux·后端
王中阳Go2 小时前
预算给够了,时间花错了地方:三跳链路给了 27 秒,实际只用掉 9 秒
人工智能·后端·性能优化
用户094248568032 小时前
第22章:Redis 持久化 RDB 与 AOF:数据不丢的底线
redis·架构·源码
用户394995084632 小时前
文件变了,inotify 为什么没反应
后端