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,新代码建议直接用下面的类型。
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,构造后不再变),所以共享它不需要任何锁。不可变数据配上引用计数,是最省心的一种形态。
延伸阅读
- std::shared_ptr --- cppreference ------ Thread safety 一节逐条列出「不同实例安全 / 同一实例非 const 操作不安全」的原文措辞
- std::atomic(std::shared_ptr) 自由函数 --- cppreference ------
atomic_load/atomic_store系列的全部重载与 deprecated 状态 - std::atomic<std::shared_ptr> --- cppreference ------ C++20 偏特化,含
compare_exchange_weak的语义 - std::memory_order --- cppreference ------ 为什么作者写
acquire/release而不是默认的seq_cst - C++ Core Guidelines ------ 并发一章的总原则:共享可变状态必须有同步,能靠「不共享」解决就不要靠锁
收个尾
回到开头那句传言。shared_ptr 保证的只有一件事:控制块里的引用计数是原子的 ,多个副本不会把计数算错。同一个实例上一旦出现非 const 操作(=、reset、swap),同步就得你自己来;被管理的对象更是从头到尾没人替你管。
选型的顺序倒是很清楚:先考虑让每个线程持有自己的副本,最省事也最快;C++17 里确实要热替换,用 std::atomic_load / std::atomic_store 这对自由函数;到了 C++20 直接上 std::atomic<std::shared_ptr<T>>,还能写 CAS 循环。不需要热替换就别碰原子版本,多出来的开销换不到任何东西。