20. 智能指针与多线程:shared_ptr的线程安全性
多线程环境下用 shared_ptr,是很多 C++ 开发者踩过坑的地方。
我刚开始接触这块时,也天真地以为 shared_ptr 是「线程安全的」。后来线上服务莫名其妙崩溃,查了两天才发现是引用计数竞争导致的。嗯,从那以后我再也不敢对智能指针的线程安全性做任何假设了。
shared_ptr 的「半线程安全」本质
先说结论:shared_ptr 的 引用计数操作是线程安全的,但 它所指向的对象不是。
说白了,就是多个线程同时拷贝、销毁同一个 shared_ptr 时,引用计数的加减是原子的,不会出现计数错乱。但如果你多个线程同时通过 shared_ptr 读写指向的对象——那该加锁还是得加锁。
- 引用计数:线程安全(原子操作)
- 对象本身:线程不安全(需要外部同步)
- 同一个 shared_ptr 的并发写:不安全
一个典型的坑
我见过不少同事这样写代码:
// 错误示范
std::shared_ptr<std::vector<int>> sp = std::make_shared<std::vector<int>>();
// 线程A
void threadA() {
sp->push_back(1); // 写对象
}
// 线程B
void threadB() {
for (auto& v : *sp) { // 读对象
std::cout << v;
}
}
你觉得这样安全吗?引用计数确实没问题,但 vector 本身不是线程安全的。线程A在 push_back 时可能触发扩容,线程B同时遍历——数据竞争,直接未定义行为。
shared_ptr 的拷贝构造和析构,也要小心「同一个 shared_ptr 对象被多个线程同时修改」的情况。比如两个线程同时执行 sp = std::make_shared<int>(42);,这会导致数据竞争。
正确的做法:控制块 vs 对象本身
我们得把 shared_ptr 拆成两部分来看:
| 组成部分 | 线程安全性 | 说明 |
|---|---|---|
| 控制块(引用计数) | 安全 | 原子操作,多个线程拷贝/析构没问题 |
| 管理的对象 | 不安全 | 需要外部加锁或使用原子类型 |
| shared_ptr 实例本身 | 不安全 | 同一个 shared_ptr 变量被多线程写,需要保护 |
我个人习惯的做法是:如果多个线程需要共享同一个对象,我会用 std::shared_ptr<std::mutex> 配合 std::lock_guard 来保护对象访问。或者直接用 std::atomic<std::shared_ptr<T>>(C++20 支持),但要注意性能开销。
实际项目中的最佳实践
我在项目中遇到过这样一个场景:一个配置管理器,多个工作线程会读取配置,偶尔有管理线程更新配置。当时我用了 std::shared_ptr<const Config>,配合 std::atomic_load 和 std::atomic_store(C++11 的旧接口)。
后来 C++20 出了 std::atomic<std::shared_ptr<T>>,代码就清爽多了:
// C++20 推荐做法
std::atomic<std::shared_ptr<const Config>> g_config;
// 读线程
void reader() {
auto local = g_config.load(); // 原子加载
local->print(); // 读的是不可变对象,安全
}
// 写线程
void writer(std::shared_ptr<const Config> new_config) {
g_config.store(new_config); // 原子替换
}
这里的关键是 const Config——对象一旦创建就不变了。读线程拿到 shared_ptr 后,读的是不可变数据,完全不需要加锁。写线程只是原子地替换指针,也不会影响正在读的线程。
std::shared_ptr 配合 std::atomic_load_explicit 和 std::atomic_store_explicit。但这些函数在 C++20 中已被弃用,建议尽早迁移。
shared_ptr 多线程使用的决策树
为了方便记忆,我画了一张图。你想想看,每次遇到多线程 + shared_ptr 的场景,按这个流程走一遍,基本不会出错:
几个容易忽略的细节
嗯,这里还要提几个我踩过的坑:
- 不要把 shared_ptr 的裸指针传给线程。我见过有人
sp.get()后把裸指针传给线程,然后原shared_ptr析构了——对象被释放,线程拿着野指针直接崩溃。 - weak_ptr 的 lock() 是原子的。如果你用
weak_ptr来观察对象是否存活,lock()返回的shared_ptr是安全的。但拿到之后,对对象的操作仍然需要同步。 - make_shared 比直接 new 更安全。因为
make_shared把控制块和对象分配在同一块内存,减少了内存碎片,也避免了异常安全问题。
shared_ptr 的引用(const shared_ptr<T>&),结果对象在另一个线程被释放了,回调执行时引用已经失效。后来改成按值捕获 shared_ptr,问题解决。记住:跨线程传递 shared_ptr 一定要按值传递,不要传引用。
总结一下
shared_ptr 的线程安全性,说白了就是「引用计数你不用担心,但其他一切都要自己操心」。我个人习惯把多线程共享的数据设计成不可变对象,配合 std::atomic<std::shared_ptr<const T>>,既安全又高效。
如果你实在需要可变数据,那就老老实实加锁。别想着取巧——我试过用 std::atomic<int> 来保护一个 shared_ptr 的计数器,结果代码变得又丑又难维护,最后还是改回了 std::mutex。