文章

C++ 智能指针实战:用 weak_ptr 破解 shared_ptr 循环引用导致的内存泄漏

C++ 智能指针实战:用 weak_ptr 破解 shared_ptr 循环引用导致的内存泄漏

在开发一个基于 Reactor 模式的 C++ 网络库时,我遇到了一个经典的内存管理难题:对象无法被析构,导致文件描述符(fd)泄露和内存持续增长。经过一番调试,最终发现罪魁祸首是 std::shared_ptr循环引用,而解决方案正是 C++11 引入的另一个智能指针——std::weak_ptr

今天,我想和大家分享这次踩坑与填坑的完整过程,希望能帮助正在学习或使用 C++ 智能指针的朋友们。

项目背景

我的网络库核心组件包括:

  • EventLoop: 事件循环。
  • Channel: 封装一个文件描述符(fd)及其关注的事件(读/写)。
  • Connection: 管理一个 TCP 连接,持有 SocketChannel 对象,并包含一个读缓冲区 Buffer

为了简化对象生命周期管理,我决定使用 std::shared_ptr 来管理 Connection 对象。同时,在 Channel 中,我需要设置一个回调函数(callback),当 fd 可读时,由 EventLoop 调用此回调来处理数据。

初始实现:一个看似完美的方案

Connection 的初始化函数 initReadEventCallBack 中,我这样设置了回调:

1
2
3
4
5
6
7
8
// Connection.cpp (有问题的版本)
void Connection::initReadEventCallBack() {
    // 错误!这会导致循环引用
    auto self = shared_from_this();
    ch_->setEventCallBack([self]{
        self->echo(); // 回调中使用Connection的方法
    });
}

这里的想法很直接:通过捕获 shared_from_this() 返回的 shared_ptr,确保在回调被调用时,Connection 对象依然存活。

问题浮现:内存和 fd 泄露

然而,程序运行一段时间后,我发现:

  1. 内存占用持续增长
  2. 客户端断开后,服务端的 fd 并未关闭
  3. 使用 lsof 命令可以看到大量处于 ESTABLISHED 状态的“僵尸”连接。

这明显是内存泄漏资源泄漏。通过日志和调试,我确认了 Connection 的析构函数从未被调用。

根本原因:shared_ptr 的循环引用

让我们分析一下对象之间的引用关系:

  1. 外部(例如 AcceptorEventLoop)持有一个 std::shared_ptr<Connection>,我们称其为 conn_ptr
  2. Connection 对象内部持有一个 std::unique_ptr<Channel> (ch_)。
  3. Channel 对象内部持有一个 std::function 类型的回调函数 eventCallback_
  4. 这个回调函数以值捕获的方式持有了一个 std::shared_ptr<Connection> (self)。

这就形成了一个致命的引用环

conn_ptr -> Connection -> Channel -> eventCallback_ -> self -> Connection

由于 Connection 对象被自己的成员变量(通过 Channel)间接地持有了一个 shared_ptr 引用,即使外部的 conn_ptr 被销毁(引用计数减1),Connection 内部的 self 仍然保持着对自身的引用(引用计数至少为1)。结果就是,Connection 对象永远无法被析构,造成了内存和 fd 的永久性泄漏。

解决方案:引入 std::weak_ptr

std::weak_ptr 是 C++ 为了解决 shared_ptr 循环引用问题而设计的。它的核心特点是:

  • 不增加所指向对象的引用计数。
  • 它只是一个观察者,可以检查对象是否还存活。
  • 需要使用时,可以通过 lock() 方法尝试获取一个临时的 shared_ptr。如果对象已销毁,lock() 返回一个空的 shared_ptr

修正后的代码

我们将回调中的 shared_ptr 替换为 weak_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Connection.cpp (修正后的版本)
void Connection::initReadEventCallBack() {
    // 关键修改:使用 weak_ptr
    std::weak_ptr wk_self = shared_from_this();
    ch_->setEventCallBack([wk_self]{
        // 在回调执行时,尝试锁定对象
        auto self = wk_self.lock();
        if (self) {
            // 对象依然存活,安全地调用方法
            self->echo();
        }
        // 如果 self 为空,说明 Connection 已经被销毁,什么也不做。
    });
}

工作原理

  1. 打破循环Channel 的回调现在持有的是一个 weak_ptr<Connection>,它不会增加 Connection 的引用计数。
  2. 安全访问:当事件发生,EventLoop 调用回调时,我们首先通过 wk_self.lock() 尝试获取一个有效的 shared_ptr
  3. 优雅处理:如果 Connection 已经因为客户端断开等原因被外部逻辑销毁,lock() 会返回 nullptr,我们直接忽略这次事件即可。这完全符合预期,因为我们不再关心一个已经关闭的连接。

通过这个简单的改动,引用环被成功打破。当外部最后一个 shared_ptr<Connection> 被销毁时,Connection 对象的引用计数会真正归零,从而触发析构函数,正确地关闭 socket 并释放所有资源。

总结

这次经历让我深刻体会到 std::weak_ptr 在实际项目中的巨大价值。它不仅是教科书上的一个概念,更是解决复杂对象关系中内存管理难题的利器。

最佳实践建议

  • 当你的回调、监听器或任何长期存在的对象需要持有对其“所有者”或“上下文”的引用时,请优先考虑使用 std::weak_ptr
  • 在 Lambda 表达式中捕获 shared_from_this() 时,务必警惕潜在的循环引用风险。
  • weak_ptr::lock() 是一个轻量级的操作,用于在需要时安全地获取对象的临时所有权。

正确地组合使用 shared_ptrweak_ptr,是编写健壮、无内存泄漏的现代 C++ 应用的关键一步。希望我的这次经验能对你有所帮助!

本文由作者按照 CC BY 4.0 进行授权