前言
随着现代处理器的发展和多核多CPU体系结构的大面积应用,C和C++编程面临着更加复杂和陡峭的学习曲线。特别是基于多线程带来的并行编程,带来了很多内存并行访问的问题。这需要非常专业的知识,深入了解CPU指令集,内存访问,CPU Cache等体系结构的底层知识,才能正确写好高性能和安全的并行程序。最近十多年,学术和工业界在并行编程方面进行了非常多创新的探索和研究,总结出一套优秀的编程实践和并行内存管理组件,并在Linux内核和大型开源软件中广泛应用。这里选取和内存对象管理有关的三个编程组件进行介绍,分别是引用计数Reference Counting,Hazard Pointer和RCU,都属于延迟处理类型的组件。本文目的一是为了个人学习的总结,另外也是给更多感兴趣的同学以启发。
引用计数
引用计数的思想很简单,通过原子变量来追踪对象的引用数量,来防止错误地销毁对象。这种思想最早可以追溯到20世纪40年代:当时工人们如果要修理危险的大型机械,他们会在进入机器之前,在机器开关上面挂一把锁,防止他在里面的时候被其他人误开机器。这也说明了引用计数的作用:通过计数来管理对象的生命周期。
以shared pointer为例,参考gcc shared_ptr实现,做了一些简化,样例代码如下:
// 计数管理类
template <typename T>
class RefCount {
public:
RefCount(T *p = nullptr) : ptr_(p), cnt_(1) {}
~RefCount() {
// 如果计数为0,销毁自己
if (Release()) {
delete this;
}
}
RefCount(const RefCount<T> &rc) = delete;
void operator=(const RefCount<T> &rc) = delete;
// 原子++
void AddRef() {
++cnt_;
}
// 原子--,如果计数为0,销毁保存的对象
bool Release() {
if (cnt_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ptr_;
return true;
}
return false;
}
// 返回管理的指针
T *Get() const noexcept { return ptr_; }
private:
T *ptr_; // 管理的对象指针
std::atomic_int32_t cnt_; // 原子计数
};
// 包装为智能指针
template <typename T>
class SharedPtr {
public:
SharedPtr() noexcept : rc_(nullptr) {}
SharedPtr(T *p) : rc_(nullptr) { rc_ = new RefCount(p); } // 创建引用计数对象
SharedPtr(const SharedPtr<T> &sp) : rc_(sp.rc_) {
if (rc_ != nullptr) {
rc_->AddRef(); // 增加计数
}
}
// 计数减一
~SharedPtr() { rc_->Release(); }
// 获取裸指针
T *Get() { return rc_->Get(); }
// 拷贝操作,当前指针计数减一,被拷贝指针计数加一
SharedPtr &operator=(const SharedPtr<T> &p) {
RefCount<T> *tmp = p.rc_;
if (tmp != rc_) {
if (tmp != nullptr) {
tmp->AddRef(); // 拷贝调用也增加计数
}
if (rc_ != nullptr) {
rc_->Release(); // 当前管理的计数减少引用
}
rc_ = tmp;
}
return *this;
}
private:
RefCount<T> *rc_; // 计数对象
};
整个代码是比较易懂的。一般来说引用计数都是采用原子变量在构造和析构的时候分别+1和-1的。当计数为0的时候,则销毁管理的对象。
但需要注意的是,cpp标准库的shared_ptr以及上面的样例代码都不是线程安全的。如果两个线程同时操作一个SharedPtr对象,那么很可能会导致内存错误。典型的问题就在SharedPtr的Get方法以及拷贝构造函数里,可以明细看出拷贝构造函数并不是线程安全的实现,并且Get方法也很有可能获取一个已经释放的对象指针。这也是生产代码容易误用shared pointer之处。
为了让引用计数更加鲁棒,还需要进一步升级。以FB的folly库的AtomicSharedPtr为代表,实现了原子变更“对象指针+引用计数+alias对象”的功能,真正实现线程安全的原子引用计数对象管理。
如果要实现一个AtomicSharedPtr,需要解决的一个问题就是如何用一个原子操作同时变更指针+引用计数。好在x64平台的虚拟内存地址有个机制是地址的高16位都是0,可以利用这16位做引用计数,就可以基于64位的CAS实现同时变更指针+引用计数的功能了。这也是folly中PackedSyncPtr基本原理。基于这个功能,就可以实现一个线程安全的AtomicSharedPtr,用来在多线程环境下管理对象的生命周期了。详情可以参考folly代码。
引用计数的优点在于不会死锁(lock-free),自动回收内存,不感知线程,和TLS无关。缺点是高并发读的时候竞争会比较激烈,高并发读写性能并不太好。在TLS支持缺失,以及需要避免死锁,或者需要自动回收内存的场景下,适合用引用计数的方法。