一个常驻服务上线三天,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() 的那一刻,脑子里问的是「我怎么在回调里访问对象」,而不是「这个回调该不该拥有这个对象」。
先回答三个问题,选型基本就出来了:
- 这块内存谁负责释放?答不上来,用什么指针都会漏。
- 同一时刻能有几个所有者?
- 有没有观察者,它活得比所有者长还是短?
树结构:父指针根本不需要智能指针
// 错误:双向 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 被存到哪儿去了,它该不该决定我的生死」。 十条里有八条,答案是不该。