10. 智能指针的线程安全性:引用计数是安全的,但对象不是

这个话题,我每次在团队内部做技术分享时都会重点强调。为什么呢?因为太多人踩过这个坑了。

很多人一听说「智能指针是线程安全的」,就放心大胆地在多线程里随便用。结果程序跑着跑着就崩了,还一脸懵:「不是线程安全吗?」

嗯,这里要分清楚:引用计数的操作是线程安全的,但智能指针管理的对象本身不是。这是两个完全不同的层面。

10.1 引用计数为什么是安全的?

先说说引用计数这块。C++11 标准要求 shared_ptr 的引用计数操作必须是原子操作。说白了,就是 use_count() 的增减是原子的,不会出现数据竞争。

举个例子:

// 线程A
auto sp1 = std::make_shared<int>(42);

// 线程B
auto sp2 = sp1;  // 引用计数从1变成2,这个操作是原子的

两个线程同时拷贝同一个 shared_ptr,引用计数的增减不会乱。不会出现两个线程都以为自己是最后一个持有者,结果把对象给 double free 了。

我在项目中遇到过这样一个场景:一个全局的 shared_ptr 被十几个工作线程同时拷贝和释放。上线前我专门 review 了这块代码,确认引用计数操作没问题才放行。运行了几个月,确实没出过引用计数相关的问题。

10.2 但对象本身不是安全的

这才是真正的坑。

shared_ptr 只保证引用计数的原子性,它不保证你管理的对象在多线程环境下是安全的。你想想看,两个线程同时拿到了同一个对象的 shared_ptr,然后同时调用对象的非 const 方法——这不就是经典的 data race 吗?

// 危险的例子
std::shared_ptr<std::vector<int>> sp = std::make_shared<std::vector<int>>();

// 线程A
void threadA() {
    sp->push_back(1);  // 修改对象
}

// 线程B
void threadB() {
    sp->push_back(2);  // 同时修改对象
}

这段代码跑起来,大概率会崩。引用计数是安全的没错,但 vector 本身不是线程安全的。两个线程同时 push_back,内部的内存分配、迭代器都会乱掉。

核心原则:智能指针的线程安全只保证「指针本身」的拷贝和析构是安全的,不保证「指针指向的对象」的并发访问是安全的。

10.3 那怎么办?加锁呗

解决方案其实不复杂。要么你自己加锁,要么用原子操作的数据结构。

我个人习惯的做法是:如果对象需要被多个线程共享访问,就把它包在一个 std::mutex 后面。

#include <mutex>
#include <memory>
#include <vector>

class ThreadSafeVector {
    std::vector<int> data_;
    mutable std::mutex mtx_;
public:
    void push_back(int val) {
        std::lock_guard<std::mutex> lock(mtx_);
        data_.push_back(val);
    }
    
    // 其他方法类似...
};

// 使用
auto sp = std::make_shared<ThreadSafeVector>();

这样,多个线程通过 shared_ptr 访问同一个 ThreadSafeVector 对象时,内部有锁保护,就不会出问题了。

10.4 一个更隐蔽的问题:控制块和对象分离

这里有个细节,我估计很多人没注意到。shared_ptr 内部有两个东西:

  • 控制块:存引用计数、删除器等元数据
  • 管理的对象:你实际要用的那个对象

控制块是线程安全的,但对象不是。而且这两个东西的内存位置是分开的

make_shared 时,控制块和对象是在一次内存分配里连续存放的。但用 shared_ptr(new T) 时,它们是两次独立分配。这有什么区别呢?

创建方式 内存布局 特点
make_shared 控制块 + 对象 连续分配 一次分配,性能好;但对象要等控制块一起释放
shared_ptr(new T) 控制块和对象分开分配 两次分配,但对象可以提前释放

我记得有一次排查内存泄漏,发现一个 shared_ptr 管理的对象明明已经没人用了,但内存就是不释放。查了半天,发现是用 make_shared 创建的,控制块里还有 weak_ptr 在引用,导致整个内存块一直留着。

小技巧:如果对象很大,而且你确定会有 weak_ptr 长期存在,可以考虑用 shared_ptr(new T) 代替 make_shared。这样对象可以提前释放,只保留小的控制块。

10.5 知识体系总览

下面这张图总结了智能指针线程安全的核心逻辑:

智能指针线程安全性全景图 shared_ptr 线程安全 ✅ 引用计数操作是原子的 ❌ 管理的对象不是线程安全的 拷贝、析构时引用计数增减安全 控制块(control block)线程安全 对象内部状态无保护 需要外部加锁或原子操作 结论:引用计数安全 ≠ 对象安全

10.6 避坑指南

我曾经在一个高并发的网络服务里犯过一个错。当时用 shared_ptr 管理一个连接对象,多个线程会同时读写这个对象的成员变量。我以为 shared_ptr 是线程安全的,就没加锁。结果线上频繁出现段错误,排查了两天才找到问题。

从那以后,我给自己定了几条规矩:

  1. 区分「指针安全」和「对象安全」——这是两个概念,别混为一谈
  2. 多线程共享对象,必须加锁或用原子类型——没有例外
  3. 能用 make_shared 就用它——性能好,异常安全,但要注意 weak_ptr 的生命周期问题
  4. 如果只是传递所有权,用 unique_ptr——别杀鸡用牛刀
一句话总结:shared_ptr 的引用计数是线程安全的,但别指望它帮你保护对象。对象的安全,还得你自己来管。

好了,这一章就到这里。记住这个核心区别,写多线程代码时心里就有底了。


公众号:蓝海资料掘金营,微信deep3321