C++11引入的shared_ptr智能指针极大地简化了动态内存管理,但其在多线程环境下的行为常让开发者感到困惑。一个经典的问题是:C++11的shared_ptr是线程安全的吗?
答案并非简单的“是”或“否”,而是一个需要分情况讨论的结论:它既是线程安全的,也是非线程安全的。 理解其安全边界,是编写正确并发程序的关键。
核心结论
shared_ptr的设计遵循一个清晰的线程安全模型:其控制块(尤其是引用计数)的操作是原子的、线程安全的。 这意味着:
- 引用计数的修改是线程安全的:多个线程同时对同一个资源进行
shared_ptr的拷贝或销毁,引用计数的增减是原子操作,确保资源最终只会被释放一次。
- 不同的
shared_ptr对象(即使是副本)可以被多线程并发操作:每个线程操作自己拥有的shared_ptr实例是安全的。
然而,线程安全的边界也显而易见:
- 同一个
shared_ptr实例的并发读写是不安全的:如果多线程直接读写(非const操作)同一个shared_ptr对象,会发生数据竞争。
- 被管理的原始对象本身不提供任何线程安全保证:
shared_ptr只管理指针的生命周期,不保护指针所指向的数据。
安全的场景
1. 引用计数的原子性
引用计数通常通过std::atomic相关操作实现,保证了即使在多线程环境下,资源的生命周期管理也是正确的。
// 场景:多个线程同时创建 shared_ptr 的副本
std::shared_ptr<Widget> global_ptr = std::make_shared<Widget>();
void thread_func() {
std::shared_ptr<Widget> local_ptr = global_ptr; // 安全!
// 引用计数的增加是原子操作
}
// 启动多个线程
std::thread t1(thread_func);
std::thread t2(thread_func);
std::thread t3(thread_func);
这段代码完全安全,每个线程都在创建自己的shared_ptr副本,控制块引用计数的修改是原子的。
2. 不同shared_ptr对象的并发访问
即使内部共享同一个控制块,不同的shared_ptr对象也可以被多个线程安全地用于可变操作(如operator=或reset)。
std::shared_ptr<int> sp1 = std::make_shared<int>(42);
void thread1() {
std::shared_ptr<int> local1 = sp1; // 安全
local1.reset(); // 安全
}
void thread2() {
std::shared_ptr<int> local2 = sp1; // 安全
local2 = std::make_shared<int>(100); // 安全
}
不安全的场景
1. 对同一个shared_ptr对象的并发修改
这是最常见的陷阱。
std::shared_ptr<Widget> global_ptr = std::make_shared<Widget>();
void thread1() {
global_ptr = std::make_shared<Widget>(); // 危险!非原子写
}
void thread2() {
global_ptr.reset(); // 危险!非原子写
}
void thread3() {
auto local = global_ptr; // 危险!可能与写操作并发读
}
原因:shared_ptr的赋值或reset操作不是原子的,它涉及修改内部指针和引用计数(在需要时)等多个步骤。并发读写可能导致对象处于中间状态,引发未定义行为(如崩溃或数据损坏)。
2. 被管理对象本身的并发访问
这是概念上最容易混淆的点。shared_ptr的线程安全仅限于其控制块,而不延伸至其指向的对象。
std::shared_ptr<std::vector<int>> vec_ptr =
std::make_shared<std::vector<int>>();
void thread1() {
vec_ptr->push_back(1); // 不安全!vector::push_back非线程安全
}
void thread2() {
vec_ptr->push_back(2); // 不安全!
}
即使vec_ptr的引用计数操作安全,多个线程直接调用std::vector的push_back方法也会导致数据竞争。
正确做法:使用互斥锁保护数据访问
std::shared_ptr<std::vector<int>> vec_ptr =
std::make_shared<std::vector<int>>();
std::mutex mtx;
void thread1() {
std::lock_guard<std::mutex> lock(mtx);
vec_ptr->push_back(1); // 安全
}
void thread2() {
std::lock_guard<std::mutex> lock(mtx);
vec_ptr->push_back(2); // 安全
}
多线程环境下安全使用shared_ptr的方案
方案1:使用局部副本(推荐)
最简洁安全的方式,每个线程持有一份资源的shared_ptr副本。
std::shared_ptr<Widget> global = std::make_shared<Widget>();
void worker() {
auto local = global; // 创建副本,引用计数原子递增,安全
local->do_something(); // 访问资源需另行考虑线程安全
}
方案2:使用std::atomic特化(C++20推荐)
C++20为std::shared_ptr提供了std::atomic特化,提供了类型安全的原子操作接口。
std::atomic<std::shared_ptr<Widget>> atomic_ptr;
void thread1() {
atomic_ptr.store(std::make_shared<Widget>()); // 原子存储,安全
}
void thread2() {
auto local = atomic_ptr.load(); // 原子加载,安全
}
这是现代C++的推荐做法,编译器能确保所有访问都是原子的。
方案3:使用互斥锁保护
传统且有效的方法,使用互斥锁将整个shared_ptr对象的访问序列化。
std::shared_ptr<Widget> global_ptr;
std::mutex ptr_mutex;
void thread1() {
std::lock_guard<std::mutex> lock(ptr_mutex);
global_ptr = std::make_shared<Widget>(); // 安全
}
void thread2() {
std::lock_guard<std::mutex> lock(ptr_mutex);
auto local = global_ptr; // 安全
}
官方示例解析
以下来自cppreference的示例清晰地展示了shared_ptr引用计数的线程安全性:
#include <iostream>
#include <memory>
#include <thread>
#include <chrono>
#include <mutex>
struct Base {
Base() { std::cout << "Base::Base()\n"; }
~Base() { std::cout << "Base::~Base()\n"; }
};
void thr(std::shared_ptr<Base> p) {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::shared_ptr<Base> lp = p; // 线程安全:引用计数原子递增
{
static std::mutex io_mutex;
std::lock_guard<std::mutex> lk(io_mutex);
std::cout << "local pointer in thread:\n"
<< " use_count=" << lp.use_count() << '\n';
}
}
int main() {
std::shared_ptr<Base> p = std::make_shared<Base>();
std::thread t1{thr, p}, t2{thr, p}, t3{thr, p};
p.reset(); // main线程释放所有权,引用计数减1(但资源未释放,因为还有3个线程持有副本)
std::cout << "Shared ownership between 3 threads:\n"
<< " use_count=" << p.use_count() << '\n'; // 输出0
t1.join(); t2.join(); t3.join(); // 每个线程结束,局部lp销毁,引用计数递减
std::cout << "All threads completed\n";
}
输出结果证明了引用计数在多线程间被正确、安全地管理:
Base::Base()
Shared ownership between 3 threads:
use_count=0
local pointer in thread:
use_count=3
local pointer in thread:
use_count=2
local pointer in thread:
use_count=1
Base::~Base()
All threads completed
总结与实践建议
| 操作场景 |
是否线程安全 |
说明 |
| 引用计数的增减 |
安全 |
原子操作 |
多个shared_ptr副本的并发访问 |
安全 |
即使共享同一对象 |
同一个shared_ptr对象的并发读(仅const成员函数) |
安全 |
如use_count() |
同一个shared_ptr对象的并发写 |
不安全 |
数据竞争 |
同一个shared_ptr对象的并发读写 |
不安全 |
数据竞争 |
| 被管理对象本身的并发访问 |
不安全 |
取决于对象自身的实现 |
核心建议:
- 优先使用局部副本:这是最清晰、最不容易出错的方式。
- 严格区分“智能指针安全”与“数据安全”:
shared_ptr保障的是指针生命周期的安全,而非指向数据的安全。对数据的并发访问需要额外的同步机制,如互斥锁。深入理解原子操作与内存序对编写高性能并发代码至关重要。
- C++20及以上版本使用
std::atomic<shared_ptr>:利用类型系统强制原子访问。
- 理解设计哲学:
shared_ptr的线程安全设计是“最小化”的,它只保证自身管理数据(引用计数、弱引用计数等)的原子性,以此来实现核心功能(防止重复释放)的线程安全,而将更复杂的同步决策留给使用者。
- 深入底层原理:要彻底掌握
shared_ptr的线程安全模型,最佳途径之一是动手实现一个工业级的智能指针,这能让你深刻理解控制块设计、多线程编程下的原子操作以及weak_ptr等关联机制。