shared_ptr 的线程安全边界:计数安全不等于对象安全

std::shared_ptr 大概是 C++ 里被误解最多的类型。很多人听说「它的引用计数是线程安全的」,就放心地把它当全局变量、在线程之间随手赋值,然后在大压力下偶发崩溃或者计数错乱。

真实情况要分三层看:引用计数(reference count)确实用原子操作实现,但 shared_ptr 这个对象本身不是原子的,它管理的对象更不是。下面把边界划清楚:哪些操作并发安全,哪些是数据竞争,真要「多线程换指针」时 C++17 和 C++20 各有什么手段。

一个「偶尔」崩的线程池

先看一段很常见、但确实是错的代码:

cpp 复制代码
// 反例,不要这么写(数据竞争;Core Guidelines 要求共享可变状态必须同步)
#include <memory>

std::shared_ptr<int> g_config;      // 全局共享指针

void reload() {
    g_config = std::make_shared<int>(42);   // 线程 A:赋值给同一个实例
}

void use() {
    std::shared_ptr<int> local = g_config;  // 线程 B:拷贝同一个实例
    // 一旦和上面同时发生 ------ 未定义行为
}

问题出在 g_config = ... 和 auto local = g_config 撞在一起:前者是非 const 成员操作,要改被管理指针和引用计数;后者只是读被管理指针。两者落在同一个 shared_ptr 对象上,就是标准意义上的数据竞争(data race)。

这里最容易混的一点:原子化的是控制块里的计数 ,shared_ptr 自己那两个字段(被管理指针 T* 和控制块指针)并不原子。赋值要同时改「指针指向」和「计数」,这两步之间没有任何机制保证另一个线程看到的是一致状态。

所以「引用计数是原子的」这句话本身没错,只是它管的事情比多数人以为的少得多。

text 复制代码
  控制块(control block)------ 被所有 shared_ptr 副本共享
  ┌───────────────────────┬───────────────────┬─────────────────┐
  │ 强引用计数(原子)       │ 弱引用计数(原子)   │ 删除器 / 分配器  │
  └───────────▲───────────┴─────────▲─────────┴─────────────────┘
              │                     │
      ┌───────┴────────┐    ┌───────┴────────┐
      │ shared_ptr 实例 │    │ shared_ptr 实例 │
      │ 线程 A 私有      │    │ 线程 B 私有      │
      │ T* p │ ctrl*    │    │ T* p │ ctrl*    │
      └───────┬────────┘    └────────┬───────┘
              │                      │
              │                      │  ← 不同实例各自读改自己的两个字段:安全
              └──────────┬───────────┘
                         ▼
              ┌─────────────────────────────┐
              │ 被管理的对象 T              │
              │ 这一层 shared_ptr 完全不管   │
              │ 并发读写必须自己加锁/原子化  │
              └─────────────────────────────┘

官方文档:std::shared_ptr --- cppreference(Thread safety 一节是这篇全部结论的出处)· std::memory_order --- cppreference

划边界:哪些操作安全,哪些是竞争

shared_ptr 的规则可以精确到「同一个实例」还是「不同实例」:

操作 并发情况 结论
多线程各自拷贝同一个 const shared_ptr 实例 全是只读访问 安全,计数用原子操作维护
不同线程持有不同的 shared_ptr 实例(互为副本) 各改各的两个字段 安全,标准明确保证
任一线程对同一实例调用非 const 成员(operator= / reset / swap) 读写混在一起 数据竞争
一个线程写同一实例,另一个线程只是拷贝它 读 + 非 const 写 数据竞争,很多人误以为这样安全
通过 p-> / *p 读写被管理的对象 与 shared_ptr 无关 要自己同步,shared_ptr 不提供任何保护
use_count() 原子读 安全,但拿到的只是某一瞬间的快照,不能当同步手段

一句话记忆:「不同实例安全,同一实例的非 const 操作危险,被管理对象全部自理」。

正确姿势:每个线程持有自己的副本

最省事、开销也最低的做法,是让每个线程都在自己的实例上工作。线程入口按值捕获,或者拷一份局部副本:

cpp 复制代码
// shared_copy.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread shared_copy.cpp -o sc
#include <atomic>
#include <cstdio>
#include <memory>
#include <thread>
#include <vector>

int main() {
    constexpr int kThreads = 4;
    auto shared = std::make_shared<int>(7);

    std::vector<std::shared_ptr<int>> copies(kThreads);   // 每个线程写自己那一格
    std::vector<long> counts(kThreads, 0);
    std::atomic<int> ready{0};
    std::atomic<bool> go{false};

    std::vector<std::thread> workers;
    for (int i = 0; i < kThreads; ++i) {
        workers.emplace_back([&, i] {
            copies[i] = shared;                              // 拷贝构造:计数原子 +1
            ready.fetch_add(1, std::memory_order_relaxed);
            while (!go.load(std::memory_order_acquire)) { }   // 等所有线程就位
            counts[i] = copies[i].use_count();                // 此刻 4 副本 + 主线程 = 5
        });
    }
    while (ready.load(std::memory_order_acquire) < kThreads) { }
    go.store(true, std::memory_order_release);
    for (auto& t : workers) t.join();

    std::printf("并发期间各线程观察到的 use_count: %ld %ld %ld %ld\n",
                counts[0], counts[1], counts[2], counts[3]);
    copies.clear();                                          // 销毁全部副本
    std::printf("副本销毁后 use_count: %ld\n", shared.use_count());
}
text 复制代码
并发期间各线程观察到的 use_count: 5 5 5 5
副本销毁后 use_count: 1

四个线程在不同实例上各自拷贝,计数被正确维护到 5;副本销毁后回到 1,没有泄漏也没有提前释放。copies[i] = shared 写的是不同的 vector 元素,彼此没有竞争。ready / go 这一对原子变量纯粹是为了让输出可复现,否则各线程读到的 use_count 取决于调度。

确实要在多线程里「换指针」怎么办

有些场景没法只靠副本,比如配置要热更新、缓存要整体切换。这类场景的共同点是同一个变量一个线程写、其他线程读,这时有三种选择。

方案 起始标准 说明 选型建议
每线程持有副本 C++11 无锁、零额外同步成本 默认首选;只在「需要看到最新值」时不适用
std::atomic_load(&p) / std::atomic_store(&p, ...) 自由函数 C++11,C++20 起 deprecated 对 shared_ptr 做原子读/写 C++17 环境的折中方案
std::atomic<std::shared_ptr<T>> C++20 支持 load / store / compare_exchange_* C++20 环境的首选

先看 C++17 里能用的自由函数版本:

cpp 复制代码
// atomic_free.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread atomic_free.cpp -o af
#include <cstdio>
#include <memory>
#include <thread>

int main() {
    std::shared_ptr<int> config = std::make_shared<int>(1);

    std::thread writer([&config] {
        std::atomic_store(&config, std::make_shared<int>(42));   // 原子发布新版本
    });
    writer.join();

    std::shared_ptr<int> snapshot = std::atomic_load(&config);   // 原子读取快照
    std::printf("快照值 = %d\n", *snapshot);
    std::printf("config.use_count() = %ld\n", config.use_count());
}
text 复制代码
快照值 = 42
config.use_count() = 2

atomic_load 返回的是一份独立的副本,所以主线程的 config 和 snapshot 各占一份计数,得到 2。读线程拿到快照之后,即便写线程紧接着替换了指针,它手里那个对象也保证还活着,这正是这套接口存在的意义。代价是这类自由函数在 C++20 里被标记为 deprecated,新代码建议直接用下面的类型。

官方文档:std::atomic(std::shared_ptr) 自由函数 --- cppreference

C++20 把这件事做进了类型系统:std::atomic<std::shared_ptr<T>> 是正式的偏特化,并且提供 compare_exchange_strong / compare_exchange_weak,可以写无锁的读-改-写循环。下面 4 个线程各做 1000 次「读到当前值、发布一个 +1 的新对象」,最终必然是 4001:

cpp 复制代码
// verify: std=c++20
// atomic_rmw.cpp --- 需要 C++20;编译: g++ -std=c++20 -Wall -O2 -pthread atomic_rmw.cpp -o armw
#include <atomic>
#include <cstdio>
#include <memory>
#include <thread>
#include <vector>

int main() {
    constexpr int kThreads = 4;
    constexpr int kRounds  = 1000;

    // C++20 起 std::atomic<std::shared_ptr<T>> 是标准偏特化
    std::atomic<std::shared_ptr<int>> g{std::make_shared<int>(1)};

    std::vector<std::thread> workers;
    for (int t = 0; t < kThreads; ++t) {
        workers.emplace_back([&g] {
            for (int k = 0; k < kRounds; ++k) {
                auto cur = g.load();
                for (;;) {
                    auto next = std::make_shared<int>(*cur + 1);
                    if (g.compare_exchange_weak(cur, next)) break;  // 失败时 cur 被刷新
                }
            }
        });
    }
    for (auto& t : workers) t.join();

    std::printf("最终值 = %d\n", *g.load());
    std::printf("sizeof(std::atomic<std::shared_ptr<int>>) = %zu\n",
                sizeof(std::atomic<std::shared_ptr<int>>));
}
text 复制代码
最终值 = 4001
sizeof(std::atomic<std::shared_ptr<int>>) = 16

有两点值得留意。一是 compare_exchange_weak 失败时会顺手把 cur 刷新成最新值,所以循环体里不用重新 load,也不会读到过期对象,这个语义和普通 CAS 一致。二是 sizeof 仍是 16,也就是「被管理指针 + 控制块指针」,没有为原子性多付一个字的存储,gcc 的实现把自旋计数藏在了控制块里。

官方文档:std::atomic<std::shared_ptr> --- cppreference · std::thread --- cppreference

多线程共享只读配置

把上面的建议落地,也就是共享数据一旦发布就不再修改,各线程各拿一份副本。这是 90% 场景的正解,也是唯一不需要锁的方案:

cpp 复制代码
// shared_config.cpp --- 编译: g++ -std=c++17 -Wall -O2 -pthread shared_config.cpp -o sconf
#include <cstdio>
#include <memory>
#include <string>
#include <thread>
#include <vector>

class Config {
public:
    Config(std::string name, int workers) : name_(std::move(name)), workers_(workers) {}
    const std::string& name() const { return name_; }
    int workers() const { return workers_; }
private:
    std::string name_;
    int workers_;
};

int main() {
    constexpr int kThreads = 3;
    auto config = std::make_shared<Config>("prod", 4);

    std::vector<std::string> seen(kThreads);
    std::vector<std::thread> workers;
    for (int i = 0; i < kThreads; ++i) {
        workers.emplace_back([&, i] {
            std::shared_ptr<Config> local = config;   // 拷贝构造:线程私有实例
            seen[i] = local->name() + "/" + std::to_string(local->workers());
        });
    }
    for (auto& t : workers) t.join();

    for (int i = 0; i < kThreads; ++i)
        std::printf("线程 %d 看到 %s\n", i, seen[i].c_str());
    std::printf("主线程 use_count = %ld\n", config.use_count());
}
text 复制代码
线程 0 看到 prod/4
线程 1 看到 prod/4
线程 2 看到 prod/4
主线程 use_count = 1

local 是每个线程自己的 shared_ptr,config 只被读取、从未被写,整个程序没有任何数据竞争。Config 本身也是只读的(成员函数全 const,构造后不再变),所以共享它不需要任何锁。不可变数据配上引用计数,是最省心的一种形态。

延伸阅读

收个尾

回到开头那句传言。shared_ptr 保证的只有一件事:控制块里的引用计数是原子的 ,多个副本不会把计数算错。同一个实例上一旦出现非 const 操作(=、reset、swap),同步就得你自己来;被管理的对象更是从头到尾没人替你管。

选型的顺序倒是很清楚:先考虑让每个线程持有自己的副本,最省事也最快;C++17 里确实要热替换,用 std::atomic_load / std::atomic_store 这对自由函数;到了 C++20 直接上 std::atomic<std::shared_ptr<T>>,还能写 CAS 循环。不需要热替换就别碰原子版本,多出来的开销换不到任何东西。

相关推荐
根目录下的猫1 小时前
ROS 2 C++(rclcpp)版本,实现话题订阅、话题发布,以及完整编译运行流程
开发语言·c++·算法
无名猿1 小时前
shared_mutex 读写锁该不该用,以及 volatile 的三大误解
c++·性能优化·并发编程·现代c++
大侠归来3 小时前
C 语言 | 在函数中操作数组
c语言·c++·算法
朝朝辞暮i9 小时前
C++ 第 23 课:class —— 开始真正进入面向对象
开发语言·c++·算法
郑同学的笔记10 小时前
【c++随笔28】strcmp 和 memcmp对比
开发语言·c++
随意起个昵称10 小时前
【背包dp】输出方案路径
c++·动态规划
朝朝辞暮i11 小时前
C++ 第 27 课:智能指针 shared_ptr
开发语言·c++·算法
by2099911 小时前
学会使用std::string类,并理解其内部是如何管理字符串的详细阐述(上)
c++·笔记·字符串·类和对象·string
沙漠之主11 小时前
C++编程教学设计资料:从入门到实战的完整课程方案
java·前端·c++