shared_ptr 线程安全的三层与四层:从 enable_shared_from_this 说起
“shared_ptr 线程安全吗”是一道面试高频题。模糊地答”安全”或”不安全”都是零分——正确答案是”哪一层安全”。我最近把这个问题从头到尾捋了一遍,从一个更基础的问题开始:enable_shared_from_this 的原理。
enable_shared_from_this 解决什么问题
场景很常见:一个对象的方法内部,需要拿到指向自身的 shared_ptr(比如把自己注册到回调、投递异步任务)。
最直觉的写法是 std::shared_ptr<T>(this)。这是错的,而且错得很致命。
shared_ptr<T>(this) 会为这个裸指针创建一个全新的控制块(Control Block)。如果外部已经有一个 shared_ptr 在管理这个对象,现在就有两个独立的控制块管理同一个裸指针——两个控制块各记各的计数,都会在计数归零时释放对象。结果就是析构时 Double Free,程序崩溃。
std::enable_shared_from_this 优雅地解决了这个问题,核心机制是三步:
-
内部持有一个
weak_ptr:enable_shared_from_this<T>是一个 CRTP 模板基类(奇异递归模板模式),内部维护一个私有的std::weak_ptr<T>(libstdc++ 里通常叫_M_weak_this)。 -
shared_ptr构造时初始化它:当你第一次在外部用shared_ptr接管这个对象(比如std::make_shared<MyClass>())时,shared_ptr的构造函数在编译期检测该类是否继承自enable_shared_from_this,如果是,就把自己刚创建的控制块同步赋值给基类内部的那个weak_ptr。 -
shared_from_this()就是weak_ptr::lock():类内部调用shared_from_this()时,实际执行的是内部weak_ptr的lock()操作——检查控制块,对象存活就返回一个共享原控制块的新shared_ptr,引用计数加 1,而不是创建新控制块。
一个必知的边界:因为内部 weak_ptr 要等外部的 shared_ptr 构造函数来初始化,不能在对象自己的构造函数里调用 shared_from_this()——此时外部 shared_ptr 还没生成,调用会抛出 std::bad_weak_ptr 异常。
三层答法:流行的标准答案
网上的教程里流传一个”三层答法”,大意如下:
| 层次 | 结论 | 解释 |
|---|---|---|
| ① 引用计数本身 | 安全 | strong/weak 计数是 std::atomic,多线程同时拷贝/销毁各自的 shared_ptr,计数增减不会错乱 |
| ② 同一控制块被不同实例共享 | 安全 | 每个线程持有自己独立的 shared_ptr 对象,各自操作计数互不干扰,对象生死由计数正确决定 |
③ 指向的对象本身(*sp) | 不安全 | shared_ptr 只保护”计数和对象生死”,不保护对象内部数据。两个线程同时 sp->field = x 是数据竞争,要自己加锁 |
配一句很精辟的记忆口诀:shared_ptr 保证”对象什么时候死”是正确的,不保证”对象活着时怎么被用”是正确的。 前者是所有权问题(shared_ptr 的职责),后者是并发访问问题(你的职责)。
这三层大体都对。但我在推演中发现它漏了一层——而且漏掉的恰好是最容易踩坑的场景。
被漏掉的第四层:实例本身
问题出在:如果两个线程共享的是同一个 shared_ptr 变量呢?(三层答法默认了大家都在操作各自的实例)
很多新手看完三层答法,会自然地推导:“既然引用计数是安全的,对象死活也是它管的,那我在两个线程里同时对一个全局 shared_ptr 执行赋值,应该也没事吧?”
结果:指针撕裂,程序崩溃。
shared_ptr 变量本身就是个普通结构体
一个 shared_ptr 实例在典型实现里就是两个裸指针:
template <typename T>
class shared_ptr {
T* ptr; // 指向真实对象
control_block* cb; // 指向堆上的控制块(引用计数所在地)
};
对这两个指针的读写,并不是原子的。
撕裂现场还原
假设全局有 shared_ptr<int> g_ptr;,当前指向对象 A:
- 线程 1(写):
g_ptr = make_shared<int>(B);——底层分两步:改ptr指向 B,改cb指向 B 的控制块 - 线程 2(读):
auto local = g_ptr;——底层也是两步:复制ptr,复制cb并计数 +1
如果线程 1 刚执行完第一步(改了 ptr),还没执行第二步,CPU 切到线程 2——线程 2 读到的 g_ptr 处于半人半鬼的撕裂状态:ptr 指向新对象 B,cb 却还指向老对象 A 的控制块。拿着这个撕裂状态去操作,轻则内存泄漏,重则野指针、当场崩溃。
满分是四层
把三层升级为四层,分成”内部”和”外部”两组:
内部(智能指针自己的事):
- 控制块(引用计数)绝对安全——底层用 atomic,怎么增减都不会乱
shared_ptr实例本身不安全——它由两个裸指针组成,对同一个实例并发读写会发生指针撕裂。要并发写同一个实例,必须加锁,或用 C++20 的std::atomic<std::shared_ptr>
外部(你的业务逻辑的事):
- 不同的实例(哪怕指向同一个对象)绝对安全——只涉及第 1 层的原子引用计数
- 底层对象(
*sp)绝对不安全——智能指针只管生老病死,不管对象内部的数据竞争,业务数据必须自己加锁
那个教程的第 ② 层虽然说了”不同实例安全”,但没有明确警告”相同实例的写操作不安全”。在真正的面试里,如果你只答三层,有经验的面试官一定会追问:“那多线程同时 reset() 同一个 shared_ptr 呢?“答不上来或者说”安全”,前面的回答就全功尽弃了。
它的最大瑕疵,就是把”控制块”和”实例本身”混为一谈,默认了大家都在用不同的实例。
与同一实例并发共享的正规做法
如果确实需要在多线程中共享并修改同一个 shared_ptr 变量:
- C++11/14/17:给它加
std::mutex,或用全局原子操作函数std::atomic_load(&g_ptr)/std::atomic_store(&g_ptr, ...) - C++20 及以后:直接用
std::atomic<std::shared_ptr<T>>
顺带一提,这也呼应了上一层的四层结构:C++ 标准的原文规则是”多个线程可以同时读同一个 shared_ptr;但只要有一个线程在写(赋值、reset),其他线程无论读还是写都是数据竞争”。读-读安全,写必须独占——和普通变量的并发规则一样,只不过它外面那层原子计数的光环太亮,把这条规则盖住了。
附:enable_shared_from_this 自身的线程安全边界
回到开头的 enable_shared_from_this,它自身的线程安全性同样遵循这套分层:
- 引用计数操作线程安全:
shared_from_this()本质是weak_ptr::lock(),标准严格保证控制块的强/弱引用计数增减是原子的。多线程并发调用shared_from_this()完全安全 - 对象的内部状态不安全:它只保护指针的生命周期,不会给类加任何同步机制。多线程通过拿到的
shared_ptr同时修改成员变量,照样是数据竞争,还是要std::mutex或std::atomic - 初始化阶段有竞态风险:不要在多个线程中同时用裸指针构造第一个
shared_ptr——除了常识性的 Double Free,并发初始化内部weak_ptr也是未定义行为。总是用std::make_shared原子地完成对象创建和指针分配
几点心得
-
“原子性”和”内存可见性”是两个概念。 计数器本身永远算得对(原子性白送),但围绕它的其他数据要不要同步(内存序)是要花钱的——这个拆分是理解后面所有问题的地基。
-
“控制块”和”shared_ptr 实例”是两个东西。 前者在堆上、是原子的;后者是栈上两个裸指针、不是原子的。所有线程安全性的讨论都要先问一句:你在说哪一层?
-
背八股不如推演边界场景。 三层答法背得再熟,被一句”多线程同时 reset 同一个实例怎么办”追问就露馅。自己推演过撕裂现场,才知道每条”安全”结论的前提是什么。
-
enable_shared_from_this的weak_ptr是外部shared_ptr构造函数”注入”的。 理解了这一点,“构造函数里不能调shared_from_this()”就不再是需要死记的规则,而是自然推论。
如果有什么不对的地方,欢迎指正。