无锁数据结构(机制篇):内存管理规则

我在《无锁数据结构(基础篇):内存模型》已经提到,实现无锁数据结构最大的两个困难,一是ABA问题,二是内存回收。即便它们之间有联系,却鲜有两全其美的办法,同时解决这两大难题,因此我将其分为两个问题进行讨论。

 

本文中我将论述无锁容器几种流行的内存安全回收方法,并在Michael-Scott经典的无锁队列中展示其中的几种。

 

标签指针(Tagged pointers)

 

标签指针作为一种规范由IBM引入,旨在解决ABA问题,它可能是解决此类问题最流行的算法。依据此规则,每个指针代表一组原子性的内存单元地址和标签(32比特的整数)

 

template <typename T>

struct tagged_ptr {

    T * ptr ;

    unsigned int tag ;

    tagged_ptr(): ptr(nullptr), tag(0) {}

    tagged_ptr( T * p ): ptr(p), tag(0) {}

    tagged_ptr( T * p, unsigned int n ): ptr(p), tag(n) {}

    T * operator->() const { return ptr; }

};

 

标签作为一个版本号,随着标签指针上的每一次CAS运算而增加,并且只增不减。一旦需要从容器中非物理地移除某个元素,就应将其放入一个盛放空闲元素的列表中。在空闲元素列表中,逻辑删除的元素完全有可能被再次调用。因为是无锁数据结构,一个线程删除X元素,另外一个线程依然可以持有标签指针的本地副本,并指向元素字段。因此需要一个针对每种T类型的空闲元素列表。多数情况下,将元素放入空闲列表中,意味着调用这个T类型数据的析构函数是非法的(考虑到并行访问,在析构函数运算的过程中,其它线程是可以读到此元素的数据)。

 

当然,标签指针规则还有以下缺陷:

 

  • 此规则由平台实现,因此该平台必须拥有一个基于dwCAS的原子性CAS原语。需要指出的是,32位的现代操作系统支持dwCAS 64比特的字运算,而所有的现代计算机架构都一套完整的64位指令集。在64比特的操作模式中,dwCAS需要128比特,至少96比特。但不是所有的架构中都实现了dwCAS。

 

简直是胡说八道,一派胡言!

 

有经验的无锁编程人员可能认为,没有必要用一个128比特或96比特的CAS去实现标签指针。完全可以用64比特完成,因为现代处理器只采用48比特寻址,还有16比特闲置,完全可以用它来做标签计数器,例如boost.lockfree库

但是本方法存在两个问题:

 

  • 问题一,谁能保证剩余的16位地址将来不会被用到?一旦内存芯片领域取得一个大的突破,即内存容量徒增,供应商可能会马上提供64比特完整的寻址处理器。

  • 问题二,16比特足够存储标签吗?相关研究表明,16比特是不够的。在此情况下,内存溢出的可能性很大,这也增加了ABA问题发生的可能。不过32比特是足够了。

 

确实如此,16比特标签的取值范围0-65535。现代操作系统,单个线程的时间片执行大约30万到50万条汇编指令(来自Linux开发人员的数据)。然而,当处理器性能增加时,时间片也会跟着增加;因此6.5万个难度较大的CAS运算也是可以执行的(即使现在不可以,未来绝对没问题)。所有采用16比特的标签,就有面对ABA问题的风险。

 

  • 空闲列表通常以无锁栈或者无锁队列的方式实现,同样也会引起性能问题:无论是空闲列表中元素移除或者是添加,至少有一个CAS会被调用。不过,空闲列表的在某些方面却在提高性能。即便空闲列表不为空,也没有必要引用系统函数,此类函数通常运行很慢,并且需要同步分配内存。

  • 针对每种数据类型提供单独的空闲列表(free list),这样做太过奢侈难以被大众所接收,一些应用使用内存太过低效。例如,无锁队列通常包含10个元素,但可以扩展到百万,比如在一次阻塞后,空闲列表扩展至百万。这样的行为通常是非法的。

 

由此可见,标签指针规则是解决ABA问题的诸多算法中的一种,但它不能解决内存回收的问题。

 

截止目前,libcds库中的无锁容器没有使用标签指针。尽管实现起来相对简单,此规则依然可能会使已使用的内存增长变得难以管控,因为无锁适用于任何一种容器类型。libcds库中,无锁算法采用可预测内存使用方式,而非dwCAS。而boost.lockfree库在标签指针规则方面有很好的应用。

 

标签指针应用案例

 

对那些喜欢壁纸的人来说,如果可能的话,带有标签指针的MSQueue伪码壁纸也是可以的。不可否认,无锁算法确实健壮。使用std:atomic简单地做一个应用。

 

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

 

让我们仔细观察位于入队和出队前面的算法,通过这些例子,你可以看到几种标准的无锁数据结构构建方式。

 

请注意这两种方法都包含循环—运算上下文不断的在重复,直到成功执行为止(也有可能无法成功执行,比如从一个空队列中进行出队列运算)。这种重复循环方式是一种典型的无锁编程方式。

 

队列首个元素,即m_Head指向的元素为哑节点,确保指向队列起始和结束的指针永远都不为空。判断一个空队列的条件是 m_Head == m_Tail且m_Tail->next == NULL,见D6到D8行 。条件m_Tail->next == NULL尤为重要,这样往队列里加数据,并不会改变m_Tail。第E9行仅仅改变 m_Tail->next,眨眼一看,enqueue()执行break跳出循环。实际上,任何方法或者线程m_Tail均可以被改变。入队添加元素时,E8行必须检查m_Tail是否指向末尾元素即m_Tail->next == NULL;如若不然,如本例,执行E12行,尝试先将指针指向末尾元素。同样,在元素出队时,倘若m_Tail并未指向末尾元素,执行D9行使其指向末尾元素。本段代码是一种广为流行的无锁编程方法:线程互助法。某个运算的算法可以扩展到容器的其它所有运算中,这样,该运算剩余的工作,就可以借由其它线程所调用的运算加以完成。

 

进一步观察,E5-E6行和D2-D4行,运算所需的指针值存于局部变量中。接着,E7、D5行比较计算值和原值。这是一个典型的无锁方法,仅限于并发编程,此刻读到的原值是可以被改变的。倘若不禁止编译器优化某些共享数据队列访问,一些“聪明”的编译器会删除E7或者D5比较行,因此需将m_Head以及m_Tail定义为C++原子类型,而在本伪码中为volatile类型。

 

此外,大家记住CAS原语是将目标地址值和某个既定值进行比较,若这俩值相等,CAS则依据目标内存地址设置新值。对CAS原语来说,推断本地拷贝是否为当前值是必需的。CAS(&val, val, newVal) 通常都会成功执行。

 

现在,我们设想这样的场景,在出对方法中,D11行复制数据,之后,执行D12行,在队列中移除该元素。不过元素的删除即D12行m_Head前移有可能失败,在此情况下,D11数据复制会被反复执行。从C++的角度看,队列中的数据存储,其实现不宜太过复杂,否则赋值运算负载会很大。令人担心的是,高负载情况下,CAS原语失败的可能会很大。

 

人们自然想到了优化,将D11移到循环外边,但这会导致一个错误:next元素很可能会被另一线程删除。因为遵循标签指针规范,其中的元素并没有被删除,因此优化最终会导致这样一个结果,返回一个错误数据;尽管D12行执行成功,但返回的数据并不在队列中。

 

Peculiarities of M&S queue MS 队列的特点

 

MSQueue有趣的地方就在于 m_Head一直会指向哑节点,即非空队列的首个元素为m_Head元素的下一个元素。非空队列第一个元素出队列,即读取m_Head的下一个元素。倘若哑元素被删除,接下来的元素便接替成为哑元素,即队列的头,最后返回后者的值。因此只有在下一次出对运算结束之后,才可以添加新元素。开发者试图采用cds::intrusive::MSQueue的侵入式变量,这些特点会引发很多问题。

 

基于周期的内存回收(Epoch-based reclamation)

 

Fraser [Fra03]引入周期规则,采用延迟删除,即在安全时刻再删除,也即确信任何线程的引用不再指向待删除元素时再删除。周期规则采取如下的保护策略:拥有一个全局周期nGlobalEpoch,并且单个线程运行于对应的局部周期nThreadEpoch中。某个线程进入周期规则保护的代码中,此时若该线程局部周期小于等于全局周期,局部周期的值便相应增加。而所有的线程进入全局周期,nGlobalEpoch的值便自增。

 

该规则伪码如下

 

// global epoch

static atomic<unsigned int> m_nGlobalEpoch := 1 ;

const EPOCH_COUNT = 3 ;

// TLS data

struct ThreadEpoch {

    // global epoch of the thread

    unsigned int        m_nThreadEpoch ;

    // the list of retired elements

    List<void *>        m_arrRetired[ EPOCH_COUNT ] ;

  

    ThreadEpoch(): m_nThreadEpoch(1) {}

    void enter() {

       if ( m_nThreadEpoch <= m_nGlobalEpoch )

          m_nThreadEpoch = m_nGlobalEpoch + 1 ;

    }

    void exit() {

       if ( all threads are in the epoch which m_nGlobalEpoch ) {

          ++m_nGlobalEpoch ;

          empty (delete) the elements

          m_arrRetired[ (m_nGlobalEpoch – 2) % EPOCH_COUNT ]

          of all threads ;

       }

    }

} ;

 

无锁容器中被清空的元素放入局部线程列表m_arrRetired中,而该列表中有m_nThreadEpoch % EPOCH_COUNT个等待删除的元素。一旦m所有线程通过全局周期m_nGlobalEpoch,此时便可以清空周期m_nGlobalEpoch — 1的所有线程列表,同时m_nGlobalEpoch也会自增。

 

无锁容器的每个运算囊括在ThreadEpoch::enter()和ThreadEpoch::exit()方法中;类似于临界区。

 

lock_free_op( … ) {

    get_current_thread()->ThreadEpoch.enter() ;

    . . .

    // lock-free operation of the container.

    // we’re inside “the critical section” of the epoch-based scheme,

    // so we can be sure that no one will delete the data we’re working with.

    . . .

    get_current_thread()->ThreadEpoch.exit() ;

}

 

此规则相当简单,旨在保护容器运算中的局部引用,该引用指向无锁容器元素;但本规则不能保护容器运算以外的全局引用。因此,无法采用周期规则实现无锁容器的元素迭代器。此规则的缺点是,程序的所有线程需进入接下来的周期中(following epoch ),譬如,这些线程须指向某些无锁容器。倘若至少有一个线程未能进入接下来的周期,已废弃的元素就不能被删除。倘若线程存在不同的优先级,优先级低的线程会导致优先级高的线程延迟待删除元素增长变得不可控。一旦某个线程失败,周期规则会导致无限的内存消耗。

然而libcds库没有采用周期规则,因为我无法创建有效的算法,来判定所有线程是否抵达全局周期。也许,读者朋友可以给些好的建议!

 

冒险指针(Hazard pointer)

 

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

本规则由Michael [Mic02a, Mic03]创建,旨在保护局部引用,同样该引用指向无锁数据结构元素。这也许是当今世界最流行、研究最多的延迟删除规则了。此规则的实现仅依赖原子性读写,而未采用任何重量级的CAS同步原语。

 

此规则的核心职责是,声明一个指向无锁容器元素的指针,将其作为无锁数据结构运算的内部冒险指针。在调用元素前,先将其放入当前线程冒险指针所在的HP数组中,HP数组是线程私有的,即只有拥护该数组的线程才能写入HP数组,而所有线程通过Scan过程都可读取HP数组。(译者注:C++中有返回值的为函数,没有的称之为过程)。仔细分析各类无锁容器的运算之后,你会发现HP数组大小,即单个线程冒险指针的数目,最多为三或四。因此可以说,此规则下的负载不高。

 

大型数据结构

 

这些“大型”数据结构需要不止64个冒险指针。譬如,skip-list (cds::container::SkipListMap),这是一个随机数据结构。实际上,它是一个嵌套的列表,存储不同级别的元素。此类容器并不适合冒险指针规则,即使libcds中实现了基于此规则的skip-list。

 

冒险指针规则伪码 [Mic02]

 

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

 

调用RetireNode(pNode),删除无锁容器元素pNode时,此刻线程将pNode放入其局部数组dlist中,该数组用来存储待删除的废弃元素。数组dlist大小为R时,调用Scan()存储过程,删除废弃元素;R和N做比较,须大于N,比如R = 2N,而N = P*K。R > P*K这个条件很重要,若满足此条件, Scan()会删除数组中的废弃元素;而此条件一旦被打破,Scan()则无法删除任何元素,算法在此情况下出现错误,数组全部填满数据,却无法降低数组本身的大小。

 

Scan()过程分四个步骤:

 

  • 第一步, 声明用于存储冒险指针的数组plist,存储所有线程的非空冒险指针。此步骤仅能读取共享数据,即HP线程数组,而其它的步骤仅作用于局部数据。

  • 第二步,数组plist进行排序,为接下来的检索进行优化。同时,删除plist中的记账元素

  • 第三步,删除运算,遍历当前线程的数组dlist,倘若dlist[i]的元素在plist中,则说明某些线程正在调用此指针,此刻还不能删除该指针,该指针会留着在dlist中。倘若dlist[i]的元素不在plist中,说明没有线程调用该指针,可以进行删除。

  • 第四步,将new_dlist中未删除元素重新被放入dlist中,当R>N,Scan()存储会被调用,来降低数组dlist大小,某些元素会被成功删除。

 

通常来说,声明一个HP指针,代码实现如下:

 

std::atomic<T *> atomicPtr ;

T * localPtr ;

do {

    localPtr = atomicPtr.load(std::memory_order_relaxed);

    HP[i] = localPtr ;

} while ( localPtr != atomicPtr.load(std::memory_order_acquire));

 

首先,读取指向局部变量localPtr的原子性指针atomicPtr,将其放入当前线程冒险指针数组HP的槽点HP[i]中。接下来,需要检查已读取的atomicPtr值是否被其它线程更改。为了方便检查,我们再次读取atomicPtr,并与此前已读取的localPtr值进行比较。检查会一直持续下去,直至将atomicPtr的真实值放入数组HP中。一旦此指针存入冒险指针数组中,就意味着不能被任何线程物理删除。因此,该指针引用无法进行空闲内存区域的散列读取或写入。

 

冒险指针规则与C++原子性运算以及内存序列化相关的分析,细节参见文章 [Tor08]

 

MSQueue performed by Hazard Pointer  冒险指针的 MSQueue实现

 

无锁队列的冒险指针由Michael Scott实现,这里我提供一个纯粹的伪码,不涉及libcds库。

 

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

 

冒险指针是否有多个用途?是否适用于所有的数据结构?事实上,并非上节描述的那样,冒险指针数数目被限制在常数K以内。对大多数数据结构,有限的冒险指针是满足要求的,数组HP通常很小。但估算并发所需冒险指针数目的算法,难以实现。 排序的Harris列表[Har01]就是一个例子。在此算法中从列表中移除元素,无限长的链接亦会被删除,这导致HP规则变得不可用。

 

严格来说,HP规则是用来防止冒险指针数量的无限增多。对于此规则,其作者提供了详尽的实现指南。在libcds库中,我将精力集中在经典算法中,避免将HP规则复杂化,不然实现起来会更加困难。同冒险指针类似,但不那么流行的规则-踢皮球(Pass the Buck)亦是如此。在本规则中,采用冒险指针不限数目的方式,稍后我会介绍这些。

 

libcds中冒险指针实现

 

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

 

本图展示了libcds库的冒险指针算法的内部实现,核心算法-冒险指针管理器-作为一个单例放入.dll或.so动态链接库中。每个线程拥有一个对象-Thread HP Manager,持有K大小的HP数组,R大小的废弃指针数组。所有的Thread HP Manager放入列表中。线程的最大值为P。在libcds中的缺省值如下:

 

  • 冒险指针数组的大小K为8

  • 线程的数目P为100

  • 废弃待删除数据所在数组大小R为2 * K * P = 1600

 

ibcds中HP规则的实现方式分三步:

 

  • 内核-一个独立的基于HP规则的数据类型底层实现,命名空间为cds::gc::hzp。然而内核没有类型,因为数据类型T会被删除,无法依赖,因此核心被移入动态库中。数据类型信息缺失,无法调用该数据析构函数,准确地说,标记为删除的数据不一定被物理删除。比如,侵入式容器,调用处理器仿函数,模仿数据安全删除事件。但我们不知道,事件背后的处理器。

  • 实现级别,为一个典型的规则实现,位于cds::gc::hzp命名空间内部。此级别代表一组内核shell结构模板,用来存储数据类型,有点类似类型擦除。当然此级别不应放在程序中。

  • 接口级别,cds::gc::HP类,应用于libcds的无锁容器中。实际上是GC容器模板的参数值。从代码的角度看,cds::gc::HP类为一个轻量级的包装类,包装了实现级别的丛多小类。

 

重建缺失的数据类型

 

如果内核中数据类型缺失,析构函数该如何被调用,更确切地说,类型如何重建?其实很简单,数组日志为内核删除做好了准备,代码如下:

 

struct retired_ptr {

   typedef void (* fnDisposer )( void * );

   void *  ptr ; // Retiredpointer

   fnDisposer pDisposer; // Disposer function

   retired_ptr( void * p, fnDisposer d): ptr(p), pDisposer(d) {}

};

 

由此,废弃指针及其删除函数一起被保存了一下来。

 

Scan()方法调用基于HP规则的pDisposer(ptr)函数进行元素删除,pDisposer函数知道其参数类型。实现级别负责“透明”地生成此函数。譬如,物理删除做如下实现:

 

template <typename T>

struct make_disposer {

    static void dispose( void * p ) { delete reinterpret_cast<T *>(p); }

};

template <typename T>

void retire_ptr( T * p )

{

    // Place p into arrRetired array of ready for deletion data

    // Note that arrRetired are private data of the thread

    arrRetired.push( retired_ptr( p, make_disposer<T>::dispose ));

    // we call scan if the array is filled

    if ( arrRetired.full() )

       scan();

}

 

方法是简单了些,不过点子确实不错。

 

假如使用libcds库中基于HP规则的容器,在main()方法中声明cds::gc::HP类型的对象即可,采用HP规则的容器,就能将其与每个线程连接。假如基于cds::gc::HP实现自己的容器,就有必要了解HP规则API。

 

cds::gc::HP类的API 

 

cds::gc::HP类的所有方法都是静态的,需要强调的是,此类为一个单例包装类。

 

构造函数

 

HP(size_t nHazardPtrCount = 0,

   size_t nMaxThreadCount = 0,

   size_t nMaxRetiredPtrCount = 0,          

   cds::gc::hzp::scan_type nScanType = cds::gc::hzp::inplace);

 

  • nHazardPtrCount,冒险指针的最大数目,即规则常数K的大小

nMaxThreadCount ,为线程的最大数目,即规则常数P

nMaxRetiredPtrCount,废弃指针数组维度,即规则常数R=2K*P

nScanType,小部分优化

cds::gc::hzp::classic的值表明,非常有必要查看Scan算法伪码,cds::gc::hzp::inplace值允许Scan()中选择数组选择dlist弃用new_dlist。

应明确一点,只存在一个cds::gc::HP对象。

事实上,构造函数调用静态方法就是在初始化内核,虽然声明两个cds::gc::HP对象,不会生成两个冒险指针规则,重新初始化是安全的,但也没有必要。

  • 将指针放入当前线程的废弃数组中,即准备延迟删除。

 

template <class Disposer, typename T>

static void retire( T * p ) ;

template <typename T>

static void retire( T * p, void (* pFunc)(T *) )

 

Disposer参数pFunc定义了删除仿函数disposer

 

In the first case the call is quite pretentious:

struct Foo { … };

struct fooDisposer {

   void operator()( Foo * p ) const { delete p; }

};

// Calling myDisposer disposer for the pointer at Foo

Foo * p = new Foo ;

cds::gc::HP::retire<fooDisposer>( p );

 

static void force_dispose();

 

对冒险指针规则Scan()算法的强制调用,我不太确定在实际开发中是否有用,不过在libcds中有时很有必要。

 

另外,cds::gc::HP声明了三个重要的子类:

 

  • thread_gc ,包装类,含有初始化私有线程数据代码,该代码指向冒险指针规则。本类的构造函数,负责HP规则连接线程,而析构函数负责将线程同规则断开。

  • Guard,冒险指针

  • template <size_t Count> GuardArray,冒险指针数组。在应用HP规则时,往往需要一次性地声明一些冒险指针。最好是一次性地在此类数组中声明这些指针,而不是在几个Guard类型的对象中进行声明。

 

Guard类以及GuardArray类均是基于内部冒险指针数组的超级数据结构,作为内部冒险指针数组的分配器,此数组为单个线程所私有。

 

Guard类是一个很重要的冒险指针槽口,具体接口如下:

 

template <typename T>

T protect( CDS_ATOMIC::atomic<T> const& toGuard );

template <typename T, class Func>

T protect( CDS_ATOMIC::atomic<T> const& toGuard, Func f );

 

声明一个原子性指针为冒险类型,通常T类型为指针。

 

我早前已描述过了,这些方法内部暗含一个循环。首先,读取原子性指针toGuard,并将其值赋给冒险指针,接着检查该指针是否被其它线程更改过。第二个Func functor参数是必要的,因为在某些场景中,声明的冒险指针并不指向T*的指针,而是由此衍生的指针类型。尤其是在侵入式容器中,该容器管理节点指针,而真实数据指针可能有别于节点指针,譬如,节点可能只是真是数据的某个字段。

functor声明如下:

 

struct functor {

value_type * operator()( T * p ) ;

};

 

调用下面这两个方法,均返回冒险指针:

 

template <typename T>

T * assign( T * p );

template <typename T, int Bitmask>

T * assign( cds::details::marked_ptr<T, Bitmask> p );

 

这些方法将p声明为冒险指针,和保护类型不同的是,此方法没有循环体,仅仅将p分配给冒险槽口。

 

第二个语法参数cds::details::marked_ptr为标签指针。标签指针中,低位的2到3比特用来存储标签,这是一种非常流行的 无锁编程方式。该函数借助位掩码将携带标签的指针放入冒险槽口。

 

调用该方法,返回冒险指针

 

template <typename T>

T * get() const;

 

读取当前冒险槽口的值有时显得很有必要的

 

void copy( Guard const& src );

 

将源冒险槽的值拷贝一份给当前对象,结果,两个冒险槽拥有相同的值

 

void clear();

 

清空冒险槽的值,功能与Guard类的析构函数一样。

 

GuardArray类拥有相似的接口,通过数组下标获取冒险指针

 

template <typename T>

T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard );

template <typename T, class Func>

T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard, Func f )

template <typename T>

T * assign( size_t nIndex, T * p );

template <typename T, int Bitmask>

T * assign( size_t nIndex, cds::details::marked_ptr<T, Bitmask> p );

void copy( size_t nDestIndex, size_t nSrcIndex );

void copy( size_t nIndex, Guard const& src );

template <typename T>

T * get( size_t nIndex) const;

void clear( size_t nIndex);

 

细心的读者大概已经发现一个未知的字CDS_ATOMIC,这是什么呢?

 

这是一个宏,为std::atomic声明恰当的命名空间。

 

编译器若支持C++11 atomic,则CDS_ATOMIC为std;若不支持,则CDS_ATOMIC为命名空间cds::cxx11_atomics。libcds接下来的一版,很用可能采用boost.atomic,届时CDS_ATOMIC则为boost。

 

带有引用计数器的冒险指针

 

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

 

冒险指针规则的缺陷,就是仅能保护无锁容器节点的局部引用,而无法对全局引用作出保护。需要说明的是,对迭代器仅仅做了概念实现。而对于一个不限大小的冒险指针数组,迭代器的存在是很有必要的。

 

特别声明

 

事实上,我们可以采用HP规则实现迭代器,即迭代器对象需持有一个HP槽口,用来保护迭代器指针。最终,我们得到一个非常特殊的,与线程绑定的迭代器。记住,冒险槽口存放线程私有数据。另外,考虑到冒险指针集合的大小有限,可以说,基于HP规则的迭代器实现几乎难以完成。

 

以往,编程人员认为引用计数技术可以作为多用途工具,处理所有错误。此刻,我们知道这些观点是错误的。

判定对象是否被调用,最流行的做法是,引用计数方法,即RefCount。Valois为无锁方法的发起者之一,在他的工作中,引用计数技术用于容器元素的安全删除。但RefCount规则存在一些缺陷,其中最大的问题是,循环数据结构,元素你引用我,我引用你。另外,许多研究者认为RefCount规则效率低下,无锁实现太过频繁地使用fetch-and-add原语。确实如此,指针每一次使用前,引用计数器数目需要增加,而每一次使用后,计数器数目需要减少。

 

2005年,哥德堡大学的研究团队发表了他们的论文 [GPST05] 。此论文将冒险指针和引用计数技术结合起来,冒险指针规则有效地保护无锁数据结构运算内部的局部引用,而引用计数技术保护全局引用,确保数据结构完整。

 

姑且将此规则命名为HRC(Hazard pointer RefCounting)。

 

采用冒险指针可以避免过于困难的运算,比如,对元素引用数目的增加或减少。总的来说,就是增加了引用计数技术规则的有效性。然而,同时调用这两种方法,某种程度上会增加联合规则算法的复杂度。不过在这方面有很多技术实现,我就不提供完整的伪码了,详细的细节参看[GPST05]。另外,冒险指针规则无需任何来自无锁容器元素的特殊支持,HRC仅依赖两个帮助方法:

 

void CleanUpNode( Node * pNode);

void TerminateNode( Node * pNode);

 

TerminateNode过程清空pNode内部元素,即指向容器元素的所有指针。而调用CleanUpNode过程则是确保pNode元素仅指向数据结构“活”的元素,必要时改变其引用。引用计数容器中的每一次引用,都伴随着元素引用计数器数目的增加,而CleanUpNode则会在元素删除时减少计数器数目:

 

void CleanUpNode(Node * pNode)

{

    for (all x where pNode->link[x] of node is reference-counted) {

    retry:

        node1 = DeRefLink(&pNode->link[x]);  // set HP

        if (node1 != NULL and !is_deleted( node1 )) {

            node2 = DeRefLink(node1->link[x]); // set HP

            // Change the reference and at once increment the reference counter

            // to the old  node1 element

            CompareAndSwapRef(&pNode->link[x],node1,node2);

            ReleaseRef(node2);        // clears HP

            ReleaseRef(node1);        // clears HP

            goto retry; // a new reference also can be deleted, so we repeat

        }

        ReleaseRef(node1);        // clears HP

    }

}

 

正是这种改变强化了无锁容器管理,从规则内核到容器元素本身。HRC规则元素本身独立于特定的无锁数据结构。需要注意的是,CleanUpNode 算法在短期内会破坏数据结构的完整性,逐一地改变元素内部引用,这在某些场景中是难以被接收的。例如,编写MultiCAS仿真,无锁容器元素中所有连接的原子性应用无法接受这样的违例。

 

同冒险指针规则相似,顶层的废弃元素数量有限,而且其物理删除算法与冒险指针规则的Scan算法极其相似。最大的不同在于:若采用HP规则的保护机制,即R > N = P * K时,Scan过程必定会删除一些东西。而HRC规则中Scan过程调用,会因为彼此引用而失败,每个引用就是一个自增的计数器。倘若Scan执行失败,须调用CleanUpAll对此过程进行支持。遍历所有线程的废弃指针数组,然后调用CleanUpNode过程,促成Scan二次成功调用。

 

ibcds库中的HRC规则实现

libcds库中的HRC规则实现方式与HP规则相似,主要实现类为cds::gc::HRC,该API与cds::gc::HP API非常相似。

 

HRC规则最大的优势是可以支持迭代器,但libcds库中并没有对其做出实现。主要是开始建库那会,私以为通用迭代器不适用无锁容器。前提是不仅对象迭代器引用是安全的,而且整个容器的迭代亦是安全的。然而一般情况下,无锁数据结构无法进行最后一轮迭代。总有一个并发线程试图删除迭代器的依赖元素,这就导致无法安全地引用节点字段。而且,节点因为被HP规则所保护,无法被物理删除。不仅如此,由于本节点已被移出无锁容器中,接下来的元素变得难以获取。

 

因此,libcds中的HRC规则可以看做是HP规则的特殊实现。比如,添加额外的条件即引用计数器使得HP规则更加复杂。测试结果显示,HRC容器比HP容器慢好几倍,还可能面对成熟垃圾回收器常见的高负载。同时,在Scan调用期间不能进行删除操作时,比如,循环引用的原因,应启动CleanUpAll过程遍历所有废弃节点。

 

在libcds库中,HRC规则作为HP规则的一种变换形式而存在,这就要求我们在构建时必须考虑泛型。由于HRC特殊的内部结构,基于HRC以及基于HP容器的泛型化处理,往往非常有趣。

 

Pass the Buck 踢皮球

 

640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

 

Herlihy和al,致力于无锁数据结构内存回收问题,提出Pass-the-Buck算法[HLM02, HLM05]。PTB算法和Michael M的HP规则非常相似,不同的地方在于其具体实现。

 

同HP规则一样,PTB规则也需要声明一个指针保护,类似于HP规则冒险指针。初始化PTB规则意味着,提前准备了无数保护,即冒险指针。一旦存在相当多的废弃数据,调用Liberate过程,类似于HP规则中的Scan过程,返回一个可以安全删除的指针数组。与HP规则不同的是,HRC规则中,废弃指针数组为单个线程私有,而这些废弃数据数组为所有线程所共享。

 

保护即冒险指针的结构,不仅包含保护指针,而且包含废弃数据指针,称之为传递队友(hand-off)。在删除的过程中,若Liberate过程发现一些被保护的废弃数据,会将这些废弃的记录放入传递队友的保护槽口中。在下一次Liberate调用时,若传递队友的数据连接其保护被改变,则此数据可以被删除,也意味着此保护指向了其它被保护的数据。

 

在文章HLM05中,作者为Liberate提供了两种算法:非等待和无锁。非等待需要dwCAS,即基于双字的CAS,这使得算法依赖平台对dwCAS的支持。而无锁算法仅在数据发生变化时起作用。倘若在无锁版本的Liberate两次调用期间,数据、即保护和废弃指针,保持不变,循环就很有可能发生。因为算法无法删除所有可能废弃的数据,不得不更密集地调用Liberate。在程序执行的最后阶段,数据依然没有变化,特别是当PTB单例析构函数中的脱离被执行时,或者Liberate被调用时。

 

这个循环困扰我很久,为此我决定改变PTB规则的Liberate算法,并借鉴HP规则算法。结果,libcds的PTB实现越来越像HP规则的变体,拥有任意数量的冒险指针,整个废弃元素数组。而容量却没有什么大的影响,“聪明”的HP规则比PTB快很多,但PTB不限保护数量的特性更受人们喜爱。

 

libcds中的踢皮球规则实现

 

在libcds库中PTB规则实现类为cds::gc::PTB,实现细节参见cds::gc::ptb。cds::gc::PTB API和cds::gc:::HP API非常相似,唯一不同的是构造函数参数。构造函数接收的参数如下:

 

PTB( size_t nLiberateThreshold = 1024, size_t nInitialThreadGuardCount = 8 );

 

  • nLiberateThreshold,Liberate调用的阈值,一旦废弃数据的整个数组达到这个值,便会调用Liberate。

  • nInitialThreadGuardCount,某一线程创建时,初始化时的guard数目,倘若guard不够用,新的保护会被自动创建出来。

 

全文总结

 

本文中,我们集中讨论了冒险指针规则的内存安全回收算法。HP规则及其各种变体,为我们提供了一种很好的无锁数据结构内存安全控制方式。

 

本文提到的任何规则,不局限于无锁数据结构领域。倘若你仅对libcds感兴趣,以下这些操作就够了,初始化选定的规则,为其关联相应的线程,并将规则类作为GC容器的首个形参。引用保护、Scan()、Liberate()等等的调用,容器中均有实现。

 

还剩一个极其有意思的RCU算法,此算法不同于HP类型的规则,我会在接下来的文章中,单独介绍它。

 

参考文献

 

[Fra03] Keir Fraser Practical Lock Freedom, 2004; technical report is based on a dissertation submitted September 2003 by K.Fraser for the degree of Doctor of Philosophy to the University of Cambridge, King’s College

 

[GPST05] Anders Gidenstam, Marina Papatriantafilou, Hakan Sundell, Philippas Tsigas Practical and Efficient Lock-Free Garbage Collection Based on Reference Counting, Technical Report no. 2005-04 in Computer Science and Engineering at Chalmers University of Technology and Goteborg University, 2005

 

[Har01] Timothy Harris A pragmatic implementation of Non-Blocking Linked List, 2001

 

[HLM02] M. Herlihy, V. Luchangco, and M. Moir The repeat offender problem: A mechanism for supporting

 

dynamic-sized lockfree data structures Technical Report TR-2002-112, Sun Microsystems

Laboratories, 2002.

 

[HLM05] M.Herlihy, V.Luchangco, P.Martin, and M.Moir Nonblocing Memory Management Support for Dynamic-Sized Data Structure, ACM Transactions on Computer Systems, Vol. 23, No. 2, May 2005, Pages 146–196.

 

[Mic02] Maged Michael Safe Memory Reclamation for Dynamic Lock-Free Objects Using Atomic Reads and Writes, 2002

 

[Mic03] Maged Michael Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects, 2003

 

[MS98] Maged Michael, Michael Scott Simple, Fast and Practical Non-Bloking and Blocking Concurrent Queue Algorithms, 1998

 

[Tor08] Johan Torp The parallelism shift and C++’s memory model, chapter 13, 2008

 

来源: 伯乐在线 - 乔永琪 

英文出处:khizmax

转载于:https://my.oschina.net/u/3100313/blog/866325

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值