C++ 语言实战(二):RAII 与智能指针——72 小时句柄泄漏打爆网关

第 2 / 13 章
C++ 语言实战(二):RAII 与智能指针——72 小时句柄泄漏打爆网关

上一章我们把网关骨架立了起来,C++ 版在工控机上跑得干干净净:零丢帧、31MB 内存。但那只是它健康的前三天。这一章讲的就是第四天发生的事——网关没崩,是慢慢「憋死」的。这个事故也是 C++ 给新人的见面礼:你从 GC 语言带来的每一个直觉,在这里都要重新对价。

一、事故现场:连接数曲线在慢慢爬坡

上线第 72 小时,值班群里有人贴了张图:网关的 Modbus TCP 连接数从上线时的 200 一路爬到了 3400 多,曲线形状是一条几乎完美的斜线,没有台阶、没有毛刺——一条匀速上升的直线。生产环境里,匀速爬坡的曲线几乎只有一种解释:泄漏。

到第 96 小时,fd 表打满,200 台设备集体掉线,采集停摆。重启后恢复,72 小时后重来。典型的「慢性病急性发作」。

问题代码就藏在重连逻辑里,看起来人畜无害:

bool Collector::poll_device(const DeviceConfig& cfg) {
    auto* conn = new ModbusConnection(cfg.ip, cfg.port);  // 每次采集新建连接
    if (!conn->connect()) {
        delete conn;
        return false;
    }
    auto samples = read_signals(*conn, cfg.signals);      // 可能抛异常
    conn->close();
    delete conn;
    return true;
}

每 100ms 对 200 台设备轮询一遍,read_signals 里解析响应帧。只要解析一旦抛异常——第 05 章你会看到,粘包、坏帧在产线上是日常——delete conn 就被跳过了。这个对象连同它持有的 socket、fd、缓冲区,从此无人认领。

账其实很好算:偶发坏帧率约 0.3%,每天轮询 200 × 864000 次/天……泄漏一点一点攒,攒到 96 小时把 fd 表打穿。没人「忘了写 delete」,只是 C++ 里任何一条非正常路径都必须自己负责归位,而异常就是那条最容易被人忽略的非正常路径。

二、排查过程:从曲线到泄漏栈的三步

第一步,判断泄漏性质。连接数是匀速爬坡而不是瞬时垂直拉起——这和第一章 Go 网关的「堆积」形态完全不同:堆积是进得快出得慢,泄漏是有进无出。曲线形状本身就是第一层诊断。

第二步,看 fd 都是什么。工控机上 lsof -p <pid> 一拉,八千多个 socket 全部处于 ESTABLISHED,对端是设备 IP,但本进程里已经没有任何代码路径还持有这些连接对象——连接还在,管理它的对象没了,这正是「丢失所有权的资源」的标准形态。

第三步,抓泄漏栈。开发机上把网关连到仿真器跑一小时,用 AddressSanitizer(链接时加 -fsanitize=address,它自带 LeakSanitizer):

==31847==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 320 byte(s) in 2 object(s) allocated from:
    #0 operator new
    #1 Collector::poll_device(DeviceConfig const&) collector.cpp:87
    #2 Collector::loop() collector.cpp:52

泄漏栈直指 poll_device 的那行 new。再用 valgrind 交叉确认(valgrind --leak-check=full,慢但更细致),definitely lost 与 asan 完全吻合。工具层面 20 分钟定案:异常路径绕过了 delete。

值得多说一句:这两个工具的分工——ASan 快,直接编进开发构建随测试跑;valgrind 不用重编译,适合对已发布的二进制做离线解剖。工业代码两个都要有。

三、底层原理:RAII 与智能指针的账本

RAII:把资源的命绑给对象

C++ 对象有一条黄金法则:栈对象离开作用域时,析构函数保证被调用;异常传播经过的每一层,其局部对象按构造的逆序依次析构——这个过程叫栈展开(stack unwinding)。RAII(Resource Acquisition Is Initialization)就是把这条法则变成资源管理策略:获取资源放在构造函数,释放放在析构函数,于是「对象活着 = 资源被持有,对象死了 = 资源已归还」。

class ScopedFd {
public:
    explicit ScopedFd(int fd) : fd_(fd) {}
    ~ScopedFd() { if (fd_ >= 0) ::close(fd_); }   // 正常退出、提前 return、异常,都到这
    ScopedFd(const ScopedFd&) = delete;             // fd 独占,禁止拷贝
    ScopedFd& operator=(const ScopedFd&) = delete;
private:
    int fd_;
};

这就是 GC 语言和 C++ 的本质分界:GC 的回收是「最终总会发生,但不知道何时」,析构是「一定发生在离开作用域的那一纳秒」。对内存,晚点回收无所谓;对 fd、锁、设备句柄,晚一纳秒都是错的。C++ 把内存也统一进了这套确定性体系——这是第一章说「没有 runtime 税」的另一半:也没有 runtime 的兜底,但换来的是资源的时机完全归你。

unique_ptr:默认选择,零开销

std::unique_ptr<T> 把上面那个手写 ScopedFd 的思路泛化成标准件:独占所有权、不可拷贝、可移动(转移所有权)。它最反直觉的地方是大小和裸指针完全一样(无引用计数、无额外内存),析构时直接调 delete——所以日常指针管理用它几乎没有运行时代价,优化后汇编里看不出区别。

struct ModbusConnectionDeleter {
    void operator()(ModbusConnection* p) const {
        if (p) { p->close(); }
        delete p;
    }
};
using ConnPtr = std::unique_ptr<ModbusConnection, ModbusConnectionDeleter>;

自定义删除器是接管 C 语言遗产的关键姿势——第 04 章接 modbus_connect 返回的 C 句柄、libcurl 的 handle,全靠它。

shared_ptr 与 weak_ptr:共享所有权的代价与陷阱

std::shared_ptr<T> 用引用计数实现共享所有权:每拷贝一次强计数 +1(原子操作),析构 -1,归零才销毁对象。代价三笔账:控制块内存(强/弱两个计数器)、每次拷贝/析构的原子操作、以及潜在的缓存行竞争。另外 shared_ptr 本身不是线程安全的——多个线程同时拷贝/析构同一个 shared_ptr 实例有数据竞争(计数是原子的,但实例不是),并发场景要么加锁,要么每线程一份拷贝。

make_shared<T>() 比 shared_ptr<T>(new T) 强在只做一次内存分配(对象和控制块连在一起),既快又缓存友好,还规避了异常时的泄漏窗口。

陷阱在循环引用:A 持有 B 的 shared_ptr,B 又持有 A 的,互相等对方归零——计数永远到不了 0,泄漏回归。解法是 std::weak_ptr<T>:只观察不持有(不增计数),使用时 lock() 临时升级成 shared_ptr。缓存、观察者回指、父子回链,凡「我认识你但我不负责你的生死」的关系,一律 weak_ptr。

三者选型一句话:默认 unique_ptr;多个持有者需要协商生死才上 shared_ptr;只是围观一律裸指针或引用(或 weak_ptr)。

四、正确姿势:网关重连逻辑重构

用 unique_ptr 改写事故代码,所有权一眼可读:

bool Collector::poll_device(const DeviceConfig& cfg) {
    ConnPtr conn{new ModbusConnection(cfg.ip, cfg.port)};  // 获取即托管
    if (!conn->connect()) return false;                     // 异常路径自动归还
    auto samples = read_signals(*conn, cfg.signals);        // 抛异常?析构兜底
    return true;
}   // conn 离开作用域:close + delete,任何路径都不会漏

配合的工程约定四条:

  1. 禁裸 new/delete:网关代码里不允许出现裸 new(容器内部实现除外)。所有权必须挂在智能指针上。
  2. 工厂返回 unique_ptr:make_driver("modbus") 返回 unique_ptr<Driver>,所有权语义写在返回类型里,调用方不需要读文档就知道该谁 delete。
  3. 传参定规矩:转移所有权按值传 unique_ptr;共享使用传 const shared_ptr&(避免无谓的原子计数);只是用一下传裸引用 const T&——大量代码滥用 shared_ptr 传参,把生命周期问题变成了性能问题。
  4. C 句柄一律自定义删除器包壳:不让任何 C 风格资源以裸指针形态活在业务代码里。

五、数据说话

重构前后同一台工控机、同一份仿真信号源,各跑 96 小时:

指标重构前(手动 delete)重构后(unique_ptr)
96h 末连接数7,412(fd 打满掉线)200(恒定)
ASan 泄漏报告每小时 +384 byte0
采集循环 P50 耗时3.1ms3.1ms
进程 RSS(96h)缓慢爬升至 210MB稳定 31MB

P50 完全没变——这就是 unique_ptr「零开销」的实测注脚。另一组微基准:单线程循环 1 亿次拷贝指针,裸指针/unique_ptr 均 0.0ns/次(编译器直接优化没了),shared_ptr 拷贝 4.8ns/次(原子计数),shared_ptr 按 const& 传参则回到 0.9ns/次。数量级不大,但在每秒 2000 次的采集热路径上,按值传 shared_ptr 就是给每个采集周期白交税。

六、面试怎么答

Q1:什么是 RAII?为什么 C++ 依赖它管理资源? 框架:定义(资源获取即初始化,资源生命周期绑定栈对象)→ 机制(栈展开保证异常路径也析构)→ 对比(GC 只管内存且回收时机不定,fd/锁/句柄需要确定性释放)→ 落点(unique_ptr/shared_ptr/lock_guard/fstream 全是 RAII 的实例)。能从 fd 泄漏讲到异常安全,就是有实战的分水岭。

Q2:unique_ptr 和 shared_ptr 怎么选? 默认 unique_ptr,理由三连:零开销、所有权清晰(谁负责销毁一目了然)、可无条件转成 shared_ptr(反之不行)。shared_ptr 只在「多个持有者、生命周期由使用方共同决定」时才上。加分项:说明大量 shared_ptr 是设计不清晰的症状——先用 unique_ptr,发现确实需要共享再升级。

Q3:shared_ptr 循环引用是怎么回事? 机制:控制块里强计数归零才析构对象,互相持有则计数永不归零。解法:把「回指/观察」一方改成 weak_ptr,使用时 lock() 升级。加分项:提一句弱计数——对象可销毁但控制块要等最后一个 weak_ptr 释放才回收,这也是 make_shared 的内存要等 weak_ptr 清零才整体释放的原因。

Q4:shared_ptr 是线程安全的吗? 精确表述:控制块的计数是原子的(多线程各自持有副本拷贝/析构安全);同一个 shared_ptr 实例被多线程读写不安全(需要加锁或 atomic<shared_ptr>,C++20 起);所指向的对象完全不设防,自己管。三个层次拆开答,这道题才能拿满。

七、落地清单

  • 默认 unique_ptr;上 shared_ptr 必须回答「第二个持有者是谁」
  • C 句柄/C 库资源:自定义删除器包成 RAII 壳,绝不裸奔
  • 传参:转移按值传 unique_ptr,共享传 const shared_ptr&,只读传 const T&
  • make_shared 优先于 shared_ptr(new)
  • -fsanitize=address 编进开发/CI 构建,泄漏在合并前抓获
  • 观察不持有:裸指针/引用/weak_ptr,杜绝为「怕悬垂」而滥用 shared_ptr
  • code review 搜 new :每处裸 new 都要给出存在理由

到这里,网关的内存账终于干净了。下一章我们处理另一个「看不见的税」:每秒 10 万帧的缓冲区被深拷贝拖到 CPU 90%——移动语义与拷贝省略,C++ 的性能密码。