智能指针选型
# 第28章:智能指针选型指南
# 目录介绍
- 1. 案例引入:两条主线
- 2. 所有权五种心智
- 3. 三大智能指针总图
- 4. unique_ptr 深度
- 5. shared_ptr 深度
- 6. weak_ptr 深度
- 7. make 系列与构造
- 8. 自定义删除器
- 9. 多线程安全模型
- 10. 五步选型方法论
- 11. 典型场景速查
- 12. 工程化最佳实践
- 13. 综合案例串讲
# 1. 案例引入:两条主线
讲智能指针,最忌讳"上来就背语法"。本篇用两条真实主线贯穿全文:一条来自生产环境的内存泄漏,一条来自 32 行的最小可复现代码。前者展示"shared_ptr 循环引用如何在大型异步系统里造成内存爬升",后者展示"裸指针构造 shared_ptr 的双控制块 UAF 怎么 5 行代码就能复现"。
# 1.1 主线一:循环不释
某长连接网关,RSS 内存以每天 200MB 速度持续爬升,重启则归零。监控曲线特征非常典型:
RSS (MB)
2400 ────────────────────────────────────────● 重启前
2200 ──────────────────────────────────●
2000 ────────────────────────────●
1800 ──────────────────────●
1600 ────────────────●
1400 ──────────●
1200 ─────●
1000 ● 服务启动
Day1 Day2 Day3 Day4 Day5 Day6 Day7
2
3
4
5
6
7
8
9
10
代码主体看起来"完全用了智能指针"——按照"现代 C++ 最佳实践"写的:
// session.h
struct Connection;
struct Session {
int id;
std::shared_ptr<Connection> conn; // 持有连接
std::vector<std::function<void(int)>> callbacks; // 业务回调
};
struct Connection {
int fd;
std::shared_ptr<Session> session; // 反向持有 Session(用于回调)
void on_data(const char* buf, size_t n);
};
// gateway.cpp
void on_new_connection(int fd) {
auto s = std::make_shared<Session>();
s->id = next_id++;
s->conn = std::make_shared<Connection>();
s->conn->fd = fd;
s->conn->session = s; // ⚠️ 关键:反向 shared_ptr
sessions_[s->id] = s;
}
void close_session(int sid) {
sessions_.erase(sid); // 看似释放了
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
现象:
- 单元测试(创建 1 千个 session 后全部 close):内存平稳;
- 灰度环境(QPS 5 万、连接率持续):内存只增不减;
- 用 jemalloc heap profile:全部对象都被持有,没有"泄漏到不可达"。
直觉怀疑:是不是哪里 sessions_ 没 erase 干净?打日志验证:每次 close_session 都被调用,sessions_.size() 也确实下降。erase 之后引用计数仍 ≥ 1——这就是经典的循环引用:
sessions_ ───► shared_ptr<Session> (count=2)
│
├──► Session
│ │
│ ▼
│ shared_ptr<Connection> (count=1)
│ │
│ ▼
│ Connection
│ │
│ ▼
│ shared_ptr<Session> (count=2)
│ │
└──────────────┘
循环!谁都先放不下谁
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sessions_.erase(sid) 把外部引用减到 1(剩下 Connection::session 这一份),但 Session 持有 Connection、Connection 又持有 Session——两边的引用计数都不会归零,对象组永远不会被析构。
更进一步用 gdb 看 sessions_.erase 后的某个 Session:
(gdb) p *some_session_ptr
$1 = (Session) {
id = 12345,
conn = std::shared_ptr<Connection> (count=1, weak=0), ← 还活着
callbacks = std::vector<...> = {...} ← 业务持有的 lambda
}
2
3
4
5
6
callbacks 里的 std::function 还按值捕获了 shared_ptr<Session>——又一道环。典型的"shared 持 shared,怎么都释放不掉"。
# 1.2 主线二:双控双删
另一位同学发来求助:
"我就是想让两个
shared_ptr共享一个对象,最简单的写法。运行第一次 OK,第二次跑就double free or corruption (out),完全看不懂。"
打开他的项目,几十个文件——但触发崩溃的代码抽离出来其实只有 32 行。这就是最小可复现案例(MCVE):
// crash.cpp —— 全文第二条主线,32 行
#include <iostream>
#include <memory>
struct Resource {
int v;
Resource(int x) : v(x) { std::cout << "ctor " << v << "\n"; }
~Resource() { std::cout << "dtor " << v << "\n"; }
};
int main() {
Resource* raw = new Resource(42); // ① 用裸指针构造
std::shared_ptr<Resource> a(raw); // ② 第一个 shared_ptr 接管
std::shared_ptr<Resource> b(raw); // ③ 第二个 shared_ptr 又接管
std::cout << "a.use_count = " << a.use_count() << "\n";
std::cout << "b.use_count = " << b.use_count() << "\n";
return 0; // ④ a 析构 → delete raw
} // b 析构 → delete raw → 崩
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
编译运行:
$ g++ crash.cpp -o crash -std=c++17
$ ./crash
ctor 42
a.use_count = 1
b.use_count = 1 ← ⚠️ 两个都是 1,说明各自一份控制块
dtor 42 ← a 析构,delete 一次
free(): double free detected in tcache 2
Aborted (core dumped) ← b 析构,第二次 delete 同一块内存
2
3
4
5
6
7
8
三个现象非常扎眼:
a.use_count == b.use_count == 1—— 不是预期的 2;- 析构信息只打了一次 —— 真正释放了一次;
- 第二次释放就崩 —— glibc 检测到 double-free 后主动 abort。
好的调试,第一步永远是把问题简化到 MCVE。32 行让"看不懂的崩溃"变成"非常清楚的双控制块问题"。
# 1.3 顺藤摸到根因
带着两条主线往下挖,至少藏着这些原理点:
① 智能指针到底是什么? 它"自动"在哪里? → 第 2 章
② unique / shared / weak 三者怎么选? → 第 3 章
③ unique_ptr 移动到底零开销吗? → 第 4 章
④ shared_ptr 的控制块长什么样? use_count 在哪? → 第 5 章
⑤ weak_ptr 怎么打破循环? lock() 是否原子? → 第 6 章
⑥ 为什么必须用 make_shared 而不是 new? → 第 7 章
⑦ 怎么用 unique_ptr 包 FILE* / fd 这类 C 句柄? → 第 8 章
⑧ shared_ptr 跨线程拷贝、读写到底安不安全? → 第 9 章
⑨ 选型时有没有可复用的方法论? → 第 10 章
2
3
4
5
6
7
8
9
# 1.4 本篇要回答什么
| 层次 | 你将学到 |
|---|---|
| 心智层 | 所有权五种心智、独占/共享/观察/借用四模型 |
| 实现层 | unique_ptr 零开销证明、shared_ptr 控制块布局、weak_ptr 升级语义 |
| 工具层 | make_shared / make_unique、自定义删除器、aliasing 构造器 |
| 方法层 | 五步选型法、cycle 检测、API 边界设计 |
| 工程层 | clang-tidy 规则、CI lint、shared 滥用治理 |
📌 本篇定位:这是开发技巧篇的资源管理总纲。无论后面要讲的 RAII 范式、PIMPL、观察者模式、异步回调安全,本质都是"用什么所有权语义包装资源"。读完本篇,再看任何 C++ 资源管理代码,都能立刻回答:"这个对象谁拥有、什么时候释放、释放风险在哪"。
# 2. 所有权五种心智
进入三大智能指针之前,先把"所有权"这个抽象概念讲透。智能指针的本质不是"自动 delete",而是"用类型表达所有权"。
# 2.1 所有权是什么
所有权(ownership) 在 C++ 里指的是:谁负责销毁这个资源、什么时候销毁、销毁后谁来通知别人。
// 裸指针的所有权语义:编译器一无所知,全靠人脑约定
void process(Widget* w); // w 是借用?传所有权?拿走 delete?
// 智能指针的所有权语义:写在类型里,编译器能检查
void process(std::unique_ptr<Widget> w); // 调用方放弃所有权
void process(std::shared_ptr<Widget> w); // 调用方共享所有权
void process(Widget& w); // 借用,绝不 delete
2
3
4
5
6
7
关键洞察:类型本身就是文档。看到 unique_ptr<T>,就知道"独占、可移动不可拷贝、出作用域自动释放"。
# 2.2 独占所有权
唯一的拥有者,复制即转让——std::unique_ptr 的语义。
auto p = std::make_unique<Widget>();
// p 是 Widget 的唯一所有者
// p 离开作用域 → Widget 自动 delete
auto q = std::move(p); // 所有权转给 q,p 变 null
// auto r = p; // ❌ 编译错:unique_ptr 不可拷贝
2
3
4
5
6
心智图景:
作用域开始: ┌─────────────────────┐
│ p ──► Widget │
└─────────────────────┘
move 之后: ┌─────────────────────┐
│ p (null) │
│ q ──► Widget │
└─────────────────────┘
作用域结束: Widget 被自动 delete
2
3
4
5
6
7
8
适合 99% 的场景:资源只有一个明确的拥有者——容器持有元素、函数局部资源、PIMPL 隐藏实现等。
# 2.3 共享所有权
多个拥有者,最后一个走的关灯——std::shared_ptr 的语义。
auto a = std::make_shared<Widget>(); // count=1
auto b = a; // count=2,共享
{
auto c = a; // count=3
} // c 析构,count=2
// b 析构,count=1
// a 析构,count=0 → Widget delete
2
3
4
5
6
7
心智图景:
┌─── 控制块 (count=3) ───┐
a ────────► │ │
b ────────► │ ┌──► Widget │
c ────────► │ └───── │
└────────────────────────┘
2
3
4
5
关键代价:
- 每个
shared_ptr占 16 字节(指针 + 控制块指针)vs 裸指针 8 字节; - 控制块本身是堆分配(除非
make_shared合并); - 拷贝/析构都要原子加减计数 → 跨核同步成本。
适用:真正"多源共享"的场景——观察者订阅、缓存条目、异步任务句柄。绝大多数情况你不需要它。
# 2.4 观察不持有
我只想看,但不想延长它的命——std::weak_ptr 的语义。
std::shared_ptr<Widget> sp = std::make_shared<Widget>();
std::weak_ptr<Widget> wp = sp; // weak,不增加 use_count
// 用的时候必须 lock 一下,转成临时 shared_ptr
if (auto sp2 = wp.lock()) { // ✅ 对象还活着
sp2->do_stuff();
} else {
// ⚠️ 对象已经死了,wp 自然失效
}
2
3
4
5
6
7
8
9
心智图景:
sp ────► [Widget] (use_count=1, weak_count=1)
▲
wp ───────────────────────┘ (只观察,不持有)
sp.reset() 之后:
sp (null)
[Widget 已 delete]
控制块还在 (use_count=0, weak_count=1)
▲
wp ───────────────────────┘ wp.expired() = true
2
3
4
5
6
7
8
9
10
核心用途:
- 打破 shared_ptr 循环引用(主线一的解药);
- 异步回调的安全引用(避免回调时对象已死);
- 观察者模式的弱订阅(订阅者不延长发布者寿命)。
# 2.5 借用不延寿
我只在这个函数里用一下,绝不影响所有权——裸指针/引用的语义。
void print(const Widget& w); // 借用:绝不 delete,绝不延寿
void inspect(const Widget* w); // 借用:可空版引用
// 调用方
auto p = std::make_unique<Widget>();
print(*p); // 借用,p 仍是唯一所有者
2
3
4
5
6
关键原则:函数参数不传所有权时,用引用/裸指针——不要传 shared_ptr/unique_ptr:
// ❌ 这样写,调用方被迫升级到 shared_ptr
void use(std::shared_ptr<Widget> w) { w->f(); }
// ✅ 借用就用引用——调用方什么所有权都能传
void use(const Widget& w) { w.f(); }
unique_ptr<Widget> u = ...; use(*u); // 都行
shared_ptr<Widget> s = ...; use(*s); // 都行
Widget w; use(w); // 都行
2
3
4
5
6
7
8
9
五种心智一览:
| 模型 | 类型 | 拷贝行为 | 释放责任 |
|---|---|---|---|
| 独占 | unique_ptr<T> | 不可拷贝,可 move | 唯一所有者出作用域 |
| 共享 | shared_ptr<T> | 拷贝增计数 | 计数归零时 |
| 观察 | weak_ptr<T> | 拷贝不增计数 | 不参与释放 |
| 借用 | T& / const T& / T* | N/A | 不参与释放 |
| 拥有但弃旧 | T(值语义) | 深拷贝 | 自身析构 |
黄金法则:如果一个类型不能告诉你它是哪一种心智,就是设计有问题。
# 3. 三大智能指针总图
把 <memory> 头文件里所有智能指针,先用三句话讲清楚。
# 3.1 unique 一句话
"新的
T*"——零开销的 RAII 包装,独占所有权,移动即转让。
auto p = std::make_unique<MyClass>(arg1, arg2);
// 等价于 MyClass* p = new MyClass(arg1, arg2)
// 但出作用域自动 delete,无需手写
2
3
99% 的"我需要个对象在堆上"场景,答案都是 unique_ptr。
# 3.2 shared 一句话
"带引用计数的 T"*——多个拥有者,原子加减计数,归零时释放。
auto sp = std::make_shared<MyClass>(arg1); // count=1
auto sp2 = sp; // count=2,共享
2
关键:除非你真的需要"多个独立的拥有者",否则不要用。shared_ptr 是工程上被滥用最严重的智能指针。
# 3.3 weak 一句话
"shared 的弱订阅"——观察对象但不延寿、用前
lock()升级。
std::weak_ptr<MyClass> wp = sp;
if (auto p = wp.lock()) {
p->use(); // 临时升级为 shared_ptr,确保使用期间对象不死
}
2
3
4
典型用途:打破循环引用、异步回调安全。
# 3.4 三者关系全景
一张图把三者关系画清:
┌──────────────────┐
│ 控制块(堆) │
│ ┌──────────┐ │
│ │ use_count│ ←──┼──── shared_ptr 增减它
│ │ weak_count│←──┼──── weak_ptr 增减它
│ │ deleter │ │
│ └──────────┘ │
└────────┬─────────┘
│
▼
shared_ptr<T> ──────────────► [Widget 对象]
shared_ptr<T> ──────────────►
use_count
决定何时
delete
weak_ptr<T> ──┐
└──► 仅指向控制块,不影响 use_count
lock() 时检查 use_count 是否仍 > 0
unique_ptr<T> ──────────► [Widget 对象] (没有控制块!只是裸指针 + deleter 的 wrapper)
sizeof = 8 (默认 deleter)
sizeof = 8 + sizeof(deleter)(自定义 deleter)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
核心区别:
| 维度 | unique_ptr | shared_ptr | weak_ptr |
|---|---|---|---|
| 大小 | 8 字节(默认) | 16 字节 | 16 字节 |
| 堆分配 | 仅对象 | 对象 + 控制块(make 时合并) | 不分配 |
| 拷贝 | ❌ 禁止 | ✅ 增计数 | ✅ 增 weak_count |
| 移动 | ✅ 转让 | ✅ 转让 | ✅ 转让 |
| 解引用 | 直接 | 直接 | ❌ 必须先 lock() |
| 延长寿命 | 是 | 是 | 否 |
| 线程安全 | 无并发问题(唯一所有者) | 计数原子,对象本身否 | lock() 原子 |
# 3.5 选型决策树
实战时按这个流程走:
┌────────────────────────────────────────────┐
│ 需要堆上对象,且要表达所有权? │
└─────┬────────────────────┬─────────────────┘
是 否
│ │
▼ ▼
有几个所有者? 用引用/裸指针/值
│
┌────┴────┐
│ │
一个 多个
│ │
▼ ▼
unique_ptr 会形成循环引用吗?
│
┌────┴────┐
否 是
│ │
▼ ▼
shared_ptr shared_ptr
+ weak_ptr 打破循环
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
实际决策时再加一道题:
✅ 该 unique 不该 shared 的信号:
• 工厂返回新对象 → unique_ptr
• 容器持有元素 → vector<unique_ptr<T>>
• PIMPL 类的 impl_ → unique_ptr<Impl>
• 函数局部资源 → unique_ptr
✅ 该 shared 的信号:
• 真有多处独立持有(缓存 + 观察者)
• 跨线程异步任务持有结果
• 引用计数本身就是业务需要
✅ 加 weak 的信号:
• shared_ptr 形成回环
• 观察者订阅但不阻止发布者死
• 异步回调可能到达时对象已死
2
3
4
5
6
7
8
9
10
11
12
13
14
15
记忆口诀(升级版):
默认 unique,共享才 shared,循环加 weak,借用用引用——永远 make 不要 new。
# 4. unique_ptr 深度
unique_ptr 是最重要的智能指针——重要到说"99% 场景用它就够了"也不夸张。本章把它的机理、惯用法、与裸指针的等价关系讲透。
# 4.1 内部就是裸指针
unique_ptr 的实现,本质就是一个裸指针 + 一个删除器:
// 简化的内部实现(gcc/clang 大致都这样)
template <typename T, typename Deleter = std::default_delete<T>>
class unique_ptr {
T* ptr_;
Deleter deleter_; // 默认 deleter 是空类,EBO 优化后零开销
public:
~unique_ptr() { if (ptr_) deleter_(ptr_); }
T* get() const { return ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
// 禁止拷贝
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
// 允许移动
unique_ptr(unique_ptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键性质:sizeof(unique_ptr<T>) == sizeof(T*)——通过 EBO(Empty Base Optimization)让默认 deleter 不占空间。这意味着:
static_assert(sizeof(std::unique_ptr<int>) == sizeof(int*)); // ✅ 通过
static_assert(sizeof(std::unique_ptr<Widget>) == sizeof(Widget*)); // ✅ 通过
2
这就是"零开销抽象"——unique_ptr 在汇编层和裸指针 100% 等价,只是编译期帮你管 delete。
# 4.2 移动而非拷贝
unique_ptr 不可拷贝,只能移动——这是它"独占"语义的强制保证:
auto p = std::make_unique<Widget>();
// auto q = p; // ❌ 编译错:copy ctor 被 delete
auto q = std::move(p); // ✅ 移动:p 变 null,q 是新所有者
2
3
移动的代价:
// 移动汇编展开:
// mov rax, [rdi] ← 读 p->ptr_
// mov [rsi], rax ← 写 q->ptr_
// mov qword [rdi], 0 ← p->ptr_ = nullptr
// 总共 3 条 mov,零分配,零原子操作
2
3
4
5
和 shared_ptr 拷贝对比(后者要原子加 1):
| 操作 | unique 移动 | shared 拷贝 |
|---|---|---|
| 指令数 | 3 mov | 1 lock add(原子加)+ 内存拷贝 |
| 跨核同步 | 无 | 有(cache line 抖动) |
| 失败可能 | 无 | 无(原子操作不会失败) |
| 异常安全 | noexcept | 通常 noexcept |
返回 unique_ptr 的工厂模式:
std::unique_ptr<Widget> create_widget(int x) {
return std::make_unique<Widget>(x);
}
auto w = create_widget(42); // 返回值优化(NRVO)+ 移动,零拷贝
2
3
4
5
C++17 起 NRVO 是强制的——返回 unique_ptr 等于直接在调用方栈上构造,比返回裸指针 new Widget(x) 还高效(少一层拷贝)。
# 4.3 自定义删除器
默认 deleter 是 std::default_delete<T>,调 delete ptr_。可以替换成任意可调用对象:
// 函数指针删除器
auto file_deleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(file_deleter)>
fp(fopen("data.txt", "r"), file_deleter);
// 出作用域自动 fclose
// 通用:用 lambda + decltype
auto sock_deleter = [](int* fd) { if (*fd >= 0) close(*fd); delete fd; };
std::unique_ptr<int, decltype(sock_deleter)> sock(new int(open(...)), sock_deleter);
2
3
4
5
6
7
8
9
注意:自定义 deleter 让 unique_ptr 变大(sizeof 增加),因为 lambda 不再是空类。
struct StatefulDeleter {
int log_id;
void operator()(Widget* w) { log("delete", log_id); delete w; }
};
// sizeof 变成 16 字节(指针 8 + log_id 8)
std::unique_ptr<Widget, StatefulDeleter> p(new Widget, {42});
2
3
4
5
6
7
# 4.4 数组特化
unique_ptr<T[]> 是数组的专门特化——析构时调 delete[] 而不是 delete:
auto arr = std::make_unique<int[]>(100); // new int[100](),全部零初始化
arr[0] = 1;
arr[99] = 2;
// 出作用域:自动 delete[],析构所有元素
// 错误用法
auto bad = std::make_unique<int>(100); // 这是 new int(100),单个 int = 100
bad[0]; // ❌ 编译错:单元素 unique_ptr 没有 operator[]
2
3
4
5
6
7
8
实战建议:有 vector<T> 就别用 unique_ptr<T[]>——前者带 size、能扩容、cache 友好。unique_ptr<T[]> 仅适合"运行时定长、且不希望 vector 的额外开销"的极少数场景。
# 4.5 与 C API 互操作
unique_ptr 和 C API 边界的三种用法:
// 1) 给 C API 用:暴露裸指针,但 unique_ptr 仍是所有者
extern "C" void some_c_api(void* opaque);
auto p = std::make_unique<Widget>();
some_c_api(p.get()); // C API 借用,不接管
// p 仍是所有者
// 2) 接管 C 分配的内存
extern "C" Widget* malloc_widget();
extern "C" void free_widget(Widget*);
auto p = std::unique_ptr<Widget, decltype(&free_widget)>(
malloc_widget(), &free_widget); // 接管 + 自定义 deleter
// 3) 把所有权交给 C API(极少数情况)
extern "C" void register_widget(Widget* w); // 接管所有权
auto p = std::make_unique<Widget>();
register_widget(p.release()); // ⚠️ release 而非 reset:放弃所有权但不 delete
// p 现在是 null,不再管理
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
release() vs reset():
release():返回裸指针,放弃所有权但不 delete——把所有权交给别人;reset(p):接管新指针 p,delete 旧指针。
# 4.6 sink 参数惯用法
参数是 unique_ptr 的函数表达"我会接管这个对象"的语义——称为 sink:
// 接收所有权
void take(std::unique_ptr<Widget> w) {
w->use();
// w 出作用域自动 delete
}
// 调用方必须显式 move
auto p = std::make_unique<Widget>();
take(std::move(p)); // ✅ p 变 null,所有权转给 take
// take(p); // ❌ 编译错:unique_ptr 不可拷贝
2
3
4
5
6
7
8
9
10
三种参数语义对比:
void f1(std::unique_ptr<Widget> w); // sink:接管所有权
void f2(Widget& w); // borrow:借用
void f3(const Widget& w); // const borrow:只读借用
void f4(std::unique_ptr<Widget>& w); // ⚠️ 反模式:要么 sink 要么 borrow,不要这个
2
3
4
反模式分析:unique_ptr<Widget>& 让调用方既要"必须用 unique_ptr"又"不接管所有权"——这种约束没有任何价值,应该改成 Widget&。只有"我可能 reset 它"才用 unique_ptr<Widget>&。
# 5. shared_ptr 深度
shared_ptr 是最容易被滥用的智能指针。本章把它的内部结构、性能成本、隐藏陷阱讲透——这些是写正确 shared_ptr 代码的物理基础。
# 5.1 控制块结构
shared_ptr<T> 的对象本身只有 16 字节:
template <typename T>
class shared_ptr {
T* ptr_; // 8 字节:指向真正的对象
control_block* ctrl_; // 8 字节:指向控制块
};
2
3
4
5
但堆上还有一个控制块(control block):
struct control_block {
std::atomic<long> use_count; // shared_ptr 引用数
std::atomic<long> weak_count; // weak_ptr 引用数 + (use_count > 0 ? 1 : 0)
Deleter deleter; // 自定义删除器(默认空)
Allocator alloc; // 分配器(默认空)
// 可能还有对象本体(make_shared 时)
};
2
3
4
5
6
7
全景图:
shared_ptr a shared_ptr b weak_ptr w
│ │ │
│ │ │
├─ptr_ ────┐ ├─ptr_ ────┐ │
├─ctrl_ ──┐ │ ├─ctrl_ ──┐ │ ├─ctrl_ ──┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────────────────────┐
│ 控制块 (heap) │
│ use_count = 2 │
│ weak_count = 2 (w + 1) │
│ deleter = default │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ Widget 对象 (heap) │
│ ... 用户数据 ... │
└──────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use_count > 0 时,weak_count 多 1("对象自己持有自己一份 weak 引用")。这样 weak_count == 0 才是"控制块也能释放"的真信号。
# 5.2 引用计数原子性
shared_ptr 的拷贝、析构都要原子加减计数——这是它跨线程安全的核心:
// 拷贝构造(简化)
shared_ptr(const shared_ptr& o)
: ptr_(o.ptr_), ctrl_(o.ctrl_) {
if (ctrl_) ctrl_->use_count.fetch_add(1, std::memory_order_relaxed);
}
// 析构
~shared_ptr() {
if (ctrl_ && ctrl_->use_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
// 我是最后一个,delete 对象
ctrl_->deleter(ptr_);
if (ctrl_->weak_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ctrl_;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键性质:
- 加计数用
relaxed——不需要同步任何数据,只是计数; - 减计数用
acq_rel——确保对象的所有写入对"最后释放线程"可见; - 比较和释放是两步——所以多线程下可能出现"两个线程都 fetch_sub 到 1"的争抢,最终只有一个进入释放分支。
性能开销:
跨核拷贝:原子 fetch_add → cache line 在 NUMA 节点间抖动
单次约 20-100 ns(视 CPU 拓扑而定)
单核拷贝:~5 ns(命中 L1 cache)
如果一个 shared_ptr 在 64 线程间频繁拷贝:
每秒可达 1 千万次原子操作 → 计数器所在 cache line 成为热点
实测吞吐能下降 50% 以上
2
3
4
5
6
7
结论:shared_ptr 跨核拷贝有显著代价——延迟敏感场景(HFT、游戏帧渲染)请避开。
# 5.3 内存布局两种
shared_ptr 的对象 + 控制块在堆上有两种布局:
布局一:分离分配(裸指针构造)
auto raw = new Widget;
std::shared_ptr<Widget> sp(raw);
┌──────────────┐ ┌──────────────┐
│ 控制块 (heap) │ ──────► │ Widget (heap)│
│ use_count=1 │ │ │
└──────────────┘ └──────────────┘
两次 malloc,两次 free
2
3
4
5
6
7
8
布局二:合并分配(make_shared 构造)
auto sp = std::make_shared<Widget>();
┌────────────────────────┐
│ 控制块 + Widget (heap) │ ← 一次 malloc
│ use_count=1 │
│ Widget {...} │
└────────────────────────┘
2
3
4
5
6
7
布局二的好处:
- 少一次 malloc/free——内存分配器开销近 50% 节省;
- 缓存友好——控制块和对象相邻,访问对象同时预取计数;
- 强异常安全(第 7.2 节详述)。
布局二的代价:
- 对象生命周期被拉长——只要还有
weak_ptr,对象的内存就不能 release(因为控制块和对象在同一块); - 大对象 + 长 weak:可能浪费内存。
实战默认:用 make_shared。只有"对象很大、且预期 weak_ptr 长期持有"时才用裸 new + shared_ptr 构造。
# 5.4 别名构造器
一个非常少用但威力惊人的特性:aliasing constructor——共享一个控制块、但指向另一个对象(通常是子对象):
struct Compound {
int metadata;
Widget widget;
};
auto sp = std::make_shared<Compound>(); // 控制块 + Compound
std::shared_ptr<Widget> wp(sp, &sp->widget); // 共享 sp 的控制块、但指向 sp->widget
// wp 析构时不 delete &widget,而是减 sp 的引用计数
// 当 sp 也析构后,整个 Compound 一起释放
2
3
4
5
6
7
8
9
10
实战用途:
- 把"对象的一部分"作为独立的
shared_ptr传出去,但不破坏整体的所有权; - 解决"内嵌对象的生命周期"问题。
坑点:别用别名 ctor 把局部变量包成 shared_ptr——那样就是 UAF。
# 5.5 enable_shared_from_this
类自己怎么拿到自己的 shared_ptr?错误写法:
class Widget {
public:
std::shared_ptr<Widget> get_shared() {
return std::shared_ptr<Widget>(this); // ❌ 双控制块!主线二的坑
}
};
2
3
4
5
6
正确写法:继承 std::enable_shared_from_this:
class Widget : public std::enable_shared_from_this<Widget> {
public:
std::shared_ptr<Widget> get_shared() {
return shared_from_this(); // ✅ 共享原有控制块
}
};
// 使用前提:必须先用 shared_ptr 持有该对象
auto sp = std::make_shared<Widget>();
auto sp2 = sp->get_shared(); // ✅
// 否则
Widget w;
w.get_shared(); // ❌ 抛 std::bad_weak_ptr 或 UB
2
3
4
5
6
7
8
9
10
11
12
13
机理:enable_shared_from_this 内部藏一个 weak_ptr<T>——构造 shared_ptr 时把这个 weak 也设上。shared_from_this() 调的是 weak.lock()。
# 5.6 性能成本清单
shared_ptr 的常见性能误区:
| 操作 | 大致耗时(单核 命中 cache) | 跨核耗时 |
|---|---|---|
make_shared<T>() | 一次 malloc ~50 ns | 同左 |
| 拷贝构造 | ~5 ns(一次原子加) | ~50-100 ns |
| 移动构造 | ~2 ns(不动计数) | 同左 |
| 析构(不释放) | ~5 ns | ~50-100 ns |
| 析构(释放对象) | 50-100 ns(free + dtor) | 同左 |
lock()(weak → shared) | ~10 ns(CAS) | ~100-200 ns |
优化要点:
- 能 move 就别 copy——
std::move(sp)不动计数; - 传参用
const shared_ptr&或裸引用——避免冗余的拷贝增减; - 不要把 shared_ptr 当 const 参数传——拷贝代价不可忽略;
- 避免循环引用——主线一的根因;
use_count() == 1不能用作并发判断——读完后另一个线程可能立刻拷贝。
# 6. weak_ptr 深度
weak_ptr 是 shared_ptr 的"安全订阅"——它存在的核心目的就两个:打破循环引用 + 异步回调安全。
# 6.1 弱引用的语义
weak_ptr 的语义:我知道这个对象的位置,但我不延长它的命:
auto sp = std::make_shared<Widget>();
std::weak_ptr<Widget> wp = sp; // wp 不增加 use_count
sp.reset(); // Widget 死了
// wp 自动失效——wp.expired() 返回 true,wp.lock() 返回 nullptr
2
3
4
5
心智图景:
sp ─────► [Widget] 控制块 (use_count=1, weak_count=2)
▲
wp ───────────────────────┘ 仅指向控制块
sp.reset() 后:
sp (null)
[Widget 已 delete]
控制块 (use_count=0, weak_count=1)
▲
wp ───────────────────────┘ expired() = true
2
3
4
5
6
7
8
9
10
# 6.2 lock 升级原子
weak_ptr 不能直接解引用——必须先 lock() 升级为 shared_ptr:
std::weak_ptr<Widget> wp = sp;
// 错误用法
// wp->use(); // ❌ weak_ptr 没有 operator->
// (*wp).use(); // ❌ 也没有
// 正确用法
if (auto p = wp.lock()) { // 原子地:检查 + 升级
p->use(); // 在 p 的生命周期内,对象保活
} // p 析构,可能让对象死
2
3
4
5
6
7
8
9
10
lock() 的原子性证明:
// 简化的 lock() 实现
shared_ptr<T> lock() const noexcept {
long old = ctrl_->use_count.load(std::memory_order_relaxed);
while (old > 0) {
if (ctrl_->use_count.compare_exchange_weak(
old, old + 1,
std::memory_order_acq_rel,
std::memory_order_relaxed)) {
return shared_ptr<T>(ctrl_, ptr_);
}
// CAS 失败,old 已被更新,循环重试
}
return shared_ptr<T>{}; // null
}
2
3
4
5
6
7
8
9
10
11
12
13
14
关键:CAS 保证"看到 use_count > 0 → 升级到 +1"是不可分割的——绝不会出现"刚看到 1,准备升级时变 0,结果给个野指针"。
# 6.3 expired 与生命
std::weak_ptr<Widget> wp = sp;
if (wp.expired()) { // 判断对象是否已死
// 已死,wp.lock() 一定返回 nullptr
}
if (auto p = wp.lock()) { // 推荐:lock 一次同时检查 + 取
// 用 p
}
2
3
4
5
6
7
8
9
坑点:expired() 和 lock() 不能分两步用——多线程下两步之间状态可能变:
// ❌ 反模式
if (!wp.expired()) { // 此刻活着
auto p = wp.lock(); // ⚠️ 此刻可能已死,p 可能是 null
p->use(); // 可能 SIGSEGV
}
// ✅ 正模式:一次 lock 完成所有事
if (auto p = wp.lock()) { // 检查 + 升级原子完成
p->use();
}
2
3
4
5
6
7
8
9
10
# 6.4 打破循环引用
回到主线一的修法——把"反向持有"改成 weak:
// 修复前(循环)
struct Session { std::shared_ptr<Connection> conn; };
struct Connection { std::shared_ptr<Session> session; }; // ⚠️ 强引用
// 修复后(破环)
struct Session { std::shared_ptr<Connection> conn; };
struct Connection { std::weak_ptr<Session> session; }; // ✅ 弱引用
void Connection::on_data(const char* buf, size_t n) {
if (auto s = session.lock()) { // 升级为 shared,使用期间保活
s->process(buf, n);
} // s 析构,可能让 Session 死
}
2
3
4
5
6
7
8
9
10
11
12
13
判断原则:
- 谁的生命周期更长,谁持 shared;
- 谁的生命周期更短或被动,谁持 weak。
主线一里 Session 的生命由外部 sessions_ 表决定(更主动),Connection 是 Session 的子组件(被动)——所以Connection 持 Session 应该是 weak。
# 6.5 异步回调 weak
异步任务的经典坑:lambda 捕获 shared_ptr 让对象意外延寿;裸捕 this 又怕回调时对象已死。weak_ptr 是标准解法:
class Timer {
std::shared_ptr<Widget> widget_;
void schedule_async() {
std::weak_ptr<Widget> wp = widget_; // 只捕弱引用
async_run([wp]() {
if (auto p = wp.lock()) { // 回调时检查
p->refresh(); // 用期间保活
}
// else: 对象已死,安静退出
});
}
};
2
3
4
5
6
7
8
9
10
11
12
13
对比三种写法:
// ❌ 写法 A:捕 shared_ptr → 异步任务延长对象寿命(不一定是想要的)
async_run([sp = widget_]() { sp->refresh(); });
// ❌ 写法 B:捕 this 裸指针 → 回调时对象可能已死(UAF)
async_run([this]() { this->refresh(); });
// ✅ 写法 C:捕 weak_ptr → 安全:对象死了回调直接退出
async_run([wp = std::weak_ptr<Widget>(widget_)]() {
if (auto p = wp.lock()) p->refresh();
});
2
3
4
5
6
7
8
9
10
这就是异步框架(folly、boost.asio、Qt 信号槽)规范化的"weak 回调"模式。
# 6.6 weak 的内存代价
weak_ptr 不延长对象的生命,但延长控制块的生命:
auto sp = std::make_shared<Widget>(); // 控制块 + Widget 合并分配
std::weak_ptr<Widget> wp = sp;
sp.reset();
// 此刻:Widget 析构了(调了 dtor),但控制块还在
// 因为 wp 还在引用控制块(weak_count > 0)
// 而 make_shared 把对象内存和控制块绑在一起 → 整块都还没归还
// wp 也 reset 后:weak_count 归零,整块释放
2
3
4
5
6
7
8
坑点:make_shared + 长期 weak_ptr → 大对象内存被卡住。
// 反模式:100MB 缓冲区被 weak 卡住
auto buf = std::make_shared<HugeBuffer>(100 * 1024 * 1024); // 100MB
std::weak_ptr<HugeBuffer> long_lived_wp = buf;
buf.reset();
// HugeBuffer 析构了(释放它管理的资源),但这 100MB 还占着
// 直到 long_lived_wp 也 reset
2
3
4
5
6
解法:大对象不用 make_shared,用 shared_ptr<T>(new T) 让控制块和对象分离分配——这样对象释放就是真释放。
# 7. make 系列与构造
std::make_shared / std::make_unique 是 Effective Modern C++ 反复强调的"Item 21"——永远优先用 make 系列,几乎不要直接用 new。本章把"为什么"讲透。
# 7.1 为何用 make
三个理由,按重要性排序:
理由 1:异常安全——见 7.2 节详述。
理由 2:单次分配优化(仅 make_shared):
// 直接 new + shared_ptr:两次堆分配
std::shared_ptr<Widget> a(new Widget(args...));
// ↑ ↑
// ↑ 分配 1:Widget 对象
// 分配 2:控制块(构造 shared_ptr 时)
// make_shared:一次堆分配
auto b = std::make_shared<Widget>(args...);
// ↑
// 分配:控制块 + Widget 合并
2
3
4
5
6
7
8
9
10
理由 3:代码更简洁、更不容易写错:
// 写法 A:要写两次类型
std::shared_ptr<Widget> a(new Widget(arg1, arg2));
// 写法 B:类型只写一次,编译器推导
auto b = std::make_shared<Widget>(arg1, arg2);
2
3
4
5
# 7.2 异常安全证明
最经典的反例——下面这行代码可能内存泄漏:
void foo(std::shared_ptr<Widget> w, int prio);
int compute_prio();
// ❌ 不安全
foo(std::shared_ptr<Widget>(new Widget), compute_prio());
2
3
4
5
为什么?编译器对参数的求值顺序有自由度(C++17 前),可能:
1. new Widget // 分配 Widget
2. compute_prio() // 计算优先级
3. shared_ptr 构造 // 构造 shared_ptr 接管 Widget
2
3
如果第 2 步抛异常——第 1 步分配的 Widget 永远没有被 shared_ptr 接管——内存泄漏。
用 make_shared 修复:
foo(std::make_shared<Widget>(), compute_prio());
// ↑
// make_shared 内部"分配 + 构造 shared_ptr"是不可分割的——任何顺序异常都不漏
2
3
C++17 起求值顺序有所收紧,但最佳实践仍然是用 make——compute_prio() 在 make_shared 之外抛仍然安全。
总结:
| 场景 | 旧写法 | 是否安全 |
|---|---|---|
foo(shared_ptr<W>(new W), g()) | C++14 之前 | ❌ 可能泄漏 |
foo(make_shared<W>(), g()) | 任何标准 | ✅ 永远安全 |
# 7.3 单次分配优势
make_shared 的内存布局:
分离分配(new + shared_ptr):
┌──────────────┐ ┌──────────────┐
│ 控制块 (32B) │ ──────► │ Widget (48B) │
└──────────────┘ └──────────────┘
两次 malloc,每次都有 16B+ 元数据开销
总开销约 96-160 字节
合并分配(make_shared):
┌────────────────────────────────────┐
│ 控制块 + Widget (80B+元数据) │
└────────────────────────────────────┘
一次 malloc
总开销约 80-96 字节
2
3
4
5
6
7
8
9
10
11
12
13
性能优势(实测):
对象大小 < 64B、构造频繁的场景:
make_shared 比 new + shared_ptr 快 30-50%
主要来自少一次 malloc + 命中同一 cache line
对象大小 > 1KB、不频繁构造:
差异 < 10%(malloc 开销占比小)
2
3
4
5
6
# 7.4 make 的局限
make_shared 的两个局限:
局限 1:不能用自定义删除器
// ❌ make_shared 不接受 deleter
auto p = std::make_shared<Widget>(args, custom_deleter); // 编译错
// ✅ 需要时用 shared_ptr ctor
std::shared_ptr<Widget> p(new Widget(args), custom_deleter);
2
3
4
5
局限 2:weak 长期持有时浪费内存——见 6.6 节。
make_unique 的局限:
// C++14 才有 make_unique
auto p = std::make_unique<Widget>(args); // C++14+
// ⚠️ 不能传 deleter
auto bad = std::make_unique<Widget, MyDeleter>(args); // 编译错
// 需要 deleter 时
std::unique_ptr<Widget, MyDeleter> p(new Widget(args), MyDeleter{});
2
3
4
5
6
7
8
# 7.5 C++20 新增 API
C++20 增加了几个值得记的工具:
// 1. make_unique_for_overwrite:分配但不初始化(性能)
auto p = std::make_unique_for_overwrite<int[]>(1000);
// 1000 个 int,不初始化,立刻被覆写时省掉初始化开销
// 同理 make_shared_for_overwrite
// 2. allocate_shared 用自定义分配器
auto sp = std::allocate_shared<Widget>(my_allocator, args);
// 控制块和 Widget 都从 my_allocator 分配——可用于内存池
// 3. shared_ptr 数组支持
auto arr = std::make_shared<int[]>(100); // C++20+
// C++17 之前必须 std::shared_ptr<int[]>(new int[100], std::default_delete<int[]>())
// 4. weak 的 owner_hash(C++26 提案):用于哈希表 key
2
3
4
5
6
7
8
9
10
11
12
13
14
# 8. 自定义删除器
智能指针管的不只是 new/delete——它能管任何成对操作:fopen/fclose、malloc/free、socket/close、mutex_lock/unlock。这把 RAII 的应用面从"C++ 对象"扩展到"任何 C 资源"。
# 8.1 函数指针删除器
最简单的形式:传函数指针给 unique_ptr 的第二个模板参数:
extern "C" void free_widget(Widget*);
std::unique_ptr<Widget, decltype(&free_widget)>
p(make_widget(), &free_widget);
// 出作用域:调用 free_widget(p.get())
2
3
4
5
坑点:decltype(&free_widget) 是函数指针类型——unique_ptr 大小变 16 字节(裸指针 8 + deleter 函数指针 8)。
sizeof(std::unique_ptr<Widget>); // 8
sizeof(std::unique_ptr<Widget, decltype(&free_widget)>); // 16
2
# 8.2 lambda 删除器
更现代的写法:用 lambda:
auto deleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(deleter)> fp(fopen("a.txt", "r"), deleter);
2
lambda 的好处:
- 无捕获的 lambda 是空类,可以被 EBO 优化掉——
sizeof仍是 8 字节; - 编译器更容易内联——比函数指针快。
sizeof(std::unique_ptr<FILE, decltype(deleter)>); // 8(无捕获 lambda)
有捕获的 lambda 不能 EBO:
int log_id = 42;
auto deleter = [log_id](FILE* f) { log("close", log_id); fclose(f); };
sizeof(std::unique_ptr<FILE, decltype(deleter)>); // 16+
2
3
# 8.3 RAII 包装 C 句柄
经典模式:用 unique_ptr 包 POSIX/Win32 句柄:
// 1) 文件
struct FCloser { void operator()(FILE* f) const { if (f) fclose(f); } };
using FilePtr = std::unique_ptr<FILE, FCloser>;
FilePtr fp(fopen("data.txt", "r"));
if (!fp) return; // 文件打不开
// 任何路径退出函数都会自动 fclose
// 2) socket fd
struct FdCloser {
void operator()(int* fd) const {
if (fd && *fd >= 0) ::close(*fd);
delete fd;
}
};
using FdPtr = std::unique_ptr<int, FdCloser>;
FdPtr sock(new int(::socket(AF_INET, SOCK_STREAM, 0)));
// 3) 系统句柄
struct SemCloser { void operator()(sem_t* s) const { sem_destroy(s); delete s; } };
using SemPtr = std::unique_ptr<sem_t, SemCloser>;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int* 是因为 unique_ptr<int> 必须存指针——不能直接 unique_ptr<int> 装 fd。如果想避免堆分配,写专门的 RAII 类:
class Fd {
int fd_ = -1;
public:
explicit Fd(int fd) : fd_(fd) {}
~Fd() { if (fd_ >= 0) ::close(fd_); }
Fd(Fd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
Fd& operator=(Fd&& o) noexcept { reset(); fd_ = std::exchange(o.fd_, -1); return *this; }
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
int get() const noexcept { return fd_; }
int release() noexcept { return std::exchange(fd_, -1); }
void reset() noexcept { if (fd_ >= 0) ::close(fd_); fd_ = -1; }
};
2
3
4
5
6
7
8
9
10
11
12
13
# 8.4 类型擦除与开销
shared_ptr 的 deleter 是类型擦除的——存在控制块里,不影响 shared_ptr 本身的 sizeof:
sizeof(std::shared_ptr<Widget>); // 16
sizeof(std::shared_ptr<Widget>); // 用 lambda deleter // 仍 16
// deleter 存在堆上的控制块里,不影响 shared_ptr 大小
2
3
对比 unique_ptr:
| 项目 | unique_ptr | shared_ptr |
|---|---|---|
| 默认 deleter | 8 字节 | 16 字节 |
| 函数指针 deleter | 16 字节 | 16 字节(不变) |
| 大状态 lambda | 24+ 字节 | 16 字节(不变) |
| 类型擦除? | ❌ deleter 在类型里 | ✅ 在控制块里 |
含义:
- 把不同 deleter 的
unique_ptr放进同一个 vector?做不到——类型不一样; - 把不同 deleter 的
shared_ptr放进同一个 vector?可以——deleter 都是擦除的。
# 8.5 常见 C 库封装
直接给出生产级写法:
// libcurl
struct CurlCloser { void operator()(CURL* c) const { curl_easy_cleanup(c); } };
using CurlPtr = std::unique_ptr<CURL, CurlCloser>;
auto curl = CurlPtr(curl_easy_init());
// SDL/SDL2
struct SDLWindowCloser { void operator()(SDL_Window* w) const { SDL_DestroyWindow(w); } };
using SDLWindowPtr = std::unique_ptr<SDL_Window, SDLWindowCloser>;
// OpenSSL
struct EVPMDCloser { void operator()(EVP_MD_CTX* m) const { EVP_MD_CTX_free(m); } };
using EVPMDPtr = std::unique_ptr<EVP_MD_CTX, EVPMDCloser>;
// FFmpeg
struct AVFormatCloser {
void operator()(AVFormatContext* c) const {
if (c) avformat_close_input(&c);
}
};
using AVFormatPtr = std::unique_ptr<AVFormatContext, AVFormatCloser>;
// sqlite3
struct SqliteCloser { void operator()(sqlite3* db) const { sqlite3_close(db); } };
using SqlitePtr = std::unique_ptr<sqlite3, SqliteCloser>;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
统一模板——3 行代码包任何 C API:
template <auto Fn> // C++17 auto template parameter
struct CDeleter {
template <typename T>
void operator()(T* p) const { Fn(p); }
};
using FilePtr = std::unique_ptr<FILE, CDeleter<&fclose>>;
using CurlPtr = std::unique_ptr<CURL, CDeleter<&curl_easy_cleanup>>;
using SqlitePtr = std::unique_ptr<sqlite3, CDeleter<&sqlite3_close>>;
2
3
4
5
6
7
8
9
# 9. 多线程安全模型
shared_ptr 是 C++ 标准库里**唯一一个明确承诺"控制块跨线程安全"**的类型。但承诺到底覆盖什么、不覆盖什么,是工程上踩坑最多的地方。
# 9.1 引用计数原子
承诺范围:控制块的引用计数操作是线程安全的。
std::shared_ptr<Widget> sp = std::make_shared<Widget>();
// 线程 A 拷贝
std::thread t1([&]{ auto local = sp; local->use(); }); // ✅ 安全
// 线程 B 拷贝
std::thread t2([&]{ auto local = sp; local->use(); }); // ✅ 安全
// 多个线程同时析构副本
std::thread t3([&]{ auto local = sp; }); // ✅ 安全,原子减计数
2
3
4
5
6
7
8
9
10
原理:use_count 和 weak_count 都是 std::atomic<long>——加减都是原子的。
# 9.2 对象本身不安全
承诺不覆盖:指向的对象本身的访问不是线程安全的。
auto sp = std::make_shared<std::vector<int>>();
// ❌ 经典错误
std::thread t1([&]{ sp->push_back(1); }); // 修改 vector
std::thread t2([&]{ sp->push_back(2); }); // 同时修改 → 数据竞争
// 类比:两个人各拿了一份钥匙(shared_ptr),但门后面(对象)只有一份
// 钥匙的复制是安全的,进门的同步要自己加锁
2
3
4
5
6
7
8
正确写法:对象本身用锁保护:
auto sp = std::make_shared<std::vector<int>>();
std::mutex mu;
std::thread t1([&]{ std::lock_guard l(mu); sp->push_back(1); });
std::thread t2([&]{ std::lock_guard l(mu); sp->push_back(2); });
2
3
4
5
# 9.3 atomic_shared_ptr
第二个承诺:同一个 shared_ptr 对象,并发读写不安全——但有专门 API 处理:
std::shared_ptr<Widget> sp;
// ❌ 反模式:并发读写同一个 shared_ptr 变量
std::thread t1([&]{ sp = std::make_shared<Widget>(...); }); // 写
std::thread t2([&]{ auto local = sp; }); // 读 → race
// ✅ 用 std::atomic_load / atomic_store(C++11)
std::thread t1([&]{ std::atomic_store(&sp, std::make_shared<Widget>(...)); });
std::thread t2([&]{ auto local = std::atomic_load(&sp); });
// ✅ C++20:atomic<shared_ptr<T>>
std::atomic<std::shared_ptr<Widget>> asp;
std::thread t1([&]{ asp.store(std::make_shared<Widget>(...)); });
std::thread t2([&]{ auto local = asp.load(); });
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么需要这个? shared_ptr 拷贝是"两步"——读 ptr_、原子加 use_count。中间另一个线程可能把 sp 改了,导致读到旧 ptr_、新 ctrl_,UAF。
# 9.4 拷贝即原子读
实践中最常用的多线程模式:通过拷贝避开并发读写:
class WidgetService {
std::shared_ptr<const Widget> current_; // 不可变对象
std::mutex mu_;
public:
// 写者:原子替换整个对象
void update(Widget new_w) {
auto sp = std::make_shared<const Widget>(std::move(new_w));
std::lock_guard l(mu_);
current_ = sp;
}
// 读者:拿一份快照
std::shared_ptr<const Widget> snapshot() const {
std::lock_guard l(mu_);
return current_; // 拷贝增计数
}
};
// 使用
auto sp = service.snapshot();
sp->method(); // 用快照,无锁、永远有效
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这就是 RCU 风格的"读侧无锁"模式——读极多写极少时是黄金方案。
# 9.5 性能与伪共享
shared_ptr 的最大性能陷阱:高并发下计数器成为热点。
// 反模式:单个 shared_ptr 被几十个线程频繁拷贝
auto global = std::make_shared<BigConfig>();
void worker() {
while (running) {
auto local = global; // ⚠️ 每次都原子加 + 减
process(local);
}
}
// 64 线程上的实测:
// QPS ~ 200K(瓶颈在 cache line 弹来弹去)
// 改成 thread_local 缓存:QPS ~ 50M(提升 250x)
2
3
4
5
6
7
8
9
10
11
12
13
优化方案:
// 方案 1:thread_local 缓存(最好的)
void worker() {
thread_local std::shared_ptr<BigConfig> cached;
if (!cached) cached = global_atomic.load();
process(cached);
}
// 方案 2:传引用而不是拷贝
void process(const std::shared_ptr<BigConfig>& sp); // 调用方不再拷贝
// 方案 3:只在必要时升级
void process(const BigConfig& cfg); // 完全避开 shared_ptr
auto sp = global.load();
process(*sp); // 拷贝一次后用引用
2
3
4
5
6
7
8
9
10
11
12
13
14
何时该用 atomic_shared_ptr / 何时不该:
| 场景 | 推荐 |
|---|---|
| 只一个线程持有,跨线程传 | 普通 shared_ptr + move |
| 多线程读、单线程写、读极多 | shared_ptr + std::mutex(拷贝快照) |
| 多线程并发更新指针本身 | atomic<shared_ptr> |
| 高频拷贝同一个 shared_ptr | thread_local 缓存 |
| 性能敏感、对象不可变 | 直接传 const T&,shared_ptr 只在外层 |
# 10. 五步选型方法论
把第 1-9 章的零散经验抽象成可复用的选型流程:
┌─────────────────────────────────────────┐
│ 1. 问所有权归属 │
│ 谁负责销毁这个对象? │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. 问生命周期 │
│ 会形成循环引用吗?观察者怎么订阅? │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3. 问性能预算 │
│ 每秒拷贝多少次?跨核多少? │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 4. 问跨边界传递 │
│ 跨函数/线程/模块/语言? │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 5. 问异常与并发 │
│ 异常路径怎么释放?多线程读写吗? │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 10.1 问所有权归属
所有权问题决定 80% 的选型。先回答:"这个对象,谁负责销毁?"
| 答案 | 推荐 |
|---|---|
| 一个明确的拥有者,作用域结束就销毁 | unique_ptr |
| 多个独立的拥有者,最后一个走的关灯 | shared_ptr |
| 没有专门的拥有者,整个程序生命周期 | 全局变量 / 单例(用引用传) |
| 调用方拥有,函数只是借用 | T& / const T& / T* |
| 函数内拥有,结束就丢 | 栈上值类型 T |
反复问自己:
- 这个对象的"主人"是谁?写下来;
- 主人死了,对象还活着吗?应该吗?
- 谁可能想"延长它的命"?为什么?
主线一的诊断:Connection 不应该是 Session 的"主人"——Session 是被 sessions_ 表管理的,Connection 只是它的子组件。所以 Connection 应该用 weak。
# 10.2 问生命周期
生命周期问题决定循环引用与回调安全。
| 模式 | 风险 | 解法 |
|---|---|---|
| A 持 B、B 持 A 的 shared | 循环不释放 | B 持 A 用 weak |
| 异步 callback 持 this | 回调时 this 已死 | 捕 weak,lock 后再用 |
| 观察者订阅事件源 | 订阅者反向延寿事件源 | 观察者持 weak |
| 缓存条目跨线程访问 | 一线程释放、另一线程仍在用 | shared_ptr,最后一个走的释放 |
主线一的修法(生命周期视角):
- Session 由 sessions_ 主动管理 → sessions_ 持 shared、Session 是"主人";
- Connection 是 Session 的子组件 → Session 持 shared
; - Connection 反向找 Session 是为了路由消息 → Connection 持 weak
。
// 修复后
struct Session {
int id;
std::shared_ptr<Connection> conn; // 持 shared
std::vector<std::function<void(int)>> callbacks; // 注意:lambda 也只捕 weak
};
struct Connection {
int fd;
std::weak_ptr<Session> session; // ✅ weak 破环
void on_data(const char* buf, size_t n) {
if (auto s = session.lock()) {
s->process(buf, n);
}
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 10.3 问性能预算
shared_ptr 不是免费的——做选型时要量化。
性能预算决策表:
| 场景 | 拷贝频率 | 推荐 |
|---|---|---|
| 单核每秒 < 1 万次 | 低 | shared_ptr 任意用 |
| 单核每秒 1-10 万次 | 中 | shared_ptr 但避免跨核 |
| 单核每秒 > 100 万次 | 高 | 优先 unique_ptr / 引用 / thread_local 缓存 |
| 多核共享同一 sp、跨核拷贝 > 1 万/秒 | 高 | atomic_shared_ptr / RCU |
典型估算:
游戏渲染 60 FPS,每帧 1 万对象:
每秒 60 × 10000 = 60 万次
如果都用 shared_ptr 拷贝:~30M 原子操作/秒
→ 应该用 unique_ptr 或 raw pointer + 集中式所有权
HTTP 服务,QPS 10 万:
每个请求拷贝 5 个 shared_ptr:50 万次拷贝/秒
单核没问题,但跨核要小心
后台任务调度:
每秒 1 千任务:5K 拷贝/秒
shared_ptr 完全无压力
2
3
4
5
6
7
8
9
10
11
12
# 10.4 问跨边界传递
跨边界 = 函数/线程/模块/语言/二进制。每跨一道边界,所有权语义就要重新表达一次。
函数边界:
// sink:传所有权
void take(std::unique_ptr<Widget> w);
// borrow:借用
void use(const Widget& w);
void mut(Widget& w);
// share:参与所有权(罕见)
void hold(std::shared_ptr<Widget> w);
2
3
4
5
6
7
8
9
线程边界:
// 启动线程时传 shared_ptr:跨线程共享
auto sp = std::make_shared<Widget>();
std::thread t([sp]{ sp->use(); });
// 通过 channel 传 unique_ptr:转移所有权
channel.send(std::move(unique_widget));
2
3
4
5
6
模块边界(动态库):
// ❌ 跨 .so 边界传 shared_ptr 危险——两边可能用不同 std lib
extern "C" std::shared_ptr<Widget> get_widget();
// ✅ 跨 .so 用 C 风格 + 显式释放函数
extern "C" Widget* create_widget();
extern "C" void destroy_widget(Widget*);
extern "C" const char* widget_name(Widget*);
2
3
4
5
6
7
语言边界(C/C++ → Python/Java/Go):
// 暴露给其他语言:用 opaque handle + 显式 destroy
extern "C" {
typedef struct WidgetHandle WidgetHandle;
WidgetHandle* widget_create();
void widget_destroy(WidgetHandle*);
int widget_method(WidgetHandle*, int arg);
}
2
3
4
5
6
7
# 10.5 问异常与并发
异常路径:
// ❌ 异常不安全
void f() {
Widget* w = new Widget;
risky(); // 抛异常 → w 泄漏
delete w;
}
// ✅ unique_ptr 自动释放
void f() {
auto w = std::make_unique<Widget>();
risky(); // 抛异常 → w 析构 → Widget 自动 delete
}
2
3
4
5
6
7
8
9
10
11
12
并发模式:
// 单线程独占 → unique_ptr
std::unique_ptr<Widget> p = ...;
// 跨线程共享读 → shared_ptr
std::shared_ptr<const Widget> p = std::make_shared<const Widget>(...);
// 跨线程读写、低频 → shared_ptr + mutex
struct State { std::shared_ptr<Widget> w; std::mutex mu; };
// 跨线程频繁原子替换 → atomic<shared_ptr>
std::atomic<std::shared_ptr<Widget>> p;
2
3
4
5
6
7
8
9
10
11
心法五条:
- 先回答归属问题,再选指针类型:理解了所有权才能写正确的选择;
- 默认 unique,多源才 shared:99% 场景都是 unique,shared 是例外;
- 看到 shared 持 shared 立刻警惕:循环引用的种子;
- API 边界用引用 + 值,不传所有权时不传智能指针:解耦调用方的所有权策略;
- 多线程下区分"控制块安全"和"对象安全":前者免费,后者要锁。
# 11. 典型场景速查
把第 1-10 章的方法论,落到 7 个最高频的场景。
# 11.1 工厂返回对象
场景:函数创建对象、把所有权交给调用方。
// ❌ 反模式:返回裸指针,调用方不知道要不要 delete
Widget* create_widget() { return new Widget; }
// ❌ 反模式:返回 shared_ptr,但其实只有一个所有者
std::shared_ptr<Widget> create_widget() { return std::make_shared<Widget>(); }
// ✅ 标准答案:返回 unique_ptr
std::unique_ptr<Widget> create_widget() {
return std::make_unique<Widget>();
}
// 调用方
auto w = create_widget(); // unique_ptr<Widget>
auto sp = std::shared_ptr(create_widget()); // 转 shared 也容易
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么 unique 优于 shared? unique 可以"零成本"转 shared(move 即可),但 shared 转回 unique 几乎不可能(要"独占"才行)。接口给最严格的、调用方按需放宽——这是最佳实践。
# 11.2 PIMPL 隐藏实现
场景:在头文件里隐藏类的内部实现,加快编译、稳定 ABI。
// widget.h
class Widget {
public:
Widget();
~Widget();
void method();
private:
class Impl; // 前向声明
std::unique_ptr<Impl> impl_; // 唯一所有者
};
// widget.cpp
class Widget::Impl {
int data;
void real_method() { ... }
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // ⚠️ 必须放 .cpp,否则编译错
void Widget::method() { impl_->real_method(); }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
坑点:~Widget() 必须定义在 .cpp 里——unique_ptr<Impl> 析构需要看到 Impl 的完整定义,但 .h 里 Impl 是 incomplete type。
用 unique_ptr 还是 shared_ptr?
- 99% 用
unique_ptr——PIMPL 的语义就是"Widget 唯一拥有 Impl"; - 极少数情况(多个 Widget 共享同一 Impl)才用
shared_ptr。
# 11.3 容器持有多态
场景:vector 装一组多态对象。
class Animal { public: virtual ~Animal() = default; virtual void cry() = 0; };
class Dog : public Animal { public: void cry() override { std::cout << "汪"; } };
class Cat : public Animal { public: void cry() override { std::cout << "喵"; } };
// ❌ 反模式:vector<Animal> 切片
std::vector<Animal> v;
v.push_back(Dog{}); // ❌ Dog 部分被切掉,只剩 Animal
// ✅ vector<unique_ptr> 多态
std::vector<std::unique_ptr<Animal>> v;
v.push_back(std::make_unique<Dog>());
v.push_back(std::make_unique<Cat>());
for (const auto& a : v) a->cry();
2
3
4
5
6
7
8
9
10
11
12
13
几乎不要写 vector<shared_ptr<T>>——除非真的需要"vector 元素的多个拥有者"。常见场景下 unique 完全够用。
# 11.4 树与父子指针
场景:树状结构,节点有 children 和 parent。
struct Node {
std::vector<std::unique_ptr<Node>> children; // 父持子:unique
Node* parent; // 子持父:裸指针(借用)
void add_child(std::unique_ptr<Node> c) {
c->parent = this;
children.push_back(std::move(c));
}
};
2
3
4
5
6
7
8
9
为什么父持子用 unique? 子节点的所有者就是父节点,唯一。删父就删整棵子树——unique 自动级联析构。
为什么子持父用裸指针? 父的生命周期严格包含子——子还活着时父一定活着,所以裸指针绝不会悬挂。只要这个不变量成立,裸指针就是安全的。
反模式:
// ❌ shared 持 shared 形成环
struct Node {
std::vector<std::shared_ptr<Node>> children;
std::shared_ptr<Node> parent; // ⚠️ 循环!
};
2
3
4
5
修法:父持子 shared、子持父 weak:
struct Node {
std::vector<std::shared_ptr<Node>> children;
std::weak_ptr<Node> parent; // ✅
};
2
3
4
# 11.5 观察者订阅模式
场景:发布-订阅模型,subscriber 订阅 publisher 的事件。
// ❌ 反模式:subscriber 持 shared<Publisher> → 订阅者反向延寿发布者
struct Subscriber {
std::shared_ptr<Publisher> pub;
~Subscriber() { pub->unsubscribe(this); }
};
// ✅ 标准模式:subscriber 持 weak<Publisher>
struct Subscriber {
std::weak_ptr<Publisher> pub;
void on_event(int x) {
if (auto p = pub.lock()) {
// 用 p
}
}
};
// publisher 持 weak<Subscriber>,发事件时 lock 升级
struct Publisher {
std::vector<std::weak_ptr<Subscriber>> subs;
void notify(int x) {
for (auto& w : subs) {
if (auto s = w.lock()) s->on_event(x);
// else: 订阅者已死,忽略
}
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
关键:双向用 weak,避免相互延寿。
# 11.6 异步回调安全
场景:定时器/IO/线程池回调时,原对象可能已死。
class Service : public std::enable_shared_from_this<Service> {
public:
void schedule_refresh() {
auto wp = std::weak_ptr<Service>(shared_from_this());
timer_.add(1000, [wp]() {
if (auto self = wp.lock()) { // 检查 + 升级
self->do_refresh();
}
// else: Service 已死,timer 回调安静退出
});
}
};
// 使用前提
auto svc = std::make_shared<Service>(); // 必须 shared 持有
svc->schedule_refresh();
// 即使 svc.reset(),timer 回调也不会 UAF
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
通用模板:异步任务的 lambda 永远捕 weak,回调入口 lock 升级。
# 11.7 单例与生命周期
场景:全局唯一对象,生命周期 = 程序生命周期。
// ❌ 反模式 1:static unique_ptr 全局
std::unique_ptr<DB> g_db; // 析构顺序不可控(static destruction order fiasco)
// ❌ 反模式 2:泄漏式单例
static DB* g_db = new DB; // 永不释放,valgrind 报泄漏
// ✅ Meyer's Singleton:函数内 static
DB& get_db() {
static DB instance; // C++11 起线程安全
return instance;
}
// ✅ 需要管理生命周期时:shared_ptr + 双重检查
std::shared_ptr<DB> get_db() {
static std::shared_ptr<DB> instance = std::make_shared<DB>();
return instance;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
永远不要返回单例的 unique_ptr——单例的语义就是"全局共享",和 unique 互斥。
# 12. 工程化最佳实践
把零散经验拼成体系。
# 12.1 默认 unique 原则
第一原则:默认所有"堆上对象"都用 unique_ptr,要 shared 时给出明确理由。
// 工厂函数 → unique
auto create() -> std::unique_ptr<Widget>;
// PIMPL → unique
class Foo { std::unique_ptr<Impl> impl_; };
// 容器多态 → unique
std::vector<std::unique_ptr<Animal>> animals;
// 函数局部资源 → unique
auto buf = std::make_unique<char[]>(8192);
2
3
4
5
6
7
8
9
10
11
只在以下场景用 shared:
- 真正多源持有(缓存 + 观察者同时引用);
- 异步任务持有结果(任务结束才释放);
- 引用计数本身是业务逻辑(如游戏的对象池);
- 跨线程不可变快照(RCU 模式)。
所有其他场景默认 unique——绝大多数情况你不需要 shared。
# 12.2 shared 是设计气味
看到 shared 滥用,往往意味着设计有问题。常见症状:
| 症状 | 可能的设计问题 |
|---|---|
类内成员都是 shared_ptr<T> | 没想清楚谁拥有谁 |
函数到处传 shared_ptr<T> | 应该传引用或裸指针借用 |
| 形成循环引用 | 双向都用 shared,应该一边 weak |
enable_shared_from_this 到处都是 | 类对外暴露了"让自己 shared"的需求,可能耦合过深 |
shared_ptr<vector<T>> | 把容器整个 shared 了,多半应该 shared 个 const 快照 |
| 全局到处 atomic<shared_ptr> | 应该重构成"不可变快照 + 替换"模式 |
治理流程:
- 在 code review 中统计每个类的 shared 数量;
- 多于 3 个的,要求作者写"为什么不能用 unique"的论证;
- 反复滥用的,进行架构层面的所有权重新设计。
# 12.3 API 边界规范
接口设计的所有权语义清单:
// 1. 借用:用引用
void render(const Widget& w);
void mutate(Widget& w);
// 2. 接管:用 unique_ptr
void take(std::unique_ptr<Widget> w);
// 3. 共享:用 shared_ptr(罕见)
void cache(std::shared_ptr<const Widget> w);
// 4. 弱引用:用 weak_ptr
void subscribe(std::weak_ptr<Listener> l);
// 5. 工厂:返回 unique_ptr
std::unique_ptr<Widget> make_widget();
// 6. 跨语言/跨 .so:用 opaque handle
extern "C" Widget* widget_create();
extern "C" void widget_destroy(Widget*);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
禁止的写法:
// ❌ shared_ptr 作为参数传值(除非真要接管所有权)
void process(std::shared_ptr<Widget> w); // 调用方被迫拷贝
// ❌ 返回 shared_ptr 但语义模糊
std::shared_ptr<Widget> get_widget(); // 调用方拿到不知该不该 reset
// ❌ const shared_ptr&(看似省了拷贝,但增加耦合)
void process(const std::shared_ptr<Widget>& w); // 改成 const Widget& 更好
2
3
4
5
6
7
8
# 12.4 lint 与静态检查
把规则写进 CI:
# clang-tidy 相关规则
clang-tidy --checks='
cppcoreguidelines-owning-memory,
cppcoreguidelines-no-malloc,
modernize-make-unique,
modernize-make-shared,
bugprone-use-after-move,
bugprone-shared-ptr-array-mismatch,
cert-mem57-cpp,
misc-no-recursion'
2
3
4
5
6
7
8
9
10
关键检查项:
modernize-make-unique/modernize-make-shared:警告裸new+unique_ptr/shared_ptr构造;bugprone-use-after-move:警告std::move(p)后又用p;cppcoreguidelines-owning-memory:用gsl::owner<T*>标记拥有所有权的裸指针;- 自定义规则:扫描
std::shared_ptr<T>(this)、循环引用模式。
# 12.5 团队规范五条
写进团队 wiki,code review 强制执行:
- 任何堆对象创建用
make_unique/make_shared——禁止裸new+ 智能指针构造(除非要 deleter)。 - API 边界不传所有权时用引用/裸指针——
f(const Widget&)优于f(shared_ptr<Widget>)。 - 不允许
shared_ptr<T>(this)——必须用enable_shared_from_this。 shared_ptr持有强类型对象时,反向引用必须用weak_ptr——禁止双向强引用。- CI 必跑 ASan + leak detector——任何泄漏立即 fail。
# 12.6 成熟度模型
| 阶段 | 能力 | 典型团队 |
|---|---|---|
| Level 1 | 知道有智能指针,但还在用 new/delete | 初级团队 |
| Level 2 | 全员用 make_shared/make_unique | 有 review 文化 |
| Level 3 | 区分所有权语义,API 边界规范 | 中级团队 |
| Level 4 | 系统性识别循环引用,weak 用得熟 | 中后台/平台 |
| Level 5 | CI 强制 lint + ASan,shared 滥用治理 | 基础设施 |
绝大多数团队卡在 Level 2-3。升到 Level 4-5 的关键是把"所有权"作为一个独立的设计维度——而不是写代码时随手挑一个智能指针。
# 13. 综合案例串讲
回到第 1 章列出的两条主线,把整本指南的知识点串起来给出最终答案。
# 13.1 真相揭晓
# 主线一:网关 RSS 每天爬 200MB
疑问回顾(第 1 章列出的 5 个问题):
- RSS 每天爬升 200MB,但
valgrind --leak-check=full报"definitely lost: 0 bytes"——为什么? pmap看堆区一直在涨,但每个 Session 析构日志都打印了——析构到底有没有跑?- heaptrack 报告里
Session::Session是分配大头,但回收路径上没看到对应的 free——free 哪去了? - 对端断开后
Connection::on_close()明明 reset 了session_,为什么 Session 还活着? - 把
session_改成weak_ptr<Session>后,泄漏停了——但偶发出现 use-after-free——又怎么回事?
根因:Session ↔ Connection 双向 shared_ptr 形成强引用环,析构永不触发。
Session ──shared_ptr──> Connection
↑ │
└─────shared_ptr───────────┘
2
3
九个疑问的精确解答:
| # | 疑问 | 真相 |
|---|---|---|
| 1 | valgrind 为什么报 0 lost? | 进程退出时强引用环上每个对象的 use_count 都是 1,但没有任何根指向它们。valgrind 的可达性算法把"任意有指针指向"的内存判为可达,强引用环里它们彼此可达,所以"未泄漏"。这是 valgrind 的盲点。 |
| 2 | 析构日志为啥还在打? | 那是老的、没成环的 Session。新成环的根本不会析构,自然不打日志。日志带 ID grep 一下就分清。 |
| 3 | free 哪去了? | ~Session 没跑就没 free。heaptrack 看的是"分配 vs 释放"差量,但它看不出环。 |
| 4 | reset 了为啥还活着? | on_close() reset 的是 Connection::session_,但 Session::conn_ 还指着 Connection,Connection 也还指着 Session(通过别的链路还可达)。单边 reset 解不开环。 |
| 5 | 改 weak 后 UAF? | 改 weak 后泄漏停了,但代码里有地方直接 weak.lock()->send()——如果 lock 返回空,解空指针就 UAF。weak 必须先判空再用。 |
修复方案(三层):
A. 立即止血(5 分钟):把 Session::conn_ 改为 weak_ptr<Connection>:
class Session {
std::weak_ptr<Connection> conn_; // 不持有,只观察
};
// 用的地方:
if (auto c = conn_.lock()) {
c->send(buf);
}
2
3
4
5
6
7
8
B. 设计加固(1 天):
- lambda 捕获
shared_from_this()时改捕weak_ptr,回调内 lock 后再用:
auto self_weak = weak_from_this();
io_->post([self_weak] {
if (auto self = self_weak.lock()) self->process();
});
2
3
4
用
enable_shared_from_this替换裸shared_ptr<This>(this),避免双控制块。所有"反向引用"(Child → Parent、Observer → Subject、Session → Connection)一律用 weak。
C. 系统兜底(1 周):
- CI 加 ASan +
-fsanitize=leak; - 加自定义 lint:
shared_ptr的成员变量出现在class B里,且class A也有shared_ptr<B>时,warning; - 监控埋点:
shared_ptr<Session>::use_count()在 reset 后 > 0 的次数; - 压测增加"建立连接 → 断开"循环用例 1M 次,观察 RSS 是否回落。
# 主线二:32 行 MCVE double-free
疑问回顾:
Widget*只new一次,怎么会被 delete 两次?- 两个
shared_ptr的use_count()都是 1——它们不是该共享吗? - 改用
make_shared后没崩——但只是运气,还是真的修好了? enable_shared_from_this为什么是必备的?
根因:同一裸指针被两个 shared_ptr 各自接管 → 创建了两个独立的控制块。
Widget* raw = new Widget;
std::shared_ptr<Widget> p1(raw); // 控制块 #1,use=1
std::shared_ptr<Widget> p2(raw); // 控制块 #2,use=1(不知道 #1 存在)
// p1 析构 → delete raw ← 第一次 delete
// p2 析构 → delete raw ← 第二次 delete = double-free,崩
2
3
4
5
四个疑问的精确解答:
| # | 疑问 | 真相 |
|---|---|---|
| 1 | 怎么 delete 两次? | 两个控制块各管各的,析构时各 delete 一次。 |
| 2 | use_count 都是 1? | use_count 属于控制块,两个独立控制块自然各自为 1。它们不知道彼此存在。 |
| 3 | make_shared 真修好了吗? | 真修好了。make_shared 把对象和控制块在同一块内存里——你拿不到独立的裸指针,也就没法搞出两个控制块。这是从机制上根除,不是运气。 |
| 4 | 为啥要 enable_shared_from_this? | 如果 class 里要在成员函数内"返回 shared_ptrshared_ptr<This>(this) 就是又造一个独立控制块——和主线二同构。shared_from_this() 内部用一个 weak_ptr 兜住已存在的控制块。 |
修复方案:
A. 立即修复:
// 错误
Widget* raw = new Widget;
auto p1 = std::shared_ptr<Widget>(raw);
auto p2 = std::shared_ptr<Widget>(raw);
// 正确
auto p1 = std::make_shared<Widget>();
auto p2 = p1; // 拷贝控制块指针,use_count 真的变成 2
2
3
4
5
6
7
8
B. 全局排查:grep shared_ptr< 后跟 (,再 grep (this)、(raw_ptr):
# 找出所有从裸指针构造 shared_ptr 的地方
grep -nE 'shared_ptr<[^>]+>\s*\([a-zA-Z_]' src/ | grep -v make_shared
# 找出 shared_ptr<This>(this)
grep -nE 'shared_ptr<[^>]+>\s*\(this\)' src/
2
3
4
C. 编码规范固化:第 12.5 节五条军规上线 + lint 强制。
# 13.2 一次泄漏的一生
把主线一的整个生命周期画成 ASCII 知识树:
[main]
│
│ make_shared<Server>()
▼
┌──────────┐
│ Server │ shared_ptr (use=1)
│ acceptor │
└────┬─────┘
│ accept() 创建 Connection
▼
┌──────────────────┐
│ Connection │ shared_ptr (use=2)
│ socket │ ┌── Server.connections_ 持有
│ session_ │ └── handler 闭包持有
│ ... │
└────┬─────────────┘
│ make_shared<Session>(shared_from_this())
▼
┌──────────────────┐
│ Session │ shared_ptr (use=1)
│ id │
│ conn_ ◄─────┼── 这里设为 shared_ptr<Connection>
│ buffer │ = 致命错误 = 形成强环
└─────┬────────────┘
│ Connection.session_ = this_session
▼
┌────────────────────────────────────────┐
│ Connection.session_ ──> Session │
│ ▲ │
│ │ shared_ptr 双向 │
│ ▼ │
│ Session.conn_ ──> Connection │
└────────────────────────────────────────┘
│
│ 对端 close → Server.connections_.erase(conn)
▼
┌────────────────────────────────────────┐
│ Connection use_count: 2 → 1(仅剩 Session 持有)│
│ Session use_count: 1(仅 Connection 持有) │
│ ─ 没有任何根指向它们 │
│ ─ 但它们彼此持有 │
│ ─ use_count 永远不会到 0 │
│ ─ 析构永不触发 → 泄漏 │
└────────────────────────────────────────┘
│
│ valgrind 进程退出扫描
▼
┌────────────────────────────────────────┐
│ valgrind: "0 bytes definitely lost" │
│ 原因:环里彼此可达,被判为"reachable" │
│ ─ 这是经典盲点 │
└────────────────────────────────────────┘
│
│ 修复:Session.conn_ 改 weak_ptr
▼
┌────────────────────────────────────────┐
│ Connection.session_ ──> Session │
│ ▲ │
│ │ weak(不计数) │
│ ▼ │
│ Session.conn_ - - > Connection │
│ │
│ Server.erase(conn) → Conn use_count=0 │
│ → ~Connection → 释放 session_ │
│ → Session use_count=0 → ~Session │
│ → 链断、内存归还 │
└────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
关键认知:
- 泄漏不是"忘记 delete",而是"循环引用 + 没有 weak 破环";
- valgrind 看不出来;只有 RSS 监控 + heaptrack 差量分析能看出来;
- 修复点不在崩溃位置,而在所有权设计的入口——决定"谁持有谁"的那一行。
# 13.3 设计哲学回扣
整理本篇四条跨篇适用的设计哲学:
哲学 1:所有权是设计,不是细节
挑 unique 还是 shared 不是写代码时随手选的——它是架构决策。一个对象能被几个人持有,决定了模块的耦合度、生命周期、线程边界。先画所有权图,再写代码——这是和数据流图、UML 一样的设计前置动作。
哲学 2:默认 unique,shared 是设计气味
std::unique_ptr 是零开销、明确语义、易推理的"无悔选择"。std::shared_ptr 引入引用计数、原子操作、控制块、环险——它表达的是"这个对象的生命周期管不清楚"。当你忍不住要写 shared 时,先停下来问:能不能让某个模块独占?能不能用 weak 替代?shared 的滥用是 C++ 团队最常见的设计气味。
哲学 3:weak 是观察者的本分
如果你的对象不主动决定生命周期,只是观察/通知/回调——它就该用 weak。Session ↔ Connection、Observer ↔ Subject、Cache、异步回调、父子树的反向边——所有"我用它,但我不养它"的关系,都是 weak 的天然场景。忘记 weak 就是给系统埋循环引用的雷。
哲学 4:现场可追溯——make_shared、shared_from_this、ASan 三件套
C++ 不像 GC 语言能事后做引用图分析,所以现场必须留下来:
make_shared让控制块和对象绑定,杜绝双控制块(机制级保证);shared_from_this()让"this 的 shared_ptr"始终从原始控制块来,杜绝二次成环(机制级保证);- ASan + leak detector 让任何漏掉 weak 的环,CI 阶段就 fail(流程级保证)。
机制 + 工具 + 流程,三层兜底,才能在大规模团队里落地。
# 13.4 智能指针速查表
一张图保存以备查:
| 场景 | 选谁 | 关键点 | 第一刀 |
|---|---|---|---|
| 局部缓冲区 / 工厂返回 / PIMPL | unique_ptr | 零开销,移动 | make_unique |
| 多模块共享只读资源 | shared_ptr | 控制块 16-24B 开销 | make_shared |
| 观察者 / 父子反向边 / 缓存 | weak_ptr | 不计 use_count | lock() 后判空 |
| C 风格句柄(FILE*/fd/sqlite3*) | unique_ptr<T, Deleter> | EBO 不增 sizeof | 函数指针删除器 |
| this 返回 shared | enable_shared_from_this | 内置 weak | shared_from_this() |
| 数组动态长度 | unique_ptr<T[]> | 调 delete[] | C++17 起 make_unique<T[]> |
| 跨线程读写同一指针 | atomic<shared_ptr<T>> (C++20) | 真原子 | C++17 用 atomic_load/store |
| 单例 / 全局对象 | shared_ptr 配合静态局部 | Magic Static 线程安全 | static auto x = make_shared<T>() |
60 秒诊断命令清单:
# 1. 怀疑泄漏:先看 RSS 趋势
top -p <PID>
pmap -x <PID> | sort -k3 -n | tail
cat /proc/<PID>/status | grep -E "VmRSS|VmSize"
# 2. 跑 ASan + leak(开发/CI 阶段)
g++ -fsanitize=address,leak -fno-omit-frame-pointer -g -O1
ASAN_OPTIONS=detect_leaks=1 ./a.out
# 3. heaptrack 抓分配热点(生产可用)
heaptrack ./your_app
heaptrack_print heaptrack.your_app.<PID>.gz | less
# 4. 怀疑 double-free:开 MallocStackLogging
MallocStackLogging=1 ./your_app # macOS
ASAN_OPTIONS=halt_on_error=0 ./a.out # Linux ASan
# 5. 找循环引用:grep 双向 shared
grep -nE 'std::shared_ptr<[A-Z][a-zA-Z]*>' src/include/*.h | sort -u
# 用 dot/graphviz 画一下哪些类相互持有
# 6. 找裸指针构造 shared
grep -nE 'shared_ptr<[^>]+>\s*\([a-zA-Z_]' src/ | grep -v make_shared
grep -nE 'shared_ptr<[^>]+>\s*\(this\)' src/
# 7. 编译期强制规范
clang-tidy --checks='cppcoreguidelines-owning-memory,modernize-make-shared,modernize-make-unique'
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
三大智能指针对比:
unique_ptr shared_ptr weak_ptr
sizeof sizeof(T*) 2*sizeof(T*) 2*sizeof(T*)
开销 零 控制块 + 原子计数 控制块 + 原子弱计数
拷贝 禁止 允许(原子++) 允许(原子++ 弱)
移动 允许 允许 允许
所有权 独占 共享 不拥有,仅观察
线程 对象本身不安全 控制块原子,对象不安全 lock 时原子升级
make 工厂 make_unique make_shared weak_from_this
典型陷阱 无 循环引用 / 双控制块 use 后未判空 (UAF)
推荐度 ★★★★★ ★★★(用前思考) ★★★★(必学)
2
3
4
5
6
7
8
9
10
所有权决策树(再来一次,作为速查):
谁负责释放?
├── 唯一一个对象 ──> unique_ptr
├── 多个对象共享 ──> shared_ptr
│ └── 反向引用 / 观察 ──> weak_ptr
├── 容器(值语义 OK)──> 直接 vector<T>
└── 不释放 / 借用一次 ──> 引用 / 裸指针 / string_view
2
3
4
5
6
# 13.5 思考题
你在做一个 LRU 缓存:
map<Key, shared_ptr<Value>>,外部用户也持有shared_ptr<Value>。当 LRU 淘汰时直接erase,外部用户的shared_ptr还能用——这正确吗?如果你想"LRU 淘汰即立刻析构",应该怎么改设计?make_shared<HugeObj>()和shared_ptr<HugeObj>(new HugeObj)在内存布局上有什么差别?哪种情况下后者反而更优?(提示:weak_ptr 的生命周期)你的代码里有
shared_ptr<Base> p = std::make_shared<Derived>(...);——Base没有虚析构。会发生什么?怎么改?为什么unique_ptr在同一情况下不会出问题?(仔细想,unique_ptr也未必安全,但有一种情况它真的安全)enable_shared_from_this<T>的weak_this_是什么时候被赋值的?如果你在构造函数里调shared_from_this(),会发生什么?为什么?多线程场景:线程 A 持有
shared_ptr<X> p,线程 B 也持有同一个shared_ptr<X> p(通过共享变量访问)。A 在做p->method(),B 在做p.reset()。这是 race 吗?你需要什么保护?假设你设计一个
Node树(一棵真的树,不是图):parent->children[]、child->parent。把parent设成 weak、children设成 shared 是对的吗?如果某个 child 想"离开父亲、独立保留",怎么做?写一个
shared_ptr<FILE>的工厂函数,自动调fclose。和unique_ptr<FILE, decltype(&fclose)>比,前者多花了多少字节?多花在哪?std::weak_ptr::lock()在多线程下是 lock-free 的吗?它的实现需要什么硬件指令?为什么expired()后立刻lock()仍可能成功?你的项目要做"所有权审计":写一段静态分析(或 lint),自动找出所有"双向 shared_ptr 持有"的类对。怎么做?需要哪些信息?误报会出现在哪?
如果让你给团队做"智能指针 30 分钟培训"——只能讲三点,你会讲哪三点?为什么?(提示:不是讲 API,是讲"什么时候选什么"+"什么时候出问题")
内存不是被释放的,是被设计的。 所有权不是写代码时的选择,是架构师的决策。 智能指针只是工具——真正的智能在持指针的人脑子里。
下一篇:到此 01.信号崩溃 → 02.ASan内存 → 03.GDB速查 → 04.CoreDump → 05.perf火焰 → 06.迭代器失效 → 07.智能指针选型,C++ 工程排查七件套形成完整闭环:从"怎么从信号定位行号"到"怎么从所有权设计杜绝泄漏"。配套阅读:01.进程地址空间布局(理解栈、堆、共享内存的真实形状,所有权与释放都要落到这张图上)。