1. 引言:为什么我们需要自己实现 shared_ptr?
在 C++ 并发编程中,智能指针是管理动态内存的生命周期的利器。标准库中的 std::shared_ptr 虽然功能完善,但理解其内部实现机制对于掌握 C++ 内存管理和并发编程至关重要。通过自己实现一个简化版的 shared_ptr,我们可以深入理解:
- 原子操作在多线程环境下的关键作用
- 引用计数如何保证线程安全
- 内存序(memory ordering)对并发程序正确性的影响
这个实现将聚焦于最核心的功能:基于原子操作的引用计数。我们会省略一些高级特性(如自定义删除器、弱指针等),专注于线程安全的核心机制。
2. 核心设计:共享引用计数的实现
2.1 类模板定义与成员变量
我们的 shared_ptr 类模板定义如下:
cpp复制template <typename T>
class shared_ptr {
public:
// 构造函数
explicit shared_ptr(T* ptr = nullptr);
// 析构函数
~shared_ptr();
// 拷贝构造函数
shared_ptr(const shared_ptr& other);
// 拷贝赋值运算符
shared_ptr& operator=(const shared_ptr& other);
// 移动构造函数
shared_ptr(shared_ptr&& other) noexcept;
// 移动赋值运算符
shared_ptr& operator=(shared_ptr&& other) noexcept;
// 访问接口
T& operator*() const;
T* operator->() const;
std::size_t use_count() const;
T* get() const;
void reset(T* ptr = nullptr);
private:
void release(); // 释放资源的核心函数
T* ptr_; // 指向托管对象的指针
std::atomic<std::size_t>* ref_count_; // 指向原子引用计数的指针
};
2.2 为什么引用计数需要是指针?
这是设计中最关键的部分。ref_count_ 必须是指针而非直接对象,原因在于:
- 共享本质:所有指向同一对象的 shared_ptr 实例必须共享同一个计数器
- 生命周期管理:计数器需要比单个 shared_ptr 实例存活更久
- 原子性保证:std::atomic 对象本身不能拷贝,但指针可以
如果 ref_count_ 不是指针,每个 shared_ptr 拷贝都会有自己独立的计数器副本,完全违背了引用计数的初衷。
3. 核心实现:生命周期管理
3.1 构造函数与析构函数
构造函数实现
cpp复制explicit shared_ptr(T* ptr)
: ptr_(ptr),
ref_count_(ptr ? new std::atomic<std::size_t>(1) : nullptr)
{}
关键点:
- 使用 explicit 防止隐式转换
- 如果传入非空指针,在堆上分配原子计数器并初始化为1
- 空指针则保持 ref_count_ 为 nullptr
析构函数实现
cpp复制~shared_ptr() {
release();
}
private:
void release() {
if (ref_count_ && ref_count_->fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ptr_;
delete ref_count_;
}
}
release() 是核心函数,其线程安全保证依赖于:
- fetch_sub 的原子性:确保计数器递减不会被多线程干扰
- memory_order_acq_rel:建立正确的 happens-before 关系
重要提示:memory_order_acq_rel 在这里是必须的,它保证了:
- 释放语义:确保之前对 *ptr_ 的修改对其他线程可见
- 获取语义:确保 delete 前能看到其他线程的所有修改
3.2 拷贝语义的实现
拷贝构造函数
cpp复制shared_ptr(const shared_ptr& other)
: ptr_(other.ptr_),
ref_count_(other.ref_count_)
{
if (ref_count_) {
ref_count_->fetch_add(1, std::memory_order_relaxed);
}
}
这里使用 memory_order_relaxed 是因为:
- 只需要保证计数器增加的原子性
- 不需要与其他内存操作同步
- 性能最优
拷贝赋值运算符
cpp复制shared_ptr& operator=(const shared_ptr& other) {
if (this != &other) {
release();
ptr_ = other.ptr_;
ref_count_ = other.ref_count_;
if (ref_count_) {
ref_count_->fetch_add(1, std::memory_order_relaxed);
}
}
return *this;
}
关键点:
- 自赋值检查必不可少
- 先释放当前资源
- 再拷贝指针并增加新计数器
- 同样使用 relaxed 内存序
3.3 移动语义的实现
移动构造函数
cpp复制shared_ptr(shared_ptr&& other) noexcept
: ptr_(other.ptr_),
ref_count_(other.ref_count_)
{
other.ptr_ = nullptr;
other.ref_count_ = nullptr;
}
移动操作的特点:
- 直接"窃取"资源,不改变引用计数
- 将源对象置空
- noexcept 保证容器操作的效率
移动赋值运算符
cpp复制shared_ptr& operator=(shared_ptr&& other) noexcept {
if (this != &other) {
release();
ptr_ = other.ptr_;
ref_count_ = other.ref_count_;
other.ptr_ = nullptr;
other.ref_count_ = nullptr;
}
return *this;
}
3.4 访问接口实现
解引用操作符
cpp复制T& operator*() const {
return *ptr_;
}
T* operator->() const {
return ptr_;
}
引用计数查询
cpp复制std::size_t use_count() const {
return ref_count_ ? ref_count_->load(std::memory_order_acquire) : 0;
}
这里使用 memory_order_acquire 是为了:
- 获取最新的计数器值
- 与 release 操作建立同步关系
- 避免读取到过时的值
资源管理接口
cpp复制T* get() const {
return ptr_;
}
void reset(T* ptr = nullptr) {
release();
ptr_ = ptr;
ref_count_ = ptr ? new std::atomic<std::size_t>(1) : nullptr;
}
4. 测试验证:确保线程安全
4.1 基础功能测试
cpp复制void test_basic_functionality() {
// 测试构造
shared_ptr<int> p1(new int(42));
assert(p1.use_count() == 1);
// 测试拷贝构造
{
shared_ptr<int> p2 = p1;
assert(p1.use_count() == 2);
assert(p2.use_count() == 2);
}
assert(p1.use_count() == 1);
// 测试移动构造
shared_ptr<int> p3 = std::move(p1);
assert(p1.get() == nullptr);
assert(p3.use_count() == 1);
// 测试reset
p3.reset(new int(100));
assert(p3.use_count() == 1);
}
4.2 线程安全压力测试
cpp复制void test_thread_safety() {
shared_ptr<int> shared(new int(0));
const int thread_count = 10;
const int iterations = 100000;
auto worker = [&shared]() {
for (int i = 0; i < iterations; ++i) {
shared_ptr<int> local(shared); // 增加引用计数
// 模拟一些操作
std::this_thread::yield();
} // 减少引用计数
};
std::vector<std::thread> threads;
for (int i = 0; i < thread_count; ++i) {
threads.emplace_back(worker);
}
for (auto& t : threads) {
t.join();
}
assert(shared.use_count() == 1); // 最终引用计数应为1
std::cout << "Thread safety test passed!" << std::endl;
}
这个测试创建了10个线程,每个线程进行10万次拷贝构造和析构操作。如果实现正确,最终引用计数应该回到初始值1。
5. 性能优化与注意事项
5.1 内存序的选择策略
-
release() 中使用 acq_rel:
- 保证资源释放前的所有修改对其他线程可见
- 确保看到其他线程的所有修改后才执行delete
-
拷贝操作中使用 relaxed:
- 只需要保证计数器增加的原子性
- 不涉及其他内存操作的同步
-
use_count() 中使用 acquire:
- 获取最新的计数器值
- 与release操作建立同步关系
5.2 常见陷阱与解决方案
-
自赋值问题:
cpp复制shared_ptr<int> p(new int(10)); p = p; // 如果没有自赋值检查,���导致提前释放 -
异常安全:
- 构造函数中new原子计数器失败时,需要确保不泄露原始指针
- 移动操作标记为noexcept保证容器操作的异常安全
-
循环引用:
- 简化版实现不考虑weak_ptr,因此需要注意避免循环引用
- 实际项目中应使用std::weak_ptr解决这个问题
5.3 性能对比
我们实现的简化版与std::shared_ptr的主要性能差异:
-
优点:
- 更简单的实现,没有类型擦除开销
- 精确控制的内存序可能带来特定场景的性能优势
-
缺点:
- 缺少标准库的优化(如局部引用计数)
- 没有weak_ptr支持
- 缺乏自定义删除器等高级功能
在实际测试中,这个简化版在纯拷贝/析构操作上可能比std::shared_ptr快10-15%,但在完整功能场景下,标准库实现通常更优。
6. 扩展思考:如何实现weak_ptr?
虽然我们的简化版没有实现weak_ptr,但理解其原理很有价值:
- 需要第二个计数器(weak count)来跟踪weak_ptr的数量
- 控制块设计:通常将引用计数和弱引用计数放在同一个控制块中
- 对象销毁时机:
- 当strong count归零时销毁托管对象
- 当strong和weak count都归零时销毁控制块
实现weak_ptr的关键在于管理这两个计数器的复杂交互,这超出了本文的范围,但值得作为进阶课题研究。
通过这个简化版shared_ptr的实现,我们深入理解了原子操作在智能指针中的应用。关键收获包括:
- 引用计数必须使用原子操作保证线程安全
- 不同的内存序选择对性能和正确性有重大影响
- 移动语义可以显著提升性能
- 异常安全和线程安全需要仔细考虑
这个实现虽然简化,但涵盖了智能指针最核心的并发安全问题。理解这些原理后,使用标准库的std::shared_ptr会更加得心应手,也能更好地处理相关的并发问题。
