17. 智能指针与多线程:shared_ptr 引用计数的原子性、数据竞争、线程安全模型
多线程环境下用 shared_ptr,是很多 C++ 项目从单线程走向并发时,第一个踩的坑。
我记得几年前接手过一个后台服务模块。代码里到处是 shared_ptr 在线程间传来传去,看起来挺现代。结果上线后,每隔几小时就崩一次。查了三天,最后定位到——引用计数竞争导致悬空指针。嗯,那之后我对 shared_ptr 的线程安全模型,就再也不敢想当然了。
这一章,我们就把它彻底说透。
17.1 核心问题:shared_ptr 到底线程安全吗?
这个问题,面试里经常有人答错。
很多人说「安全」,也有人说「不安全」。其实都不全对。
标准答案是这样的:
- 引用计数的增减是原子的 —— 这是标准库保证的。
- 但 shared_ptr 对象本身的读写不是原子的 —— 这是很多人忽略的。
说白了,两个线程同时操作同一个 shared_ptr 对象(比如一个读、一个写),那就会出问题。但两个线程各自操作自己的 shared_ptr 副本,指向同一个堆对象,那是安全的。
一句话总结:
引用计数本身是线程安全的。但 shared_ptr 对象不是。
17.2 引用计数的原子性
shared_ptr 内部维护一个控制块,里面存着引用计数。每次拷贝或析构,都会操作这个计数。
标准库要求:引用计数的增减必须是原子操作。也就是说,两个线程同时拷贝同一个 shared_ptr,计数不会乱。
我们来看一个典型场景:
// 线程 A
auto sp1 = sp; // 计数 +1,原子操作
// 线程 B
auto sp2 = sp; // 计数 +1,原子操作
// 两个线程同时执行,计数最终是 3(初始 1 + 2)
// 不会出现计数变成 2 的情况
这个保证很关键。它意味着:只要你不直接修改同一个 shared_ptr 对象,只是拷贝它,就不会有数据竞争。
我在项目中遇到过一种情况:有人为了「性能」,自己实现了一个引用计数,用 int 加 std::atomic<int> 来替代 shared_ptr。结果漏了 memory order 的约束,线上偶发崩溃。后来老老实实换回 shared_ptr,问题消失。标准库的原子性,不是随便能复制的。
17.3 数据竞争:shared_ptr 对象本身不是原子的
好,现在说重点。
引用计数是原子的,但 shared_ptr 对象本身包含两个指针:
- 一个指向管理的对象
- 一个指向控制块
这两个指针的读写,不是原子的。
看这个例子:
// 全局 shared_ptr
std::shared_ptr<Foo> g_sp = std::make_shared<Foo>();
// 线程 A:写入
void thread_a() {
g_sp = std::make_shared<Foo>(); // 修改 g_sp
}
// 线程 B:读取
void thread_b() {
auto local = g_sp; // 读取 g_sp
if (local) {
local->do_something();
}
}
这段代码有数据竞争。为什么?
因为线程 A 在写 g_sp 时,会先更新对象指针,再更新控制块指针(或者反过来)。线程 B 在读的时候,可能读到「对象指针是新的,控制块指针是旧的」这种不一致状态。结果就是:local 指向了一个半构造的 shared_ptr,引用计数可能不对,甚至控制块指针是野指针。
警告:
不要在多线程中直接读写同一个 shared_ptr 对象。这不是线程安全的。
17.4 线程安全模型:三种典型用法
根据我的经验,多线程中使用 shared_ptr,可以归纳为三种模型。搞清楚它们,你就知道什么时候该加锁,什么时候不用。
模型一:只读共享
多个线程只读取同一个 shared_ptr,不修改它。这是安全的。
// 全局 shared_ptr,初始化后不再修改
std::shared_ptr<Config> g_config = load_config();
// 多个线程只读
void worker_thread() {
auto cfg = g_config; // 拷贝,安全
cfg->use();
}
这种场景最常见。只要保证初始化发生在所有线程启动之前,后面就随便读。
模型二:写者唯一,读者多个
一个线程写 shared_ptr,多个线程读。这时候需要加锁。
std::shared_ptr<Foo> g_foo;
std::mutex g_mutex;
void writer() {
std::lock_guard<std::mutex> lock(g_mutex);
g_foo = std::make_shared<Foo>();
}
void reader() {
std::shared_ptr<Foo> local;
{
std::lock_guard<std::mutex> lock(g_mutex);
local = g_foo; // 在锁保护下拷贝
}
// 离开锁后,local 是独立的 shared_ptr,可以安全使用
local->do_something();
}
注意这里的技巧:在锁内拷贝,在锁外使用。这样锁的粒度最小,性能最好。
模型三:多个写者
多个线程同时写同一个 shared_ptr。这必须加锁,而且通常需要更复杂的同步机制。
// 多个写者,必须互斥
void updater() {
std::lock_guard<std::mutex> lock(g_mutex);
g_foo = compute_new_foo();
}
如果写操作很频繁,可以考虑用 std::atomic<std::shared_ptr>(C++20 引入),它提供了无锁的读写。不过要注意,它内部还是用了锁,只是封装好了。
17.5 一个经典陷阱:引用计数泄漏
多线程环境下,还有一个容易忽略的问题:循环引用导致的内存泄漏。
两个对象互相持有 shared_ptr,引用计数永远降不到 0。这在单线程里就存在,但在多线程里更难排查。
我曾经在一个网络库中见过:Connection 和 Session 互相持有 shared_ptr,连接断开后,两个对象都无法释放。服务跑一天,内存涨了 2GB。
解决方案很简单:用 weak_ptr 打破循环。
class Connection : std::enable_shared_from_this<Connection> {
std::shared_ptr<Session> session_; // 这里应该用 weak_ptr
};
class Session {
std::shared_ptr<Connection> conn_; // 没问题
};
改成 weak_ptr 后,引用计数就能正常归零了。
17.6 性能考量:原子操作的开销
引用计数的原子操作,不是免费的。
每次拷贝 shared_ptr,都要执行一次原子加。每次析构,都要执行一次原子减。原子操作会触发内存屏障,影响 CPU 指令重排,对性能有影响。
我做过一个压测:在 8 核机器上,大量传递 shared_ptr 的场景,原子操作的开销能占到总 CPU 的 5%~10%。
如果性能敏感,可以考虑:
- 用
unique_ptr替代,所有权明确,没有原子开销 - 用原始指针 + 生命周期管理,但风险高
- 减少
shared_ptr的拷贝次数,比如用引用传递
小技巧:
如果你只是想在函数间传递所有权,用 unique_ptr。如果你需要共享,但读多写少,考虑用 shared_ptr 配合 weak_ptr 做缓存。
17.7 知识体系图
下面这张图,把本章的核心逻辑串起来了。你一看就明白。
17.8 总结
多线程用 shared_ptr,记住三条:
- 引用计数是原子的,拷贝和析构安全。
- shared_ptr 对象本身不是原子的,不要直接读写同一个对象。
- 用锁保护 shared_ptr 的写操作,在锁内拷贝,锁外使用。
另外,别忘了 weak_ptr 这个好工具。它能帮你打破循环引用,避免内存泄漏。
嗯,这一章就到这里。代码写多了,这些细节自然就刻在脑子里了。