并行编程之内存管理(总结)

这篇总结并行编程的三种常见的内存管理方法,三种方法如下: Reference Counting:并行编程之内存管理(一) Hazard Pointer:并行编程之内存管理(二) RCU:并行编程之内存管理(三) 三种方法各有利弊,优缺点对比如下: 引用计数(Reference Counting) Hazard Pointer RCU 读性能 低,不可扩展 高,可扩展 高,可扩展 可保护的对象数量 可扩展 不可扩展 可扩展 保护周期 支持长周期 支持长周期 用户必须限制保护周期长度 遍历是否需要重试 如果和删除冲突需要重试 如果和删除冲突需要重试 不需要 存在性保证 复杂 保证 保证 读竞争 高 无 无 对象遍历开销 CAS原子操作,内存屏障,cache missing 内存屏障 无 读遍历机制 无锁 无锁 有限制的无等待(wait free) 读引用获取 可能失败 可能失败 不会失败 内存开销 有限 有限 无限 内存回收机制 无锁 无锁 阻塞 自动回收 是 部分 部分 已经有很多C++项目,包括开源和大厂内部项目,都开始采用Hazard Pointer和RCU来实现并发数据结构,而且C++标准委员会也已经在讨论将这两个组件加入到C++26标准中。 时代在变,并行编程技术也在突飞猛进。多线程多核(multi-core)技术,甚至甚多核(many-core)技术都在飞速发展,加上各种并行编程范式的应用,可以预见到在一段时期内,并行编程将面临百花齐放,技术爆发的局面。

June 14, 2022 · Skyan

并行编程之内存管理(三)

RCU(Read-Copy-Update) 前述的几种内存管理方式只能同时管理一个内存对象,而RCU是一种同时管理多个对象的并发读写的方法。它实际上是提供了一种受保护的内存区域,通过RCU方法,将对象关联到受保护内存区域后,可以保证读操作完成以后才会回收对象,而且读操作开销极其低(5ns左右),适合多个对象的批量保护,以及读多写少的场景。自从2002年RCU功能引入Linux内核以后,在内核中使用次数突飞猛进,如下图所示: 在内核中RCU API被广泛应用于网络,内存管理,文件系统等内核组件中。除了内核,RCU也有开源的user space RCU库,被广泛应用于QEMU模拟器,DPDK,Go语言的垃圾回收等项目中。 那么RCU到底是什么神秘的方法呢?其实原理正如其名:Read-Copy-Update。当需要修改对象的时候,先拷贝这个对象,修改产生新版本对下,然后变更新版本对象,等待老版本对象的reader完成读操作,再销毁老版本对象。可以明显看出,这个思想和Copy-On-Write思想一脉相承,和MVCC的思想更是极其类似。在生产环境中,按照类似原理实现的支持并行变更的数据结构也屡见不鲜,例如索引领域的hashmap,B+树等数据结构都可以按照类似思想实现并发读写。因此,思想并不新,但如果要正确实现,有三个原则需要保证,有很多细节需要处理,这也是RCU比较神秘之处。 RCU正确实现的三个原则是:宽限期保证(grace-period guarantee),发布-订阅保证(publish-subscribe)和内存顺序保证(memory-order guarantee)。解释如下: 宽限期保证 允许所有的更新操作等待之前存在的读操作完成。之前存在的读操作其实就是还在访问修改前的内存对象的reader,因此如果不等待这些reader就销毁老版本的对象,则会导致并发错误。在一个链表的场景中,先删除一个节点的链接,然后通过宽限期保证等待所有该节点的访问结束之后,就可以安全销毁这个节点。 相关的RCU接口有rcu_read_lock,rcu_read_unlock, synchronize_rcu,其中rcu_read_lock,rcu_read_unlock用于读操作,标记当前线程正在读该内存区域,rcu_read_lock,rcu_read_unlock之间访问的所有内存对象被称为读端临界区(read-side critical section)。synchronize_rcu接口用于执行宽限期保证,等待所有还在读老版本数据的线程完成。 发布-订阅保证 该保证是说所有reader读取一个新版本对象的时候,可以保证该对象已经初始化好了。这里reader就是所谓的订阅者,RCU通过rcu_assign_pointer接口实现发布操作,RCU的这个保证可以保证订阅者看到的一定是发布者发布时刻的对象,不存在乱序等因素的干扰。实现上,发布方可以通过std::memory_order_release操作来更新对象,而读方可以通过std::memory_order_consume来读取对象,这样就可以避免乱序读取的情况发生。 内存顺序保证 该保证是宽限期保证的自然延续。读端临界区之间保护的内存对象和synchronize_rcu调用之间,存在一个严格保证的内存顺序关系。 下面几个例子说明该顺序的含义,其中X和Y的初始值都为0: 类别 图示 描述 情况1 如果按照绿色箭头的顺序执行,RCU可以保证读端临界区之后,即rcu_read_unlock发生之后才发生synchronize_rcu,这样如果r2==1,那么可以保证r1==0 情况2 如果CPU 0在读端临界区间运行时发生synchronize_rcu调用,RCU可以保证synchronize_rcu之前发生的事件一定在读端临界区中生效。在这个例子中,如果r2==1,那RCU一定保证r1==1 情况3 如果两个读端临界区并行执行,RCU是无法保证两者之间的内存顺序的。在这个例子中,CPU0和CPU2的读端临界区中对象的读取顺序是无法保证的,因此如果按照绿色箭头执行,r1==0,r3==0,是无法保证X=1和r2=X之间不乱序的 情况4 如果RCU的读端临界区和宽限期保证synchronize_rcu调用重叠,如果按照绿色箭头执行顺序,RCU可以保证读端临界区先于synchronize_rcu执行之前,即r1==1,r2==0 那么,如何实现RCU的呢?实际上,一个最简单的实现方式只需要10行代码,如下所示: static void rcu_read_lock(void) { spin_lock(&rcu_gp_lock); } static void rcu_read_unlock(void) { spin_unlock(&rcu_gp_lock); } void synchronize_rcu(void) { spin_lock(&rcu_gp_lock); spin_unlock(&rcu_gp_lock); } 可以看出rcu_read_lock和rcu_read_unlock通过一把锁来保证和synchronize_rcu之间严格的顺序关系,同时synchronize_rcu必须等待rcu_read_unlock执行完成之后才能执行。当然,这种naive的实现性能最低,而且不支持并发读,在生产环境中完全不可用。 有几种可以考虑的改进思路: 通过引用计数的方式来实现并发读; 通过TLS的方式减少读之间的竞争; 通过多版本的方式来减少锁竞争。 这样一个优化的实现如下所示: DEFINE_SPINLOCK(rcu_gp_lock); // 全局spin lock,用于保证并发只有一个写线程 DEFINE_PER_THREAD(int [2], rcu_refcnt); // 每个线程缓存一个引用计数 long rcu_idx; // 全局多版本号 DEFINE_PER_THREAD(int, rcu_nesting); // 每个线程缓存一个内嵌计数,用于支持读端临界区的重复进入 DEFINE_PER_THREAD(int, rcu_read_idx); // 每个线程缓存一个版本号 // 进入读端临界区 static void rcu_read_lock(void) { int i; int n; n = __get_thread_var(rcu_nesting); if (n == 0) { // 第一次进入的时候更新引用计数 i = atomic_read(&rcu_idx); // 获取可用的全局版本号 __get_thread_var(rcu_read_idx) = i; // 在对应版本计数 __get_thread_var(rcu_refcnt)[i]++; } __get_thread_var(rcu_nesting) = n + 1; // TLS内嵌计数++ smp_mb(); } // 退出读端临界区 static void rcu_read_unlock(void) { int i; int n; smp_mb(); n = __get_thread_var(rcu_nesting); if (n == 1) {// 最后一次退出的时候更新引用计数 i = __get_thread_var(rcu_read_idx); // 获取可用的全局版本号 __get_thread_var(rcu_refcnt)[i]--; // 在对应版本更新计数 } __get_thread_var(rcu_nesting) = n - 1; // TLS内嵌计数-- } // 反转全局版本号,保证所有读线程完成 static void flip_counter_and_wait(int i) { int t; atomic_set(&rcu_idx, !...

June 8, 2022 · Skyan

并行编程之内存管理(二)

Hazard Pointer Hazard Pointer的思想和引用计数恰好相反。不在数据内部存储一份引用计数,而是从外部来管理对象。流程如下:首先为每个需要管理的对象标记一个指针,放到一个全局或者per-thread的list中。在这个list中的标记指针就叫做hazard pointer。需要销毁对象的时候将hazard pointer放到一个待回收list,根据一些策略来决定是否正式销毁对象。销毁待回收的对象的方式还有主动和异步的接口,主动的方式是直接调用接口回收销毁。异步是通过异步线程定期销毁。销毁回收对象的时候,首先查看下hazard pointer list中有没有指向这个对象指针,有就不回收,没有就可以安全回收。其实本质上通过统计指向这个对象的hazard pointer数量来作为“引用计数”。将复杂性放到回收阶段,这也是为什么hazard pointer适合读多写少的场景的原因。 实现Hazard Pointer的主要复杂度在于管理正确处理被保护的hazard pointer和待回收的hazard pointer之间的关系。对于C++来说,可以通过构造/析构函数(RAII)来自动实现加入/退出hazard pointer的保护。整个流程可以见如下示意图所示: 从上图可以看出,销毁hazard pointer所管理的对象是通过延迟销毁来解决。通过对比被销毁的hazard pointer地址和当前正在被保护的hazard pointer地址,从而避免销毁掉正在使用中的指针对象,从而实现保护的目的。 作为样例,我们首先实现一个全局的hazard pointer基类,所有需要拥有hazard pointer保护功能的类都需要继承这个基类。 // 该类实现基本的hazard pointer回收方法 class HazPtrObj { using ReclaimFnPtr = void (*)(HazPtrObj*); template <typename T> friend class HazPtrObjBase; friend class HazPtrDomain; // 对象回收函数 ReclaimFnPtr reclaim_; // 下一个待回收对象。本质上是通过这个指针,将所有待回收对象连成一个链表 HazPtrObj* next_; // 将自身加入到待回收对象list中,依赖全局对象domain,domain统一管理所有已保护和待回收的hazard pointer list void push_obj(HazPtrDomain& domain) { hazptr_domain_push_retired(this, this, domain); } // 对象回收函数 ReclaimFnPtr reclaim() noexcept { return reclaim_; } // 原始指针 const void* raw_ptr() const { return this; } public: HazPtrObj() noexcept : next_(this) {} HazPtrObj(const HazPtrObj& o) noexcept : next_(this){} HazPtrObj(HazPtrObj&& o) noexcept : next_(this) {} /** Copy operator */ HazPtrObj& operator=(const HazPtrObj&) noexcept { return *this; } /** Move operator */ HazPtrObj& operator=(HazPtrObj&&) noexcept { return *this; } HazPtrObj* next() const noexcept { return next_; } void set_next(HazPtrObj* obj) noexcept { next_ = obj; } }; // hazard pointer基类,所有需要保护的类都继承该类 template <typename T> class HazPtrObjBase: public HazPtrObj { // 默认的对象回收函数 void delete_obj(T* p) { delete p; } public: // 将this放到待回收list中,准备被回收 void retire(HazPtrDomain& domain = default_hazptr_domain()) { assert(next_ == this); this->reclaim_ = [](HazPtrObj* p) { auto hobp = static_cast<HazPtrObjBase*>(p); auto obj = static_cast<T*>(hobp); hobp->delete_obj(obj); }; this->push_obj(domain); } }; 实现代码也比较直观,通过继承HazPtrObjBase类,任意一个对象都可以拥有一个retire方法,这个方法可以认为和delete等同用于销毁对象,但并不是立即回收,而是通过hazard pointer的延迟回收。 接下来需要实现一个保护HazPtrObjBase的对象,如下样例代码所示:...

June 8, 2022 · Skyan

并行编程之内存管理(一)

前言 随着现代处理器的发展和多核多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....

June 6, 2022 · Skyan