1. 所有权写进类型
1.1 裸指针说不清谁来 delete
函数返回一个 Widget*,调用方拿到以后要不要 delete?光看类型看不出来:它可能是新 new 出来交给你的,也可能指着别人管着的对象。猜错了就是两种结果之一:没人 delete,内存泄漏;两个人都 delete,同一块内存释放两次。中途抛个异常、提前 return,本该执行的 delete 也会被跳过。
智能指针的做法是把“谁负责释放”写进类型:持有 unique_ptr<Widget> 的就是唯一的主人,持有 shared_ptr<Widget> 的是主人之一,拿着 Widget* 或 Widget& 的只是借用。释放这件事交给析构函数:智能指针离开作用域时,析构函数里调 delete,不管是正常走完、提前返回还是抛了异常,析构函数都会执行。这就是 RAII(资源的生命期绑在对象的生命期上),见 构造、析构和 RAII。
2. unique_ptr:独占,只能移动
2.1 只能移动,不能拷贝
“独占”的意思是同一时刻只有一个 unique_ptr 指着这个对象。要做到这一点,它把拷贝构造和拷贝赋值删掉了(= delete),写 auto q = p; 直接编译不过。所有权可以转交,但要明着写 std::move:移动构造把指针拿过来,再把原来那个置空,于是原来的 unique_ptr 析构时什么也不做,对象只会被释放一次。
std::unique_ptr<Widget> make() {
auto p = std::make_unique<Widget>();
p->v = 42;
return p; // 返回局部变量:自动移动,不用写 std::move
}
void sink(std::unique_ptr<Widget> p) { std::printf("sink got %d\n", p->v); } // 拿走所有权
void use(const Widget& w) { std::printf("use %d\n", w.v); } // 只是用一下
auto w = make();
use(*w);
sink(std::move(w)); // 所有权交给 sink,w 变成空
std::printf("after sink: w is %s\n", w ? "set" : "null");
用 GCC 16.2 跑出来是 use 42、sink got 42、~Widget、after sink: w is null:Widget 在 sink 返回时就析构了,因为那时它的主人是 sink 的参数。
2.2 和裸指针一样大
默认的 unique_ptr<T> 里只有一个指针,析构时调 delete,用它没有额外的内存开销。删除器是它的第二个模板参数,是否占空间要看删除器本身有没有状态:
| 类型 | sizeof(GCC 16.2,64 位) |
|---|---|
unique_ptr<Widget> | 8 |
unique_ptr<Widget[]> | 8 |
unique_ptr<FILE, void(*)(FILE*)>(函数指针做删除器) | 16 |
unique_ptr<FILE, decltype(lambda)>(不捕获的 lambda) | 8 |
unique_ptr<FILE, decltype(lambda)>(捕获了一个 int) | 16 |
shared_ptr<Widget>、weak_ptr<Widget> | 16 |
函数指针是一个运行时的值,得存下来,所以多 8 字节。不捕获的 lambda 是一个空类,它要调用的函数在类型里就定了,标准库实现把它放在空基类(或者 [[no_unique_address]])里,不占空间,原理见对象布局的空类一节。所以写删除器时优先用不捕获的 lambda 或者空的函数对象,unique_ptr 仍然只有 8 字节。
2.3 自定义删除器:不只管 new 出来的内存
unique_ptr 管的其实是“离开时要做的一件事”,默认这件事是 delete。换成别的删除器,就能管 C 库里成对出现的资源:
auto close_file = [](std::FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<std::FILE, decltype(close_file)> f(std::fopen("a.txt", "r"), close_file);
// 离开作用域时 fclose;中途 return、抛异常也一样
同样的写法能管 malloc 出来的内存(删除器调 free)、opendir 的目录、各种 C 库的句柄。fd 这种不是指针的资源,unique_ptr 装不下,要自己写一个小的 RAII 类。
2.4 make_unique 和数组
std::make_unique<T>(args...)(C++14)在内部 new,直接返回 unique_ptr,代码里就不再出现裸的 new:每个 new 都立刻有了主人,不存在“new 完、还没交给智能指针,中间抛了异常”的窗口。
unique_ptr<T[]> 是数组版本,析构时调的是 delete[],还提供 operator[]。它和 unique_ptr<T> 不能混用:用单个对象的版本去管 new T[n],析构时调 delete 而不是 delete[],是未定义行为。大多数时候数组直接用 std::vector 更好,它还记着长度。
2.5 函数参数怎么写:看要不要所有权
参数的类型就是函数在声明“我要对这个对象做什么”:
| 参数写成 | 意思 |
|---|---|
unique_ptr<T>(按值) | 我要拿走所有权,调用方得写 std::move,之后它手里是空的 |
T&、const T&、T* | 我只是用一下,不管它活多久;T* 表示可以传空 |
unique_ptr<T>& | 我可能要换掉你手里的那个对象(reset 成别的),很少用 |
不需要所有权时,不要传 unique_ptr 本身,传引用或裸指针:这样函数也能用在栈上的对象、别人管着的对象上,不被绑死在“必须由 unique_ptr 管理”上。裸指针在这里不危险,因为它只借不还,释放的责任清清楚楚在 unique_ptr 那边。
2.6 转成 shared_ptr
一个右值 unique_ptr 可以直接用来构造 shared_ptr:std::shared_ptr<Widget> s = make();,所有权从独占变成共享(反过来不行,共享的所有权收不回独占)。所以工厂函数通常返回 unique_ptr:调用方只要一个主人就直接用,要共享就转一下。代价是这样转来的 shared_ptr 要另外分配一次控制块,享受不到 make_shared 的“一次分配”(3.1)。
3. shared_ptr:共享所有权,计数在控制块里
3.1 对象 + 一块存计数的控制块
std::shared_ptr<T> 让好几个地方共同拥有一个对象:谁都可以持有一份,最后一份没了,对象才析构。它靠一个引用计数做到这一点。计数不在对象里,而在一块单独的内存里,叫控制块(control block)。
std::make_shared<T>()只分配一次,对象和控制块挨在一起。- 每复制一个
shared_ptr,计数 +1;每销毁一个,计数 -1;减到 0 就析构对象。 - 一个
shared_ptr本身是两个指针:一个指向对象,一个指向控制块,sizeof是 16 B。
两种写法差在分配次数。make_shared 只调一次 operator new,对象和控制块在同一块内存里,少一次分配,用对象时顺带碰到的计数也多半在相邻的缓存行。代价在释放:use_count 归零时对象照常析构,可它占的那段内存和控制块连在一起,要等 weak_count 也归零(最后一个 weak_ptr 没了)才能整块还掉。对象很大、又有 weak_ptr 长期挂着时,make_shared 会让这块内存一直留着,这时用 shared_ptr<T>(new T) 分开分配,对象析构时它的内存就能先还。
3.2 编译出来是什么:复制 = 拷两个指针 + 计数加 1
std::shared_ptr<S> copy_sp(const std::shared_ptr<S>& p) { return p; }
movdqu (%rsi), %xmm0 # 一次读 16 B:对象指针 + 控制块指针
movups %xmm0, (%rdi) # 写进新的 shared_ptr
movq 8(%rdi), %rdx # 控制块地址
testq %rdx, %rdx
je .L12 # 空指针就不用加计数
cmpb $0, __libc_single_threaded(%rip) # 进程是不是单线程
je .L14
addl $1, 8(%rdx) # 单线程:普通加 1
ret
.L14:
lock addl $1, 8(%rdx) # 多线程:原子加 1
__libc_single_threaded 是 glibc 维护的标志,进程还没起第二个线程时为真。一旦有了多个线程,每次复制(以及每次销毁时的减 1)都是一次 lock 原子操作:没有别的核在抢时约 5–10 ns;几个核同时复制同一个对象的 shared_ptr 时,计数所在的缓存行在核之间来回搬(缓存行乒乓),一次要几十到上百 ns。所以频繁调用的函数不要按值传 shared_ptr:只是用一下、不需要“拥有”,就传 const std::shared_ptr<T>&,或者直接传 T& / T*。
3.3 多线程下安不安全:只有计数是安全的
问 shared_ptr 线程安不安全,先分清楚它涉及的三样东西。它们放在不同的地方,安全与否也各不相同:
| 是什么 | 安全吗 |
|---|---|
| ① 控制块里的计数 | 安全,增减是原子的 |
② shared_ptr 变量本身,也就是那两个指针 | 不安全,同一个变量有人写,别人就不能同时读写 |
| ③ 对象指针指向的对象 | 不安全,shared_ptr 不管对象里的数据 |
- ① 控制块里的计数是线程安全的:指向同一个对象的所有
shared_ptr共用这一个计数,不管它们在哪个线程里,复制一份就 +1,销毁一份就 −1。标准库知道这个计数一定会被几个线程同时改,所以用 3.2 里那条lock原子指令来加减它。最后放手的那个线程把它减到 0、析构对象,对象只会析构一次。 - ②
shared_ptr变量本身不是线程安全的:一个shared_ptr变量就是两个指针,和一个装着两个指针的普通struct没有区别。标准库对它的保证和对一个int变量一样:几个线程同时读它(包括同时从它复制)没问题,只要有一个线程在写它,别的线程就不能同时读写。 - ③ 指向的对象不受保护:
shared_ptr只管对象什么时候析构,不管对象里的数据。两个线程通过各自的shared_ptr同时改Config的成员,照样是数据竞争,要对象自己加锁。
判断的关键是:几个线程用的是不是同一个 shared_ptr 变量。 每个线程拿着自己的一份时,各是各的变量,只是指向同一个对象,它们之间唯一共用的是计数,而计数是原子的,所以安全。几个线程直接读写同一个变量时,比如一个全局的 shared_ptr、一个对象的成员,就落到了第 ② 条。
auto cfg = std::make_shared<Config>();
std::thread t1([cfg] { use(*cfg); }); // 按值捕获:t1 有自己的一份,安全
std::thread t2([cfg] { use(*cfg); }); // t2 也有自己的一份,安全
std::shared_ptr<Config> g_cfg; // 全局变量:两个线程都直接读写它
// 线程 A:g_cfg = std::make_shared<Config>(load()); 换一份新配置
// 线程 B:auto c = g_cfg; 同时复制一份来用,数据竞争
为什么计数是原子的还不够。 线程 B 的 auto c = g_cfg; 分两步:先从 g_cfg 读出控制块的地址,再给那个控制块的计数 +1。线程 A 的赋值也分两步:先把 g_cfg 的两个指针改成新配置的,再给旧控制块的计数 −1。两边的 +1、−1 各自都是原子的,可 B 的“读地址”和“+1”之间有空隙,A 的赋值能插进来:
| 时刻 | 线程 B:auto c = g_cfg; | 线程 A:g_cfg = 新配置; | 旧配置 |
|---|---|---|---|
| 1 | 读出 g_cfg 里的控制块地址,是旧配置的 | 计数 1,只有 g_cfg 这一份 | |
| 2 | 把 g_cfg 的两个指针改成新配置的 | 计数还是 1 | |
| 3 | 给旧控制块 −1,减到 0:析构旧配置,释放控制块 | 已经释放 | |
| 4 | 给时刻 1 读到的旧控制块 +1 | 写进了已经释放的内存 | |
| 5 | 拿着旧配置的地址去用 | 用的是已经析构的对象 |
原子的只是计数这一个整数,不是“读出指针、再加计数”这一整套。 要修,就得把这一整套变成一个整体,让赋值插不进来:
- C++20 起用
std::atomic<std::shared_ptr<Config>>:线程 A 调g_cfg.store(p),线程 B 调g_cfg.load()。load()把读指针和加计数做成一次不可分割的操作,store()插不到中间。 - C++20 之前用
std::atomic_load(&g_cfg)和std::atomic_store(&g_cfg, p):作用和上面一样,只是作用在普通的shared_ptr变量上,C++20 起标为不推荐(deprecated)。 - 用一把互斥锁保护这个变量:读的一方在锁里复制出一份,出了锁再用这份副本;写的一方在锁里赋值。锁只护住复制和赋值的那一下,用配置的时候不用锁着。
前两种在 libstdc++ 里都不是无锁的:GCC 16 实测 std::atomic<std::shared_ptr<int>>::is_lock_free() 返回 false,内部还是靠锁。
三条合起来,就是“后台换配置”的常见写法。配置对象做成只读的 std::shared_ptr<const Config>,发布以后谁也不改它,第 ③ 条就不用操心;全局那个变量用 atomic<shared_ptr> 或者锁来读写,管住第 ② 条;读的线程先 load() 出自己的一份再用,就算写的线程这时已经换上了新配置,它手里这份也还活着,靠的是第 ① 条。要改配置,就新建一份、改好,再整个换上去,而不是在原来那份上改。
还有三点常被追问:
use_count()在多线程下只能当参考:读到的那一刻,别的线程可能已经复制或者放掉了一份,所以不能用use_count() == 1判断“只有我在用,可以放心改”。- 析构函数在最后放手的那个线程里执行:哪个线程把计数减到 0,就由哪个线程析构对象。析构要释放大块内存或者关文件时,这段时间就算在那个线程头上。
weak_ptr要用lock()取对象:lock()把“检查对象还活着”和“计数加 1”做成一次原子操作,先expired()再用中间有窗口,见 4.2。
4. weak_ptr:看着对象,不让它活着
4.1 指向控制块,不加 use_count
weak_ptr 只能从 shared_ptr(或另一个 weak_ptr)得到。它也是两个指针、16 字节,同样指着那块控制块,但它加的是控制块里的 weak_count,不是 use_count。对象的死活只看 use_count:最后一个 shared_ptr 没了,对象照样析构,不管还有多少 weak_ptr 指着它。
所以 weak_ptr 不能直接解引用:它指着的对象可能已经没了。要用对象,先调 lock():对象还活着,返回一个新的 shared_ptr(use_count 加 1,用的这段时间对象不会被析构);已经死了,返回空的 shared_ptr。
std::shared_ptr<Widget> s = make();
std::weak_ptr<Widget> weak = s;
if (auto locked = weak.lock()) // 活着:拿到一份 shared_ptr
std::printf("lock ok, use_count = %ld\n", locked.use_count()); // 2
s.reset(); // 最后一个 shared_ptr 没了:~Widget
std::printf("expired = %d, lock = %s\n", weak.expired(), weak.lock() ? "set" : "null");
// expired = 1, lock = null
4.2 用 lock,不用“先 expired 再用”
expired() 也能告诉你对象还在不在,但在多线程里,“查一次 expired(),再去用对象”之间有一个窗口:你刚查完说还活着,另一个线程就把最后一个 shared_ptr 放掉了。lock() 把“检查还活着”和“把 use_count 加 1”做成一次原子操作:要么拿到一份让对象一定活着的 shared_ptr,要么拿到空的,没有中间状态。所以 weak_ptr 的标准用法就是 if (auto p = w.lock()),expired() 只适合“不用对象、只想知道它在不在”的场合。
4.3 循环引用:两个计数都停在 1
父节点用 shared_ptr 持有子节点,子节点也用 shared_ptr 指回父节点:
struct Node {
std::string name;
std::shared_ptr<Node> child;
std::shared_ptr<Node> parent; // 指回去也用 shared_ptr
~Node() { std::printf("~Node %s\n", name.c_str()); }
};
{
auto p = std::make_shared<Node>("parent");
auto c = std::make_shared<Node>("child");
p->child = c; c->parent = p; // 此时两个 use_count 都是 2
} // 离开作用域:一行 ~Node 都没有打印
离开作用域时,局部变量 p、c 各放掉一份,两个 use_count 都从 2 降到 1,剩下的那一份正是对方成员里的那个 shared_ptr。父节点要析构,得先等子节点放掉它;子节点要放掉它,得先析构自己,而子节点也在等父节点。两个计数都停在 1,谁都到不了 0,两个对象永远不析构,内存泄漏。实测的输出里确实一行 ~Node 都没有。
把指回去的那一条改成 weak_ptr<Node> parent;,父节点的 use_count 只剩局部变量 p 那一份(实测是 1,子节点是 2)。离开作用域时父节点归零、析构,析构时放掉 child,子节点跟着归零。实测输出 ~Node2 parent、~Node2 child。子节点要用父节点时,if (auto pp = parent.lock())。
打破环的规则:拥有关系画成一棵树,指回去、指旁边的边都用 weak_ptr。 父拥有子,子只是要找得到父;反过来写就会成环。
4.4 还有哪些地方该用 weak_ptr
凡是“要能找到它,但不该由我决定它活多久”的地方:
- 观察者:被观察的对象维护一个观察者列表。列表里存
shared_ptr,观察者本来该销毁了,却被列表拉着活下去;存weak_ptr,通知时逐个lock(),拿不到的说明观察者已经没了,顺手从列表里删掉。 - 缓存:按 key 缓存加载过的对象,表里存
weak_ptr。还有人在用,lock()能拿到,直接复用;没人用了,对象自然释放,缓存不会把所有东西都攥在手里。 - 回调里的
this:异步回调要用对象,但对象被销毁了回调就该什么也不做,这时捕获weak_ptr,回调开头lock()。要回调期间保证对象活着,才捕获shared_ptr(见 5.1 的回调场景)。
4.5 对象没了,控制块还在
use_count 归零时析构的是对象,控制块要等 weak_count 也归零才释放:weak_ptr 还得靠它回答“对象还在不在”。用 shared_ptr<T>(new T) 时控制块单独一块,对象那块内存在析构时就还了;用 make_shared 时对象和控制块在同一块内存里,对象虽然析构了,整块内存要等最后一个 weak_ptr 也没了才能还(见 3.1 的控制块图)。对象很大、weak_ptr 又活得很久时,这是选 make_shared 前要想到的一点。
5. 成员函数里拿到自己的 shared_ptr:enable_shared_from_this
5.1 场景:对象要活到回调执行完
一个 Session 对象代表和服务器的一条连接,由 shared_ptr 管理。Session::send_request() 把请求发出去,服务器要过一会儿(比如几十 µs)才回包。程序不会停下来等,而是先登记一个函数,意思是“回包到了就调用它”。这种先登记、以后由别人来调用的函数叫回调。登记完,send_request() 立刻返回,程序接着干别的。负责“看到回包到了,就调用登记过的回调”的,通常是一个一直在转的循环,叫事件循环。
问题出在等待的这段时间里。回包到之前,程序别处可能已经不用这条连接了(比如风控把它断开),丢掉了手里的 shared_ptr。如果回调里没有持有一份 shared_ptr<Session>,计数就到 0,Session 被析构;回包一到,回调去用的是一个已经析构的对象。回调自己持有一份,计数就不会在它执行之前归零:
| 时刻 | 发生了什么 | 回调不持有时的计数 | 回调持有一份时的计数 |
|---|---|---|---|
| 1 | make_shared<Session>(),外面持有一份 | 1 | 1 |
| 2 | send_request() 登记回调 | 1 | 2 |
| 3 | 外面丢掉自己那份 | 0,Session 被析构 | 1,Session 还活着 |
| 4 | 回包到了,事件循环调用回调 | 用到已析构的对象,未定义行为 | 正常处理回包 |
| 5 | 回调执行完、被删掉 | — | 0,这时才析构 |
难点在于:回调是在 send_request() 这个成员函数里登记的,函数里手上只有 this,一个裸指针(Session*),不是 shared_ptr。怎么从 this 得到一份和外面共用同一个计数的 shared_ptr?
5.2 错误写法:shared_ptr<Session>(this) 另起一个控制块
void send_request() {
std::shared_ptr<Session> self(this); // 错:又分配了一个新的控制块
g_loop.push_back([self] { self->on_reply(); });
}
用裸指针构造 shared_ptr 时,它不知道这个对象已经有人管了,就新建一个控制块,计数从 1 开始。于是两个计数各数各的:时刻 3 外面那份数到 0,对象析构;回调这份还是 1,指着已经析构的对象;时刻 5 它也数到 0,再 delete 一次。GCC 13 实测程序直接 abort(munmap_chunk(): invalid pointer),ASan 报 bad-free。
5.3 正确写法:继承 enable_shared_from_this,调 shared_from_this()
std::vector<std::function<void()>> g_loop; // 假装是事件循环:登记的回调先存起来,回包到了再调用
struct Session : std::enable_shared_from_this<Session> {
int id = 7;
void on_reply() { std::printf("reply on session %d\n", id); }
void send_request() {
// ……把请求发出去……
auto self = shared_from_this(); // 计数 +1,和外面那份共用控制块
g_loop.push_back([self] { self->on_reply(); });
}
};
void demo() {
auto s = std::make_shared<Session>(); // 时刻 1:计数 1
s->send_request(); // 时刻 2:计数 2
s.reset(); // 时刻 3:外面丢掉自己那份,计数 1,Session 还活着
for (auto& cb : g_loop) cb(); // 时刻 4:回包到了,打印 reply on session 7
g_loop.clear(); // 时刻 5:回调删掉,计数 0,这时才析构
}
它怎么做到的。 enable_shared_from_this<Session> 里藏了一个 std::weak_ptr<Session> 成员。weak_ptr 也指向控制块,但不让计数 +1,所以不会让对象一直活着(第 4 节)。make_shared 建对象时,发现它继承了 enable_shared_from_this,就把这个 weak_ptr 指向刚建好的控制块。shared_from_this() 只是把它升级成 shared_ptr:同一个控制块,计数 +1。
这是 CRTP。 enable_shared_from_this 是一个往子类里注入功能的 CRTP 基类(见 #8 的 1.3.2 节):子类什么都不写,就多了 shared_from_this()。基类里的 weak_ptr 成员和 shared_from_this() 的返回值都要写成 Session,所以模板参数传的是子类自己。基类如果不是模板,只能存、只能返回 shared_ptr<基类>,每次用都要先 static_pointer_cast 转回 shared_ptr<Session>,转错了就是未定义行为。
5.4 用 shared_from_this() 的三个坑
都在 GCC 13 上实测过:
- 对象必须已经归
shared_ptr管。 对栈上的对象调用,或者在构造函数里调用(这时make_shared还没填weak_ptr),C++17 起抛std::bad_weak_ptr,更早的标准是未定义行为。 - 必须
public继承。 私有继承时shared_ptr看不见这个基类,weak_ptr没人填,同样抛bad_weak_ptr。 - 对象变大,调用不免费。 对象多一个
weak_ptr(两个指针,16 B;实测sizeof(Session)是 24,含对齐填充);每次shared_from_this()都是 3.2 那次原子加。所以它用在连接管理这种冷路径上(Asio 风格的异步服务器里,回调常写成[self = shared_from_this()]),每秒要处理几百万条消息的循环里不这么做。
6. 三种怎么选
默认用 unique_ptr。 绝大多数对象只有一个明确的主人:容器里的元素、类的成员、工厂造出来交给调用方的对象。它和裸指针一样大,没有计数,转交所有权时代码里能看到 std::move。
只有对象的寿命真要由几方共同决定时才用 shared_ptr:比如异步操作的回调可能在发起方之后才执行、几个线程都要用同一份配置而说不清谁最后用完。它的代价是 16 字节、一块控制块、多线程下每次复制一次原子操作。
weak_ptr 配合 shared_ptr 用,放在指回去的边、观察者列表、缓存里。只是在函数里借用一下对象,用引用或裸指针,不需要任何智能指针。
shared_ptr 的线程安全常被问到:控制块里的计数是原子操作,所以不同线程各拿一份 shared_ptr 复制、销毁是安全的;但它指着的对象本身不受保护,同一个 shared_ptr 变量一边赋值一边读也是数据竞争。几种情况和修法见 3.3。
C++98 的 auto_ptr 想做 unique_ptr 的事,但那时还没有移动语义,它只能把拷贝写成“偷”:auto_ptr<T> b = a; 之后 a 悄悄变成空的。看起来是拷贝,实际是转移,放进容器、传给按值收参数的函数都会莫名其妙地把原来那个弄空。C++11 有了右值引用以后,unique_ptr 把拷贝禁掉、只留移动,转移必须写成 std::move(a),auto_ptr 在 C++17 里被删掉了。移动语义本身见 左值、右值和移动语义。