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_loadstd::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 后,读的是不可变数据,完全不需要加锁。写线程只是原子地替换指针,也不会影响正在读的线程。

小技巧: 如果你还在用 C++17 或更早版本,可以用 std::shared_ptr 配合 std::atomic_load_explicitstd::atomic_store_explicit。但这些函数在 C++20 中已被弃用,建议尽早迁移。

shared_ptr 多线程使用的决策树

为了方便记忆,我画了一张图。你想想看,每次遇到多线程 + shared_ptr 的场景,按这个流程走一遍,基本不会出错:

多线程使用 shared_ptr 多个线程是否同时修改 同一个 shared_ptr 变量? 加锁或使用 atomic<shared_ptr> 引用计数 自动安全 多个线程是否同时 读写 shared_ptr 指向的对象? 外部加锁 对象本身 无需保护 对象是否可变为 不可变(const)数据? 推荐使用 immutable 模式 加 mutex 或原子操作 总结:引用计数安全 对象和指针变量需要保护

几个容易忽略的细节

嗯,这里还要提几个我踩过的坑:

  • 不要把 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

一句话记住: shared_ptr 自己管好引用计数,你管好对象和指针变量。各司其职,多线程才能安稳。