排查一个长跑服务的内存曲线,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,编译不过的地方就是确实需要共享所有权的位置——编译器会替你把这轮排查做完。