shared_ptr 泄漏排查与三种智能指针的选型边界

格 格达软件 2026-11-03 0 次阅读

一个常驻服务上线三天,RSS 从 200MB 涨到 1.8GB。跑 valgrind 一个泄漏点都没报——对象确实还被引用着,引用计数不是零,只是永远到不了零。最终定位到一行 lambda:[self = shared_from_this()]。

这篇文章从这个 bug 出发,只讲三件事:什么时候 unique_ptr 就够了、什么时候 shared_ptr 是真需要、weak_ptr 到底解决什么又不解决什么。不复述 API 文档,讲判据和坑。

泄漏现场:回调捕获了 shared_from_this

先看有问题的写法。假设有一个网络层,Session 表示一条连接,TimerQueue 是长生命周期的定时器队列(接口是示意的):

// 错误写法
class Session : public std::enable_shared_from_this<Session> {
public:
    void Start(TimerQueue& tq) {
        // lambda 里捕获了一份 shared_ptr,TimerQueue 存着这个回调
        // 结果:TimerQueue 活多久,Session 就活多久
        tq.Add(1000, [self = shared_from_this()] { self->OnTimeout(); });
    }

private:
    void OnTimeout() { /* 处理超时 */ }
};

引用链是:TimerQueue → std::function → lambda 闭包 → shared_ptr<Session> → Session。只要 TimerQueue 不销毁,Session 的引用计数就至少是 1。

Session::Close() 里能把 Session 析构掉吗?不能,Close() 本身就得先拿到一个活着的 Session 才调得动。于是形成「要析构得先停掉定时器、要停掉定时器得先析构」的死结。这就是那种 valgrind 查不出来、只能顺着引用链看的泄漏。

修法:

void Start(TimerQueue& tq) {
    // 只捕获 weak_ptr,回调真正执行时再尝试提升
    std::weak_ptr<Session> weak = weak_from_this();  // C++17;C++11 写 shared_from_this() 再转
    tq.Add(1000, [weak] {
        if (auto self = weak.lock()) {
            self->OnTimeout();   // 对象还活着才操作
        }
        // lock 失败说明 Session 已析构,回调空转即可
    });
}

这一改同时成立了几件事:

  • Session 的生命周期不再被外部回调绑架,最后一个 shared_ptr<Session> 析构时它就真的死了。
  • 回调里会不会访问到已析构对象?不会,lock() 拿不到强引用就不进 if。
  • 不需要记得「在析构路径上注销所有回调」。手动注销(保存 timer id,Close() 里 tq.Cancel(id))也能修,但它依赖每条退出路径都执行到注销,异常、提前 return、析构顺序变化都能把这条路绕过去。weak_ptr 让这件事结构上成立,不依赖人的记性。

代价也得说清楚:每次回调触发都多一次原子操作(lock()),事件频率极高时这是实打实的开销;另外从 lock() 成功到回调执行完,对象是保活的,回调耗时长就会推迟析构。

C++11 没有 weak_from_this(),得写 std::weak_ptr<Session> weak = shared_from_this();。lambda 的初始化捕获([self = ...])是 C++14 才有的,C++11 里要用 std::bind 或者先建个局部 shared_ptr 再按值捕获。

所有权先画清楚,类型只是结果

上面那个 bug 的本质不是「用错了 weak_ptr」,而是类型写在了所有权之前。写下 shared_from_this() 的那一刻,脑子里问的是「我怎么在回调里访问对象」,而不是「这个回调该不该拥有这个对象」。

先回答三个问题,选型基本就出来了:

  1. 这块内存谁负责释放?答不上来,用什么指针都会漏。
  2. 同一时刻能有几个所有者?
  3. 有没有观察者,它活得比所有者长还是短?

树结构:父指针根本不需要智能指针

// 错误:双向 shared_ptr,成环
struct TreeNode {
    std::shared_ptr<TreeNode> parent;                  // 错
    std::vector<std::shared_ptr<TreeNode>> children;
};

删掉根节点的最后一个外部引用之后,根节点引用计数是 1(被某个子节点的 parent 指着),每个子节点引用计数也是 1(被根节点的 children 指着)。整棵树原地不动,一根毛都释放不掉。这不是泄漏一个对象,是泄漏整个子树。

正确写法是所有权单向:

struct TreeNode {
    TreeNode* parent = nullptr;                        // 非拥有,裸指针
    std::vector<std::unique_ptr<TreeNode>> children;   // 拥有

    TreeNode* AddChild(std::unique_ptr<TreeNode> child) {
        child->parent = this;
        children.push_back(std::move(child));
        return children.back().get();
    }
};

为什么裸指针在这里安全:children 用 unique_ptr 之后,结构上保证子节点比父节点先死(父析构 → vector 析构 → 子节点析构)。子节点活着的时候 parent 一定有效。裸指针在这里承担的角色是「非拥有引用」,这本来就是它该干的事。

如果结构上保证不了(节点可以被单独摘出来),就把 parent 换成 std::weak_ptr<TreeNode>,代价是每次访问父节点都要 lock() 判空。

一句话版本:shared_ptr 描述的是「共享所有权」,不是「共享访问权」。 只访问不拥有的话,裸指针、引用、weak_ptr 都比 shared_ptr 合适。

函数签名:用类型说明要不要交出所有权

void Render(const Widget& w);                   // 只用一下,不涉及生命周期
void Render(const Widget* w);                   // 可能为空,同上

void TakeOwnership(std::unique_ptr<Widget> w);  // 我接管,调用方必须 std::move 进来
void ShareOwnership(std::shared_ptr<Widget> w); // 我要存一份(这次拷贝是原子操作)
void LookAt(const std::shared_ptr<Widget>& w);  // 我只看看,不存

std::unique_ptr<Widget> Create();               // 我造好了,所有权给你

TakeOwnership(std::unique_ptr<Widget>) 这种签名有个隐性好处:调用方看到它就知道东西交出去就没了,不会传完之后继续用。而 void f(Widget*) 看签名分不清是借用还是接管,这种歧义最后一定会变成 UAF(use-after-free)。

三个 unique_ptr 的坑

坑 1:pImpl 里用默认析构,直接编译失败。

// widget.h
class Widget {
public:
    Widget();
    ~Widget();          // 必须自己声明,且必须在 .cpp 里定义
private:
    struct Impl;
    std::unique_ptr<Impl> impl_;   // 此处 Impl 是不完整类型
};
// widget.cpp
struct Widget::Impl { std::vector<int> data; };

Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;   // Impl 完整了,这里才能生成 delete 代码

如果在头文件里写 ~Widget() = default;,编译器会在每个包含 widget.h 的翻译单元里生成析构函数体,那时 Impl 还是不完全类型,delete Impl 无从生成,编译直接失败,报错大概是 invalid application of 'sizeof' to incomplete type 'Widget::Impl' 或 can't delete pointer to incomplete type。第一次见很懵,其实这是标准有意为之的保护。

坑 2:有状态 deleter 会让 unique_ptr 变大。

#include <cstdio>
#include <memory>

struct Big { char data[64]; };
struct EmptyDeleter { void operator()(Big* p) const { delete p; } };
struct LoggedDeleter {
    std::FILE* log;
    void operator()(Big* p) const { std::fprintf(log, "free %p\n", (void*)p); delete p; }
};

static_assert(sizeof(std::unique_ptr<Big>) == sizeof(void*));                  // 默认 deleter:8 字节
static_assert(sizeof(std::unique_ptr<Big, EmptyDeleter>) == sizeof(void*));    // 空类 deleter:EBO,还是 8
// sizeof(std::unique_ptr<Big, LoggedDeleter>) == 16:多存了一个 FILE*

64 位下 unique_ptr<T> 就是 8 字节,和裸指针一样大,移动就是一次指针赋值,没有任何计数操作。这是它应该成为默认选择的硬理由。

坑 3:数组版本和 make_unique 没配对。

std::unique_ptr<Big[]> a(new Big[10]);   // 可以,析构走 delete[]
std::unique_ptr<Big> b(new Big[10]);     // 编译过,析构走 delete 而非 delete[],UB
auto c = std::make_unique<Big[]>(10);    // C++14 起,推荐

第二种在某些编译器上能编过,然后运行期静默出问题。用 make_unique 就不会写错。(顺带说一句,数组场景大多数时候 std::vector 更合适,unique_ptr<T[]> 主要用于接 C 风格的分配接口。)

shared_ptr 的钱花在哪

16 字节和两次原子操作

#include <cstdio>
#include <memory>
struct Big { char data[64]; };

int main() {
    std::printf("unique_ptr<Big> = %zu\n", sizeof(std::unique_ptr<Big>));   // 8
    std::printf("shared_ptr<Big> = %zu\n", sizeof(std::shared_ptr<Big>));   // 16
    std::printf("weak_ptr<Big>   = %zu\n", sizeof(std::weak_ptr<Big>));     // 16
}

shared_ptr 是两个指针:一个指向对象,一个指向控制块。控制块里装着强引用计数、弱引用计数、deleter、allocator,通常 24~32 字节,还是一次额外的堆分配(用 make_shared 时除外,见下)。

真正贵的是计数:拷贝构造一次 ++,析构一次 --,都是原子 RMW 指令。单线程下一条 lock xadd 在 x86 上十几到二十个周期,两次就是十几纳秒。多线程下如果多个核频繁拷贝同一个 shared_ptr,控制块那个 cacheline 在核间来回弹,吞吐掉一个数量级很正常。

跑个最小基准:

// g++ -O2 -std=c++17 bench.cpp -o bench
#include <chrono>
#include <cstdio>
#include <memory>

struct Payload { long a, b, c, d; };

template <typename F>
void bench(const char* name, int n, F&& f) {
    auto t0 = std::chrono::steady_clock::now();
    long sink = 0;
    for (int i = 0; i < n; ++i) sink += f();
    auto dt = std::chrono::duration<double, std::nano>(
                  std::chrono::steady_clock::now() - t0).count();
    std::printf("%-30s %7.2f ns/op (sink=%ld)\n", name, dt / n, sink);
}

int main() {
    constexpr int N = 20'000'000;
    auto sp = std::make_shared<Payload>(Payload{1, 2, 3, 4});

    bench("shared_ptr by value", N, [&] {
        std::shared_ptr<Payload> copy = sp;   // ++ 然后 --
        return copy->a;
    });

    bench("shared_ptr by const&", N, [&] {
        const std::shared_ptr<Payload>& ref = sp;
        return ref->a;
    });
}

在我这台机器上(Ryzen 5800X,g++ 12.2,-O2),按值那行落在 10ns 上下——这就是两次原子 RMW 的钱。const& 那行小得离谱,因为编译器把整个循环折叠掉了;这行数字别当真,它恰恰说明「不涉及所有权的传递本身没有成本」。你的机器数字会不一样,自己跑一遍。

结论落地成两条规矩:

  • 函数只读不存,参数写 const std::shared_ptr<T>&;
  • 真要存一份,就用 std::shared_ptr<T> 按值传,并在调用处 std::move 进去,省掉多出来的那次原子增:
void Register(std::shared_ptr<Handler> h);   // 调用方 Register(h):多一次拷贝(++)
Register(std::move(h));                      // 更好:所有权直接移进去,不增计数

// 或者签名直接声明「我要拿走」,和按值版本二选一
void Register(std::shared_ptr<Handler>&& h);

make_shared 的取舍:省一次分配,但可能拖住内存

std::make_shared<T>(args) 把对象和控制块分配在同一块内存里,一次 malloc 搞定;std::shared_ptr<T>(new T) 是两次。前者还顺带解决了「new 成功但控制块分配抛异常」这种极端情况下的泄漏。

代价是:对象内存和控制块绑在一起,只要控制块还在(哪怕强引用计数已归零、对象已经析构),这块内存就还不给 allocator。而控制块要活到最后一个 weak_ptr 也死掉。

#include <array>

struct BigBuffer {
    std::array<char, 8 * 1024 * 1024> data;   // 8MB 直接内嵌在对象里
};

auto sp = std::make_shared<BigBuffer>();
std::weak_ptr<BigBuffer> w = sp;   // 假设这个 weak_ptr 被塞进了长寿命的观察者列表

sp.reset();     // 强引用归零:BigBuffer 析构,但 8MB 那块内存没还给 allocator,因为 w 还活着

对比:

std::shared_ptr<BigBuffer> sp(new BigBuffer);   // 两次分配
std::weak_ptr<BigBuffer> w = sp;
sp.reset();     // 对象内存立刻 free,只剩几十字节的控制块继续活着给 w 用

怎么选:

  • 对象小、创建频繁(每次请求造个 shared_ptr<Request>)→ 用 make_shared,省一次分配,缓存局部性也更好。
  • 对象大(几百 KB 以上的 buffer、大结构体),且外面有长寿命的 weak_ptr 观察者 → 用 shared_ptr<T>(new T),或者干脆改回 unique_ptr + 裸指针观察。

一个裸指针造两个 shared_ptr,直接 double free

// 错误
Foo* raw = new Foo();
std::shared_ptr<Foo> a(raw);
std::shared_ptr<Foo> b(raw);   // 两个互不相干的控制块,各自记账为 1
// 作用域结束:a 析构 delete raw,b 析构再 delete 一次 → double free / 堆损坏

这段不报编译错,只在运行期崩,而且崩的位置往往离 bug 很远——堆已经被踩烂了才发作。

正确姿势只有两种:

auto a = std::make_shared<Foo>();
auto b = a;                       // 拷贝,共享同一个控制块

// 或者对象自己知道怎么造 shared_ptr
class Foo : public std::enable_shared_from_this<Foo> { /* ... */ };
auto c = std::make_shared<Foo>();
auto d = c->shared_from_this();   // 拿的还是同一个控制块

enable_shared_from_this 自己也有两个坑。第一个:构造函数里不能调 shared_from_this()。

class Session : public std::enable_shared_from_this<Session> {
public:
    Session() {
        // auto self = shared_from_this();   // 运行期抛 std::bad_weak_ptr
        // 原因:weak_this 是在 make_shared/shared_ptr 构造完成后才设好的
    }
    void Init() { auto self = shared_from_this(); }   // 构造完再调,没问题
};

第二个:对象必须由 shared_ptr 管理。Session s; s.Init(); 在栈上创建,shared_from_this() 一样抛 bad_weak_ptr。这个错在单测里特别常见——为了省事在栈上造一个对象测,结果一进 Init() 就炸。

weak_ptr:管生命周期,不管并发

weak_ptr 只做一件事:让你观察一个对象,但不影响它的生命周期。它不能解引用,必须 lock() 换成一个临时 shared_ptr 才能用。

lock() 本身是一次原子操作:检查强引用计数是否为 0,非 0 就 ++。所以「先判空再用」的两个常见写法,其中一个是错的:

// 错误:TOCTOU
if (!w.expired()) {
    w.lock()->DoWork();   // 两次独立操作,中间对象可能被别的线程销毁
                          // lock() 返回空 shared_ptr,-> 就是空指针解引用
}

// 正确
if (auto sp = w.lock()) {
    sp->DoWork();         // sp 是强引用,作用域内对象一定活着
}

单线程下第一种写法其实出不了事,所以它经常能混过测试,然后在线程池真正跑起来那天变成千分之一概率的崩溃。

第二个要注意的:weak_ptr 不延长对象的命,但会延长控制块的命。前面讲 make_shared 时说过后果了。如果代码里有「注册了就不管」的观察者列表,那些 weak_ptr 会一直堆着,每个都让一个控制块(几十字节,用 make_shared 时是整块对象内存)无法释放。

一个典型的长寿命缓存写法,以及它的边界:

class WidgetCache {
public:
    std::shared_ptr<Widget> Get(int id) {
        if (auto it = map_.find(id); it != map_.end()) {
            if (auto sp = it->second.lock()) {
                return sp;            // 命中,对象还活着
            }
            map_.erase(it);           // 对象已死,顺手清掉脏条目
        }
        auto sp = Load(id);           // 重新加载
        map_.emplace(id, sp);
        return sp;
    }

private:
    std::shared_ptr<Widget> Load(int id);
    std::unordered_map<int, std::weak_ptr<Widget>> map_;   // 缓存不持有所有权
};

好处是缓存自己不需要淘汰策略,对象没人用了自然从缓存里「消失」。边界是——脏条目只在再次访问同一个 key 时才被清理。如果 id 是用户 ID 这种高基数、基本只访问一次的东西,map_ 会无限膨胀,最后内存不是被 Widget 吃掉的,是被 map 本身吃掉的。要么加定期遍历清理,要么换成有容量上限的 LRU。

选型表

把上面的结论压成一张表,回头直接对:

场景 选什么 理由 / 代价
工厂返回值、独占资源的成员 std::unique_ptr<T> 8 字节,零计数开销,默认就该选它
需要共享生命周期的对象 std::shared_ptr<T> 先问一句「真的需要共享吗」,答案往往是「不需要」
回调、观察者、缓存 std::weak_ptr<T> 不延长生命周期;代价是每次用都要 lock()
树 / 图的父指针 T*(结构保证)或 weak_ptr<T> 父一定比子活得久就用裸指针,否则 weak
函数参数,不涉及所有权 T& / T* / const shared_ptr<T>& 别按值传 shared_ptr
需要共享访问但不管生命周期 T& / T* + 文档约定 比 weak_ptr 更轻,但靠约定不靠编译器
热路径上的高频对象 对象池 + 裸指针,或 unique_ptr 批量管理 原子操作和额外分配都得避掉

有个很有效的自查方法:如果某个 shared_ptr 在任何时刻都只有一个所有者在持有,那它就是 unique_ptr 用错了。 搜出代码里所有 shared_ptr 成员和参数,逐个问「这里第二个所有者是谁」,答不上来的全换成 unique_ptr。

几个不在教科书写法里的边界

1. 引用计数线程安全 ≠ 对象线程安全。

这是最容易被误读的一条。shared_ptr 的拷贝、赋值、析构在多线程下安全(计数是原子的),但:

  • 多个线程同时读写同一个 shared_ptr 变量(不是各自的副本)不安全,那是数据竞争,坏掉的是那 16 个字节本身。
std::shared_ptr<Config> g_config;   // 错:一个线程 reset,另一个线程读,UB

// C++20
std::atomic<std::shared_ptr<Config>> g_config;
g_config.store(std::make_shared<Config>());
auto cfg = g_config.load();

// C++11/17 用自由函数(C++20 起弃用,但大量存量代码还在用)
std::atomic_store(&g_config, std::make_shared<Config>());
auto cfg = std::atomic_load(&g_config);
  • 被指向的对象本身完全没有保护,两个线程各拿一个 shared_ptr 副本去改同一个对象,照样是数据竞争。

2. 别名构造函数:共享控制块,但指向子对象。

struct Impl {
    Config cfg;
    std::vector<int> buffer;
};

auto impl = std::make_shared<Impl>();
// 指向 impl->cfg,但共享 impl 的引用计数:cfg 活着,整个 Impl 就活着
std::shared_ptr<Config> cfg(impl, &impl->cfg);

用途是把内部成员安全地暴露出去(比如返回 shared_ptr<const Config>),调用方拿到的 shared_ptr 会正确延长整个 Impl 的命。不注意这点很容易退化成 shared_ptr<Config>(&impl->cfg, [](Config*){}) 这种空 deleter 的假共享,一旦 impl 先死就是悬空指针。

3. 什么时候根本不该用智能指针。

  • 全局 / 静态对象、单例:生命周期本来就是整个进程,用 shared_ptr 持有只是给退出时的静态析构顺序添乱。
  • 结构上已确定生命周期的成员(上面的 parent):裸指针更准确也更快。
  • 高频创建销毁的小对象(每秒百万级):shared_ptr 的原子操作和额外分配会成为瓶颈,这时候该考虑对象池或 arena。

4. 需要确定性析构的场景。

shared_ptr 的析构时机取决于最后一个所有者什么时候放手,多线程下这不是你能控制的。如果对象持有文件句柄、socket、GPU 显存这类必须及时归还的资源,shared_ptr 会让它们在你想不到的时刻才释放。这种场景宁可用 unique_ptr 加一个明确的 Close() 调用点,也别赌引用计数什么时候归零。

回到那行 lambda

weak_ptr 除了破循环引用,还有个常见形态是做「弱引用索引」:资源管理器用 unordered_map<Path, weak_ptr<Texture>> 记录已加载的资源,谁要用谁去 lock(),没人用就自动可以卸载。形状和上面的 WidgetCache 一样。

回到开头那个 bug。修完之后我把这条写进了 review 清单:看到 shared_from_this(),先问一句「这个 lambda 被存到哪儿去了,它该不该决定我的生死」。 十条里有八条,答案是不该。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。