shared_ptr 循环引用:一次内存泄漏暴露的选型问题

格 格达软件 2026-10-19 0 次阅读 AI 生成

排查一个长跑服务的内存曲线,RSS 只涨不降,重启才回到基线。代码里搜不到一个 new,所有对象都由 make_shared 创建。挂上 ASAN 跑几分钟,LeakSanitizer 报出几万个没释放的 Session——根因是一行看起来完全正常的 lambda 捕获。

这篇文章从这个 bug 讲起,把三种智能指针的选型讲清楚:unique_ptr 什么时候是唯一正确答案,shared_ptr 的代价具体在哪,weak_ptr 什么时候是解药、什么时候只是心理安慰。

一、循环引用:没有 new,照样泄漏

先上最小复现。这段可以直接编译:

// leak.cc
// g++ -std=c++17 -g -fsanitize=address leak.cc -o leak && ./leak
#include <cstdio>
#include <functional>
#include <memory>

class Session {
public:
    explicit Session(int id) : id_(id) { std::printf("  ctor %d\n", id_); }
    ~Session() { std::printf("  dtor %d\n", id_); }

    void setHandler(std::function<void()> h) { handler_ = std::move(h); }
    void doWork() { std::printf("  working %d\n", id_); }

private:
    int id_;
    std::function<void()> handler_;   // ← 强引用藏在这里
};

int main() {
    auto s = std::make_shared<Session>(1);
    s->setHandler([s] { s->doWork(); });   // 捕获 shared_ptr 副本
    std::printf("use_count = %ld\n", s.use_count());   // 2
}

输出:

  ctor 1
use_count = 2

没有 dtor 1。s(栈上)和 handler_(堆上)各持有一个强引用,引用计数停在 1,~Session 永远不执行。s 离开作用域之后,这条对象变成不可达却又没被回收的内存——LeakSanitizer 报的就是它。

真实项目里这个捕获通常藏得比较深,几种常见变体:

// 变体 1:[=] 隐式捕获,从代码上完全看不出来
s->setHandler([=] { s->doWork(); });

// 变体 2:捕获了一个包含 shared_ptr 成员的结构体
struct Ctx { std::shared_ptr<Session> session; };
Ctx ctx{s};
s->setHandler([ctx] { ctx.session->doWork(); });

// 变体 3:注册发生在另一个模块,层层传递后没人记得谁持有谁
bus.subscribe("data", makeCallback(s));   // makeCallback 内部捕获了 s

修法是把强引用换成弱引用,在回调执行时临时提升:

std::weak_ptr<Session> wp = s;
s->setHandler([wp] {
    if (auto sp = wp.lock()) {   // 提升成功才说明对象还活着
        sp->doWork();
    }
    // 提升失败 = 对象已销毁,这次回调直接丢弃
});

lock() 返回的 shared_ptr 在整个 if 块内保证对象存活,这是它比 expired() 检查正确的地方(第三节会展开)。

检测手段有边界:LeakSanitizer 只报「完全不可达」的内存。如果代码里有个全局 registry 还拿着 shared_ptr,这条对象就是 reachable,LSan 一声不吭,你只能看着 RSS 涨。这时候用 heaptrack 或者 valgrind --tool=massif 看分配栈的累计增长,或者干脆在关键类的析构函数里打日志——日志条数对不上构造次数,问题就在那儿。

二、默认选择应该是 unique_ptr,它的代价接近零

很多人写 C++ 的第一反应是「要智能指针就 shared_ptr」,因为 unique_ptr 不能拷贝,感觉受限。这个方向正好反了。

判断标准只有一条:这块所有权会不会同时被两方持有? 不会,就用 unique_ptr。

典型场景:容器的元素、类的成员、工厂的返回值、pImpl 的实现体。

// 工厂:所有权清晰地交给调用方,不需要引用计数
std::unique_ptr<Session> createSession(int id) {
    return std::make_unique<Session>(id);
}

// 容器持有:容器析构,里面的对象全部释放,不需要任何手动管理
std::unordered_map<int, std::unique_ptr<Session>> sessions;
sessions.emplace(1, createSession(1));

unique_ptr 的成本就是一次 new/delete,sizeof 等于一个裸指针,移动是一次指针拷贝。没有控制块,没有原子操作。shared_ptr 则是一次控制块分配(make_shared 能合并成一次)、引用计数是原子的、每次拷贝和析构都有原子读改写。

差距有多大,自己跑:

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

struct Node { char buf[64]; };

template <typename F>
double nsPerOp(int n, F f) {
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < n; ++i) f();
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration<double, std::nano>(t1 - t0).count() / n;
}

int main() {
    constexpr int N = 5'000'000;

    std::printf("make_unique : %6.2f ns/op\n", nsPerOp(N, [] {
        auto p = std::make_unique<Node>();
        asm volatile("" ::"r"(p.get()));      // 防止被优化掉
    }));
    std::printf("make_shared : %6.2f ns/op\n", nsPerOp(N, [] {
        auto p = std::make_shared<Node>();
        asm volatile("" ::"r"(p.get()));
    }));

    auto shared = std::make_shared<Node>();
    std::printf("copy shared : %6.2f ns/op\n", nsPerOp(N, [&] {
        auto p = shared;                       // 一次原子递增 + 一次原子递减
        asm volatile("" ::"r"(p.get()));
    }));
}

在 GCC 13 / -O2 / x86-64 上,前两项的差距只有几纳秒,主要来自控制块的初始化;真正拉开量级的是第三项——shared_ptr 的拷贝要走原子引用计数,unique_ptr 的移动是一次指针赋值。单次几纳秒听起来不多,但在一秒几十万次的回调分发路径上,性质就不一样了。

还有一个更隐蔽的性能问题:同一个对象的所有 shared_ptr 共享控制块里的那一个引用计数。多线程频繁拷贝、销毁指向同一对象的 shared_ptr 时(哪怕它们只是读对象、完全不写),这条 cache line 会在核间来回弹,核越多越慢。unique_ptr 根本没这个问题,因为它压根不能跨线程共享。

自定义删除器:无捕获 lambda 不占空间

unique_ptr 的第二个参数是删除器,这是它比 shared_ptr 灵活的地方——删除器类型是编译期确定的,无状态删除器不占额外空间:

#include <cstdio>

// 无捕获 lambda:删除器是空类,靠空基类优化,unique_ptr 大小仍是一个指针
auto closeFile = [](std::FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<std::FILE, decltype(closeFile)> fp(std::fopen("/tmp/a.txt", "r"), closeFile);

// 换成函数指针:unique_ptr 变成两个指针大小,且调用无法内联
std::unique_ptr<std::FILE, void (*)(std::FILE*)> fp2(
    std::fopen("/tmp/a.txt", "r"),
    [](std::FILE* f) { if (f) std::fclose(f); });

shared_ptr 的删除器是运行时类型擦除的,永远占控制块里的一块空间,调用还多一层间接。所以「需要定制释放逻辑」这件事本身也偏向 unique_ptr。

坑:pImpl 里 ~Widget() = default 编译不过

这是 unique_ptr 用得最多、也最容易踩的地方。头文件里前向声明 Impl,用 unique_ptr<Impl> 持有:

// widget.h
#pragma once
#include <memory>

class Widget {
public:
    Widget();
    ~Widget();                                  // 必须声明,不能 = default
    Widget(Widget&&) noexcept;                  // 移动操作同理
    Widget& operator=(Widget&&) noexcept;
    void run();

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};
// widget.cc
#include "widget.h"
#include <vector>

struct Widget::Impl {
    std::vector<int> data;
    void work() {}
};

Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;                    // 这里 Impl 是完整类型,才能生成 delete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::run() { impl_->work(); }

如果在头文件里图省事写 ~Widget() = default;,任何包含 widget.h 并且会销毁 Widget 的翻译单元都会编译失败,报错是 libstdc++ 那句 static assertion failed: can't delete an incomplete type。原因不复杂:unique_ptr 的析构要调用 delete,而删除一个非平凡类型需要完整定义(要调它的析构函数),此时 Impl 还只是个前向声明。

代价是每个 pImpl 类都要多写四行样板代码。这是 unique_ptr 的固有约束,不是编译器脾气差。shared_ptr 没这个问题,因为它在构造时就把删除器类型擦除进控制块了,那时候类型还是完整的——但为了省这四行去用 shared_ptr 持有 pImpl,属于用大炮打蚊子。

三、shared_ptr 的代价:不只是原子操作

3.1 make_shared 会让大对象的内存跟着 weak_ptr 走

这是 make_shared 一个反直觉的副作用。make_shared 把对象和控制块放在同一块分配里(这本来是它的优点:一次 malloc、缓存局部性好),代价是只要还有 weak_ptr 活着,整块内存就释放不掉,哪怕对象本身早就析构完了。

struct Big {
    char data[1 << 20];   // 1 MB
};

std::weak_ptr<Big> wp;
{
    auto sp = std::make_shared<Big>();   // 对象 + 控制块,一次分配
    wp = sp;
}   // sp 析构,~Big 执行,但内存还给 allocator 了吗?没有

std::printf("expired = %d\n", (int)wp.expired());   // 1,对象确实没了
// 但那 1 MB 还在,因为控制块的 weak count 还是 1
// 只有 wp 也析构,整块内存才真正 free

对比两次分配的写法:

{
    std::shared_ptr<Big> sp(new Big);    // 对象一块,控制块另一块
    wp = sp;
}
// 这里 1 MB 已经释放,只剩一个几十字节的控制块挂在 wp 上

结论很具体:对象很大(几十 KB 以上)并且会被 weak_ptr 长期观察时,别用 make_shared。重灾区是资源缓存——一个 4 MB 的纹理被缓存用 weak_ptr 索引,用户切走之后纹理对象析构了,但只要缓存条目还在,这 4 MB 就释放不掉。用 std::shared_ptr<T>(new T) 或者带自定义分配器的 allocate_shared 都行。

小对象、没有 weak_ptr 观察的场景,make_shared 仍然更好:一次分配、异常安全(不存在「对象构造成功但控制块分配失败」的泄漏窗口)。

3.2 引用计数原子,不等于 shared_ptr 线程安全

这条几乎每个项目都有人踩。标准保证的是:多个 shared_ptr 实例指向同一对象时,引用计数的增减是原子的。它没保证同一个 shared_ptr 变量可以被多线程并发读写。

std::shared_ptr<Config> g_cfg = std::make_shared<Config>();

void reload() { g_cfg = std::make_shared<Config>(); }   // 线程 A:写指针本身
Config read() { return *g_cfg; }                        // 线程 B:读指针本身

g_cfg = ... 要同时改「对象指针」和「控制块指针」两个字段,读线程可能读到一半新一半旧的状态,这是数据竞争,UB。踩中的表现千奇百怪:偶发 crash、读到已析构的对象、引用计数错乱导致 double free。

C++20 之后有标准解法:

#include <atomic>
#include <memory>

std::atomic<std::shared_ptr<Config>> g_cfg;

void reload() { g_cfg.store(std::make_shared<Config>()); }
std::shared_ptr<Config> read() { return g_cfg.load(); }

注意 std::atomic<std::shared_ptr<T>> 在 libstdc++ 和 libc++ 里都不是 lock-free 的,内部用一把自旋锁。写少读多的配置场景没问题,如果它落在高频路径上,先测再说。C++17 及以前用 std::atomic_load(&g_cfg) / std::atomic_store(&g_cfg, ...) 这对自由函数(C++20 起被废弃)。

顺带一提按值传参的代价:

void handle(std::shared_ptr<Session> s);          // 调用方多一次原子递增,函数内多一次递减
void handle(const std::shared_ptr<Session>& s);   // 不增加计数,但函数内若要保存仍需拷贝
void handle(Session& s);                          // 零成本,前提是调用期间对象一定活着

如果函数只是用一下、不保存所有权,传引用,别传 shared_ptr。

3.3 从 this 构造 shared_ptr 会造出第二个控制块

class Foo {
public:
    std::shared_ptr<Foo> self() {
        return std::shared_ptr<Foo>(this);   // 灾难:new 一个控制块,计数从 1 开始
    }
};

auto a = std::make_shared<Foo>();   // 控制块 A,计数 1
auto b = a->self();                 // 控制块 B,计数 1
// a 和 b 析构时各 delete 一次 this → double free

正确写法是继承 std::enable_shared_from_this<Foo>:

class Foo : public std::enable_shared_from_this<Foo> {
public:
    std::shared_ptr<Foo> self() {
        return shared_from_this();    // 复用已有的控制块
    }
};

enable_shared_from_this 有两个使用前提,违反会拿到 std::bad_weak_ptr(C++17 起标准明确为抛异常):

class Conn : public std::enable_shared_from_this<Conn> {
public:
    Conn() {
        // auto self = shared_from_this();  // 错:构造期间 weak_this 还没被设置
    }
    void start() {
        auto self = shared_from_this();     // 对:前提是对象由 shared_ptr 管理
        // asyncRead(self);
    }
};

Conn c;          // 栈对象,没有 shared_ptr 拥有它
c.start();       // 抛 std::bad_weak_ptr

weak_this 是在 shared_ptr 构造时被赋值的,所以构造函数体里一定还没设好;make_shared 也一样,构造函数体内同样失败。

四、weak_ptr 的三种用法和两个边界

weak_ptr 的定位很清晰:不拥有所有权,但需要知道对象是否还活着。三个真实用途:

1. 观察者回调(第一节的例子)。被观察者销毁后回调自动失效。

2. 打破双向引用的循环。父子、主从结构里,一方持强引用,另一方持弱引用:

class Child;

class Parent {
public:
    void addChild(const std::shared_ptr<Child>& c) { children_.push_back(c); }
private:
    std::vector<std::shared_ptr<Child>> children_;   // 父持有子:强引用
};

class Child {
public:
    explicit Child(const std::shared_ptr<Parent>& p) : parent_(p) {}
private:
    std::weak_ptr<Parent> parent_;                   // 子回指父:弱引用,环被打断
};

3. 缓存索引。缓存不该延长对象的生命周期,对象没人用了条目就自然作废:

class Texture;

class TextureCache {
public:
    std::shared_ptr<Texture> get(const std::string& key) {
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            if (auto sp = it->second.lock()) {
                return sp;                     // 命中
            }
            cache_.erase(it);                  // 对象已经没了,顺手清理死条目
        }
        auto sp = std::make_shared<Texture>(key);
        cache_[key] = sp;                      // 只存 weak_ptr
        return sp;
    }
    std::size_t size() const { return cache_.size(); }

private:
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
};

这个缓存有个边界:死条目只在同 key 再次被访问时才清理。如果 key 空间很大(比如按 URL 缓存),死条目会一直堆积,size() 只增不减。真要用的话,加一个定期全量清理:

void sweep() {
    for (auto it = cache_.begin(); it != cache_.end(); ) {
        it = it->second.expired() ? cache_.erase(it) : std::next(it);
    }
}

边界一:expired() 和 lock() 之间不是原子的

// 错误:检查和使用之间,另一个线程可能已经销毁了对象
if (!wp.expired()) {
    wp.lock()->doWork();     // lock() 可能返回空 shared_ptr,解引用是 UB
}

// 正确:一次 lock(),用返回值
if (auto sp = wp.lock()) {
    sp->doWork();            // 整个 if 块内对象保证存活
}

expired() 是 const 的、可以并发调用,但它返回的只是「那一瞬间」的状态。lock() 返回非空 shared_ptr 之后对象才被真正钉住,这是唯一正确的写法。

边界二:weak_ptr 不解决生命周期设计

weak_ptr 解决的是「对象可能已经没了」,它不解决「对象什么时候该没」。如果一个回调在 lock() 成功之后还要做长时间的工作(比如一次网络往返),中途对象被销毁,lock() 拿到的 shared_ptr 会一直把它撑到最后——这本身没错,但意味着对象的生命周期被回调延长了,析构时机变得不可预测。需要精确控制销毁时机的场景(比如必须在主线程释放 GPU 资源),weak_ptr 帮不上忙,还是得靠显式的 close/shutdown 协议。

另外,weak_ptr 和 shared_ptr 一样:多个线程操作同一个 weak_ptr 实例是非线程安全的,只是 lock() 这个 const 操作本身可以并发调用。

五、选型判断表

场景 选型 关键理由 / 代价
类的成员、容器元素、工厂返回值 unique_ptr 零额外开销,移动即指针拷贝;代价是不可拷贝,pImpl 需要 out-of-line 析构
需要跨模块、跨线程共享所有权 shared_ptr 有控制块和原子操作;拷贝在热点路径上是真金白银
回调、观察者、缓存索引 weak_ptr 不延长生命周期;每次使用必须 lock() 并检查返回值
双向引用(父子、主从) 一边 shared_ptr,一边 weak_ptr 两边都强引用必然泄漏,选语义上「从属」的那一边持弱引用
全局配置对象,跨线程读写 atomic<shared_ptr<T>> 内部是锁,不是 lock-free;只在写少读多时用
大对象 + 会被 weak_ptr 长期观察 shared_ptr<T>(new T) 别用 make_shared,否则对象内存陪控制块一起挂着
需要精确控制释放时机 都不合适 智能指针只管「谁最后放手」,不管「什么时候放手」,用显式 close 协议

几条可以直接背下来的规则:

  • 默认写 unique_ptr。等到确实需要共享所有权再改成 shared_ptr——这个方向是局部改动,反过来是重构。
  • 看到 shared_ptr 出现在函数参数里,先问一句「这里真的共享所有权吗」。八成只是「用一下」,应该传引用。
  • shared_ptr 的循环引用只能靠 weak_ptr 或者改设计来解,没有运行时检测。ASAN 能抓不可达的,抓不到可达的。
  • 能用栈对象和值语义的地方,一个智能指针都别用。std::vector<Session> 比 std::vector<std::unique_ptr<Session>> 快得多,缓存也友好。

六、一个能跑的完整例子

把上面的东西串起来。Session 由 shared_ptr 管理(因为要交给回调共享),回调持 weak_ptr 观察,缓存用 weak_ptr 做索引。

// demo.cc
// g++ -std=c++17 -O2 -Wall demo.cc -o demo && ./demo
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>

// ---------- 被观察的会话对象 ----------
class Session : public std::enable_shared_from_this<Session> {
public:
    explicit Session(int id) : id_(id) { std::printf("  [ctor] Session %d\n", id_); }
    ~Session() { std::printf("  [dtor] Session %d\n", id_); }

    int id() const { return id_; }
    void setHandler(std::function<void(int)> h) { handler_ = std::move(h); }
    void feed(int v) { if (handler_) handler_(v); }

private:
    int id_;
    std::function<void(int)> handler_;
};

// ---------- 带 1MB 数据的资源,用来演示 weak_ptr 缓存 ----------
class Texture {
public:
    explicit Texture(std::string name) : name_(std::move(name)), data_(1 << 20) {
        std::printf("  [alloc] Texture %s (%zu bytes)\n", name_.c_str(), data_.size());
    }
    ~Texture() { std::printf("  [free ] Texture %s\n", name_.c_str()); }
    const std::string& name() const { return name_; }

private:
    std::string name_;
    std::vector<char> data_;
};

// ---------- weak_ptr 索引的缓存:不持有所有权 ----------
class TextureCache {
public:
    std::shared_ptr<Texture> get(const std::string& key) {
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            if (auto sp = it->second.lock()) {
                std::printf("  [hit  ] %s\n", key.c_str());
                return sp;
            }
            std::printf("  [stale] %s,对象已销毁,清理条目\n", key.c_str());
            cache_.erase(it);
        }
        auto sp = std::make_shared<Texture>(key);
        cache_[key] = sp;
        return sp;
    }
    std::size_t size() const { return cache_.size(); }

private:
    std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
};

int main() {
    std::printf("== 1. 回调持 weak_ptr ==\n");
    std::weak_ptr<Session> observer;
    {
        auto s = std::make_shared<Session>(7);
        std::weak_ptr<Session> wp = s;
        s->setHandler([wp](int v) {
            if (auto sp = wp.lock()) {
                std::printf("  session %d 处理 %d (use_count=%ld)\n",
                            sp->id(), v, sp.use_count());
            } else {
                std::printf("  会话已销毁,丢弃数据 %d\n", v);
            }
        });
        observer = wp;
        s->feed(100);        // 正常处理
    }                        // s 析构 → ~Session 执行
    std::printf("  observer.expired() = %d\n", (int)observer.expired());

    std::printf("== 2. weak_ptr 做缓存索引 ==\n");
    TextureCache cache;
    {
        auto t1 = cache.get("hero");
        auto t2 = cache.get("hero");                       // 命中
        std::printf("  同一对象? %d\n", (int)(t1.get() == t2.get()));
    }   // t1、t2 析构 → Texture 释放
    std::printf("  缓存条目数 = %zu(死条目还没清理)\n", cache.size());
    auto t3 = cache.get("hero");                           // 触发清理 + 重新分配
    std::printf("  重新拿到 %s\n", t3->name().c_str());
    return 0;
}

输出:

== 1. 回调持 weak_ptr ==
  [ctor] Session 7
  session 7 处理 100 (use_count=2)
  [dtor] Session 7
  observer.expired() = 1
== 2. weak_ptr 做缓存索引 ==
  [alloc] Texture hero (1048576 bytes)
  [hit  ] hero
  同一对象? 1
  [free ] Texture hero
  缓存条目数 = 1(死条目还没清理)
  [stale] hero,对象已销毁,清理条目
  [alloc] Texture hero (1048576 bytes)
  重新拿到 hero
  [free ] Texture hero

把第一段里的 [wp] 改成 [s] 再跑一次,你会看到 [dtor] Session 7 消失、use_count 变成 3、程序结束时不打印任何析构——这就是开头那个线上问题的完整模型。

如果手头的代码里 shared_ptr 已经泛滥,一个成本很低的起点是:把所有 make_shared 和 std::shared_ptr< 的调用点列出来,逐个问一句「这里真的有两方同时持有吗」。回答是「不确定」的,先改成 unique_ptr,编译不过的地方就是确实需要共享所有权的位置——编译器会替你把这轮排查做完。

格
格达软件

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

咨询我们

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

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