19. 手写一个简易 shared_ptr:引用计数类、线程安全递增递减、析构逻辑
说实话,shared_ptr 是 C++ 里我最常用的智能指针之一。但用了这么多年,你真的理解它底层的引用计数是怎么工作的吗?
我记得刚入行那会儿,有个同事在项目里自己封装了一个“智能指针”,结果线上服务跑着跑着就崩了。查了半天,发现是引用计数在多线程环境下被写乱了。嗯,从那以后我就养成了一个习惯——凡是涉及共享所有权,必须搞清楚引用计数的线程安全。
今天我们就手写一个简易的 shared_ptr。不追求完美,但要把核心逻辑讲透。
核心设计思路
一个 shared_ptr 说白了就两样东西:
- 一个指向原始对象的指针
- 一个指向引用计数的指针
引用计数是个整数,记录有多少个 shared_ptr 共享同一个对象。当计数归零时,就释放对象。
你想想看,如果每个 shared_ptr 都自己存一份计数,那还怎么共享?所以计数必须放在堆上,所有实例共享同一份。
关键点:引用计数对象本身也需要动态分配,并且要保证线程安全。
引用计数类
我们先写一个引用计数类。它只做一件事:管理一个整数,并提供原子操作。
#include <atomic>
class RefCount {
public:
explicit RefCount(int initial = 1)
: count_(initial) {}
// 递增,返回新值
int increment() {
return ++count_;
}
// 递减,返回新值
int decrement() {
return --count_;
}
int get() const {
return count_.load();
}
private:
std::atomic<int> count_;
};
这里我用了 std::atomic<int>。为什么不用普通的 int?
我在项目中遇到过这样一个坑:两个线程同时对一个 int 做 ++ 操作,结果只加了 1 次。因为 ++ 不是原子操作,它分“读-改-写”三步。两个线程同时读到了同一个值,各自加 1 再写回去,就覆盖了对方的修改。
用 std::atomic 就能保证 ++ 和 -- 是原子的。说白了,就是硬件级别帮你加锁了,性能比用 mutex 好得多。
线程安全递增递减
刚才的 RefCount 类已经用了原子操作,所以递增递减本身就是线程安全的。但这里有个细节:递减后判断是否为零,这个“判断”动作也必须和“递减”绑定在一起,不能分开。
举个例子:
// 错误写法
if (ref_count.decrement() == 0) {
delete object;
}
这段代码有问题吗?
有。假设两个线程同时调用 decrement(),都返回了 0。然后两个线程都去 delete object,double free 就来了。
正确的做法是:只有最后一个递减的线程负责释放资源。因为原子操作保证了只有一个线程能看到计数变为 0。
注意:递减操作本身是原子的,但“递减+判断”这个组合操作,必须由同一个线程连续完成,中间不能被其他线程打断。好在 std::atomic 的 -- 操作返回的是修改后的值,所以判断是安全的。
析构逻辑
析构逻辑其实就一句话:递减引用计数,如果归零则释放对象和计数本身。
但这里有个容易忽略的点:shared_ptr 管理的是两个资源——原始对象和引用计数对象。两者都需要释放。
~shared_ptr() {
if (ref_count_) {
if (ref_count_->decrement() == 0) {
delete ptr_; // 释放原始对象
delete ref_count_; // 释放引用计数
}
}
}
我个人习惯把析构逻辑封装成一个辅助函数,这样代码更清晰:
void release() {
if (ref_count_ && ref_count_->decrement() == 0) {
delete ptr_;
delete ref_count_;
ptr_ = nullptr;
ref_count_ = nullptr;
}
}
完整实现
下面是一个简易 shared_ptr 的完整代码。我刻意省略了 make_shared、weak_ptr 等高级功能,只保留核心逻辑。
#include <atomic>
#include <utility>
class RefCount {
public:
explicit RefCount(int initial = 1) : count_(initial) {}
int increment() { return ++count_; }
int decrement() { return --count_; }
int get() const { return count_.load(); }
private:
std::atomic<int> count_;
};
template<typename T>
class shared_ptr {
public:
// 默认构造
shared_ptr() : ptr_(nullptr), ref_count_(nullptr) {}
// 从原始指针构造
explicit shared_ptr(T* ptr)
: ptr_(ptr), ref_count_(new RefCount()) {}
// 拷贝构造
shared_ptr(const shared_ptr& other)
: ptr_(other.ptr_), ref_count_(other.ref_count_) {
if (ref_count_) {
ref_count_->increment();
}
}
// 拷贝赋值
shared_ptr& operator=(const shared_ptr& other) {
if (this != &other) {
release(); // 释放当前资源
ptr_ = other.ptr_;
ref_count_ = other.ref_count_;
if (ref_count_) {
ref_count_->increment();
}
}
return *this;
}
// 移动构造
shared_ptr(shared_ptr&& other) noexcept
: ptr_(other.ptr_), ref_count_(other.ref_count_) {
other.ptr_ = nullptr;
other.ref_count_ = nullptr;
}
// 移动赋值
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;
}
// 析构
~shared_ptr() {
release();
}
// 基本接口
T* get() const { return ptr_; }
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
int use_count() const { return ref_count_ ? ref_count_->get() : 0; }
explicit operator bool() const { return ptr_ != nullptr; }
private:
T* ptr_;
RefCount* ref_count_;
void release() {
if (ref_count_ && ref_count_->decrement() == 0) {
delete ptr_;
delete ref_count_;
}
ptr_ = nullptr;
ref_count_ = nullptr;
}
};
引用计数生命周期图
下面这张图展示了两个 shared_ptr 共享同一个对象时,引用计数的变化过程:
避坑指南
我曾经在代码里犯过一个低级错误:在拷贝赋值操作符里,先 release() 再拷贝指针。结果如果 other 和 this 指向同一个对象,release() 直接把对象释放了,后面拷贝的就是野指针。
所以正确的顺序是:先递增 other 的计数,再释放当前资源。这就是为什么标准库的 shared_ptr 赋值操作符里有个“先增后减”的惯用法。
小技巧:如果你不想自己处理这些边界情况,直接用 std::shared_ptr 就好。手写只是为了理解原理,生产环境还是用标准库更稳妥。
总结
手写 shared_ptr 的核心就三点:
- 引用计数必须放在堆上,所有实例共享
- 递增递减必须是原子操作,否则多线程下会出问题
- 析构时只有最后一个线程负责释放,判断条件就是递减后计数为 0
嗯,其实标准库的实现比这个复杂得多。它还要考虑 weak_ptr、make_shared 的内存优化、自定义删除器等等。但核心逻辑,就是我们今天写的这些。
下次面试如果被问到 shared_ptr 的实现,你可以从引用计数讲起,再聊到线程安全,最后提一嘴析构的竞态条件。相信我,面试官会对你刮目相看的。