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 对象,只是拷贝它,就不会有数据竞争

我在项目中遇到过一种情况:有人为了「性能」,自己实现了一个引用计数,用 intstd::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。这在单线程里就存在,但在多线程里更难排查。

我曾经在一个网络库中见过:ConnectionSession 互相持有 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 知识体系图

下面这张图,把本章的核心逻辑串起来了。你一看就明白。

shared_ptr 多线程安全模型 shared_ptr 线程安全 引用计数:原子操作 对象指针:非原子 拷贝/析构安全 计数不会丢失 读写同一对象 数据竞争 三种使用模型 只读共享:安全 初始化后不再修改 一写多读:需加锁 锁内拷贝,锁外使用 多写者:必须互斥 或使用 atomic<shared_ptr>

17.8 总结

多线程用 shared_ptr,记住三条:

  1. 引用计数是原子的,拷贝和析构安全。
  2. shared_ptr 对象本身不是原子的,不要直接读写同一个对象。
  3. 用锁保护 shared_ptr 的写操作,在锁内拷贝,锁外使用。

另外,别忘了 weak_ptr 这个好工具。它能帮你打破循环引用,避免内存泄漏。

嗯,这一章就到这里。代码写多了,这些细节自然就刻在脑子里了。


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