C++无锁数据结构的原子性、原子性原语分析

80次阅读
没有评论

共计 8545 个字符,预计需要花费 22 分钟才能阅读完成。

今天丸趣 TV 小编给大家分享一下 C ++ 无锁数据结构的原子性、原子性原语分析的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来了解一下吧。

原子性操作可以简单地分为读写(read and write)、原子性交换操作(read-modify-write,RMW)两部分。原子操作可认为是一个不可分的操作;要么发生,要么没发生,我们看不到任何执行的中间过程,不存在部分结果(partial effects)。简单的读写操作甚至不具有原子性,例如,没有内存对齐的数据,该数据的读取不具有原子性。在 X86 架构的计算机中,这样的读操作会导致内部回避。这样,处理器读取数据就被分成了好几部分。在其它诸如 Sparc、Intel Itanium 架构中,这样的读操作会导致段错误,这些操作要能被拦截并处理,而原子性操作不存在这样的问题。在现代处理器中,原子性的读写操作仅仅作用于对齐后的完整类型(整数和指针);而现代编译器是 volatile 基本类型正确对齐的保障。如果你想 4 到 8 个比特大小的数据结构具有原子性,那你就应该谨慎行事,借助编译器指令确保其正确对齐。每种编译器都有其独一无二的数据、类型对齐方法。顺便说一下,libcds 库支持一组备用类型和宏指令,当你声明对齐数据时,它们会隐藏编译器依赖的部分。

Compare-and-swap

即便竭尽全力,设计一个仅仅使用读写的无锁容器算法依然是困难重重(我不清楚针对线程随机数的数据结构)。这就是为什么处理器架构开发人员采用 RMW 操作的原因。RMW 可以原子性地执行对齐内存单元读操作和针对它的写操作:compare-and-swap (CAS)、fetch-and-add (FAA)、test-and-set (TAS) 等等。在学术圈,compare-and-swap (CAS)被认为是最基本的一种操作。伪代码如下:

bool CAS( int * pAddr, int nExpected, int nNew )

atomically {

 if ( *pAddr == nExpected ) {

  *pAddr = nNew ;

 return true ;

 }

 else

 return false ;

}

从字面意思上看,如果 pAddr 地址中的当前变量值等于预期的 nExpected,那么将 nNew 的值赋给此变量,并返回 true;否则返回 false,变量值不变。所有执行过程都是原子性的、不可分的,不会产生任何可见的部分结果。借助于 CAS,其它的 RMW 操作都可以估值。如下的 fetch-and-add 是这样的:

int FAA( int * pAddr, int nIncr )

{

 int ncur ;

 do {

 ncur = *pAddr ;

 } while ( !CAS( pAddr, ncur, ncur + nIncr ) ;

 return ncur ;

}

CAS 操作的学术性类型在实践中并非那么得心应手。CAS 失败后,我们时常想知道内存单元中的当前值是多少。这时可以考虑另一个种 CAS(所谓的 valued CAS,依然是原子性执行):

int CAS( int * pAddr, int nExpected, int nNew )

atomically {

 if ( *pAddr == nExpected ) {

  *pAddr = nNew ;

 return nExpected ;

 }

 else

 return *pAddr

}

C++11 中的 compare_exchange 函数包含了两种衍生类型(严格地说,C++11 没有此类函数,它们是 ompare_exchange_strong 和 compare_exchange_weak,这些我稍后会告知大家):

bool compare_exchange( int volatile * pAddr, int  nExpected, int nNew );

参数 nExpected 通过引用传值,并且包含 pAddr 地址的预期变量值。在输出端,返回变化之前的值。(译者注,其实就是返回 pAddr 的旧地址。假如函数地址中存在值 nExpected,返回 true,加入失败了则返回 false(nExpected 会包含地址 pAddr 的当前变量值)。multipurpose CAS 操作构建涵盖了学术 CAS 定义的两种衍生类型。但在实际应用中,compare_exchange 会出现一些错误,你需要知道 nExpected 参数是传引用,它是可以改变的,这一点是不能接受的。

但借助 compare_exchange 可以实现 fetch-and-add 基本类型,代码可以写成下面这样:

int FAA( int * pAddr, int nIncr )

{

 int ncur = *pAddr;

 do {} while ( !compare_exchange( pAddr, ncur, ncur + nIncr ) ;

 return ncur ;

}

ABA 问题

CAS 基本类型适合多种方式。不过在应用过程中,可能发生一个严重的问题,就是所谓的 ABA 问题。为了描述这个问题,我们需要考虑一种 CAS 操作应用的典型模式:

int nCur = *pAddr ;

while (true) {

 int nNew = calculating new value

 if ( compare_exchange( pAddr, nCur, nNew ))

 break;

}

事实上,我们一直在循环中,直到 CAS 执行才跳出循环。在读取 pAddr 地址中的当前变量值和计算新值 nNew,这个在 pAddr 地址中可被其它线程改变的变量之间,循环是必须的。

ABA 问题可以用下面的方式加以描述。假设线程 A 从共享内存单元读取 A 值,与此同时,该内存单元指针指向某些数据;接着线程 Y 将内存单元的值改为 B,接着再改回 A,但此时指针指向了另一些数据。但线程 A 通过 CAS 基本类型试图更改内存单元值时,指针和前面读取的 A 值比较是成功的,CAS 结果也正确。但此时 A 指向完全不一样的数据。结果,线程就打破了内部对象连接(internal object connections),最终导致失败。

下面是一个无锁栈的实现,重现了 ABA 问题 [Mic04]:

// Shared variables

static NodeType * Top = NULL; // Initially null

 

Push(NodeType * node) {

 do {

/*Push2*/ NodeType * t = Top;

/*Push3*/ node- Next = t;

/*Push4*/ } while ( !CAS( Top,t,node) );

}

 

NodeType * Pop() {

 Node * next ;

 do {

/*Pop1*/ NodeType * t = Top;

/*Pop2*/ if ( t == null )

/*Pop3*/ return null;

/*Pop4*/ next = t- Next;

/*Pop5*/ } while ( !CAS( Top,t,next) );

/*Pop6*/ return t;

}

下面一系列活动导致栈结构遭受破坏(需要注意的是,此序列不是引起 ABA 问题的唯一方式)。

 Thread XThread YCalls Pop().
Line Pop4 is performed,
variables values: t == A
next == A- next NodeType * pTop = Pop()
pTop == top of the stack, i.e. A
Pop()
Push(pTop)
Now the top of the stack is A again
Note, that A- next has changedPop5 line is being performed.
CAS is successful, but the field Top- next
is assigned with another value,
which doesn’t exist in the stack,
as Y thread has pushed A and A- next out,
of a stack and the local variable next
has the old value of A- next
 

ABA 问题是所有基于 CAS 基本类型的无锁容器的一个巨大灾难。它会在多线程代码中出现,当且仅当元素 A 从某个容器中被删除,接着存入另一个元素 B,然后再改为元素 A。即便其它线程使该指针指向某一元素,该元素可能正在被删除。即使该线程物理删除了 A,接着调用 new 方法创建了一个新的元素,也不能保证 allocator 返回 A 的地址。此问题在超过两个线程的场景中经常出现。鉴于此,我们可以讨论 ABCBA 问题、ABABA 问题等等。

为了处理 ABA 问题,你应该物理删除(延迟内存单元再分配,或者安全内存回收)该元素,并且是在不存在竞争性线程局部,或全局指向待删除元素的情况下进行。

因此,无锁数据结构中元素删除包含两个步骤:

第一步,将该元素逐出无锁容器中;

第二步(延迟),不存在任何连接的情况下,物理移除该元素。

我会在接下来的某篇文章中详细介绍延迟删除的不同策略。

Load-Linked / Store-Conditional

我猜测,因为 CAS 中出现的 ABA 问题,促使处理器开发人员寻找另外一种不受 ABA 问题影响的 RMW 操作。于是找到了 load-linked、store-conditional (LL/SC) 这样的操作对。这样的操作极其简单,伪代码如下:

word LL( word * pAddr ) {

 return *pAddr ;

}

 

bool SC( word * pAddr, word New ) {

 if ( data in pAddr has not been changed since the LL call) {

  *pAddr = New ;

 return true ;

 }

 else

 return false ;

}

LL/SC 对以括号运算符的形式运行,Load-linked(LL)运算仅仅返回 pAddr 地址的当前变量值。如果 pAddr 中的数据在读取之后没有变化,那么 Store-conditional(SC))操作会将 LL 读取 pAddr 地址的数据存储起来。这种变化之下,任何 pAddr 引用的缓存行修改都是明确无误的。为了实现 LL/SC 对,程序员不得不更改缓存结构。简而言之,每个缓存行必须含有额外的比特状态值(status bit)。一旦 LL 执行读运算,就会关联此比特值。任何的缓存行一旦有写入,此比特值就会被重置;在存储之前,SC 操作会检查此比特值是否针对特定的缓存行。如果比特值为 1,意味着缓存行没有任何改变,pAddr 地址中的值会变更为新值,SC 操作成功。否则本操作就会失败,pAddr 地址中的值不会变更为新值。

CAS 通过 LL/SC 对得以实现,具体如下:

bool CAS( word * pAddr, word nExpected, word nNew ) {

 if ( LL( pAddr ) == nExpected )

 return SC( pAddr, nNew ) ;

 return false ;

}

注意,尽管代码中存在多个步骤,不过它确实执行原子性的 CAS。目标内存单元内容要么不变,要么发生原子性变化。框架中实现的 LL/SC 对,仅仅支持 CAS 基本类型是可能的,但不仅限于此种类型。在此,我不打算做进一步讨论。如果感兴趣,可以参考引文[Mic04]。

现代处理器架构分为两大部分。第一部分支持计算机代码中的 CAS 基本类型;第二部分是 LL/SC 对。CAS 在 X86、Intel Itanium、Sparc 框架中有实现。基本类型第一次出现在 IBM 系统 370 基本类型中;而 PowerPC、MIPS、Alpha、ARM 架构中的 LL/SC 对, 最早出现在 DEC 中。倘若 LL/SC 基本类型在现代架构中没有完美实现,那它就什么都不是。比如,采用不同的地址无法调用嵌入的 LL/SC,连接标签存在错误遗弃的可能。

从 C ++ 的角度看,C++ 并没有考虑 LL/SC 对,仅仅描述了原子性原语 compare_exchange (CAS),以及由此衍生出来的原子性原语——fetch_add、fetch_sub、exchange 等等。这个标准意味着通过 LL/SC 可以很容易地实现 CAS;而通过 CAS 对 LL 的向后兼容实现绝对没有那么简单。因此,为了不增加 C++ 库开发人员的难度,标准委员会仅仅引入了 C ++ compare_exchange。这足以用于无锁算法实现。

伪共享(False sharing)

现代处理器中,缓存行的长度为 64-128 字节,在新的模型中有进一步增加的趋势。主存储和缓存数据交换在 L 字节大小的 L 块中进行。即使缓存行中的一个字节发生变化,所有行都被视为无效,必需和主存进行同步。这些由多处理器、多核架构中缓存一致性协议负责管理。

假设不同的共享数据(相邻地址的区域)存入同一缓存行,从处理的角度看,某个数据改变都将导致同一缓存行中的其它数据无效。这种场景叫做伪共享。对 LL/SC 基本类型而言,错误共享具有破坏性。这些基本类型的执行依赖于缓存行。加载连接(LL)操作连接缓存行,而存储状态(SC))操作在写之前,会检查本行中的连接标志是否被重置。如果标志被重置,写就无法执行,SC 返回 false。考虑到缓存行的长度 L 相当长,那么任何缓存行的变更,即和目标数据不一致,都会导致 SC 基本类型返回 false。结果产生一个活锁现象:在此场景下,就算多核处理器满负载运行,依然无用。

为了处理错误共享,每个共享变量必须完全处理缓存行。通常借用填充(padding)来处理。缓存的物理结构影响所有的操作,不仅仅是 LL/SC,也包含 CAS。在一些研究中,采用一种特殊的方式创建数据结构,该方式有考虑缓存结构(主要是缓存行长度)。一旦数据结构被恰当地构建,性能就会有极大的提升。

struct Foo {

 int volatile nShared1;

 char _padding1[64]; // padding for cache line=64 byte

 int volatile nShared2;

 char _padding2[64]; // padding for cache line=64 byte

};

CAS 衍生类型

同样,我乐意介绍两种更有用的基本类型:double-word CAS (dwCAS) 和 double CAS (DCAS)。

Double-word CAS 和通用 CAS 相似,不同的是前者运行在双倍大小的内存单元中:32 位体系结构是 64 比特,64 位体系结构是 128 比特(要求至少 96 比特)。有鉴于此架构提供 LL/SC 而非 CAS,LL/SC 应该运行在 double-word 之上。我了解的情况是仅有 X86 支持 dwCAS。那么为什么 dwCAS 如此有用呢?借助它可以组织一种 ABA 问题的解决方案——tagged pointers。此方案依赖于每种相关的共享 tagged pointer 整数。tagged pointer 可以通过以下结构加以描述:

template  typename T

struct tagged_pointer {

 T * ptr ;

 uintptr_t tag ;

 

 tagged_pointer()

 : ptr( new T )

 , tag( 1 )

 {}

};

为了支持原子性,本类型的变量必须与 double-word 对齐:32 位架构是 8 字节,64 位架构是 16 字节。tag 包含“版本号”数据,ptr 指向此数据。我会在接下来的某篇文章中详尽介绍 tagged pointers,集中介绍安全内存回收和安全内存回收。目前仅讨论内存,一旦涉及 T-type 数据(以及其对应的 tagged_pointer),都不应该物理删除,而是移入到一个 free—list 中(对每个 T -type 进行隔离)。未来随着 tag 增长,数据得以分布式存储。ABA 问题解决方案:现实中,此指针式很复杂的,tag 包含一个版本号(分布式位置编号)。如果 tagged_pointer 指针类型和 dwCAS 参数相同,但 tag 的值不同,那么 dwCAS 不会成功执行。

第二种原子性原语——double CAS (DCAS),是纯理论,没有在任何现代处理器架构中实现。DCAS 伪代码如下:

bool DCAS( int * pAddr1, int nExpected1, int nNew1,

 int * pAddr2, int nExpected2, int nNew2 )

atomically {

 if ( *pAddr1 == nExpected1  *pAddr2 == nExpected2 ) {

  *pAddr1 = nNew1 ;

  *pAddr2 = nNew2 ;

 return true ;

 }

 else

 return false

}

DCAS 运行子两个随机排序内存单元上。若当前值与预期值一致,可改变这两个内存单元的值。

为何此基本类型如此有意思呢?因为它容易构建一个无锁双链表(deque)。数据结构是许多有趣算法的基础。许多学术性工作关注的数据结构,都基于 DCAS。尽管这个基本类型在硬件中还没有实现,依然有一些工作(比如[Fra03]- 最流行的一种)描述了基于常规 CAS 的 DCAS 构建(针对任意多个 pAddr1…pAddrN 地址的 multi-CAS)算法。

性能

那么原子性原语性能如何?

现代处理器是如此的复杂、难于预测,以至于程序员对计算机指令常常难以适从。特别是原子性指令,其工作机制涉及内部同步、处理器总线信号等等。许多工作正在试着测试处理器指令长度。而我所提及的测试来自[McKen05]。在这篇文章中,作者比较了原子性增长(atomic increment)和 CAS 基本类型长度和 nop(no-operation)长度。比如 Intel Xeon 3.06 hHz 处理器(2005 model)原子性增长长度为 400 nop,CAS 长度 850-1000 nop。IBM Power4 1.45 hHz 处理器原子性增长长度为 180 nop,CAS 长度为 250 nop。测试时间有些久远,处理器架构有了一些不小的进步,不过我猜还是在同一数量级上。

正如你所看到的那样,原子性原语是相当复杂的。所以不加取舍,任何场景下都用它是相当不利的。例如,二进制树搜索算法采用 CAS 读取当前树的节点,我不看好此类算法。毫无意义,每一代 Intel 核心架构,其 CAS 都会变得更快。显然,Intel 付出很多努力去改进微型架构。

Volatile 和原子性

C++ 中有一个神秘的关键字 Volatile。很多时候,Volatile 被认为与原子性以及校准(regulation)有关。其实这是不对的,当然存在这样的认识是有历史原因的。Volatile 仅仅是防止编译器将值缓存入寄存器(编译器优化、寄存器越多,编译器在其中缓存的数据也越多)。读取 Volatile 变量意味着永远从内存中读取,Volatile 变量的写是直接写入内存中。倘若并发地改变 Volatile 数据,需要注意这一点。

实际上我们并没有这么做,主要是缺乏内存栅栏。某些语言如 Java、C#,volatile 被赋予一个神奇的状态值来提供校准。不过 C ++11 中并没有这么做。volatile 并没有任何特殊的校准,现在我们知道恰当的校准对原子性来说是必须的。

因此,在 C ++11 兼容的编译器没有必要为原子性变量提供 volatile。不过在以往的编译器中,采用 volatile 还是很有必要的,如果你想自己实现原子性。在下面的声明中:

class atomic_int {

 int m_nAtomic;

 //….

};

编译器有权优化 m_nAtomic 调用(尽管是间接调用)。因此,时常在此声明一个 int volatile m_nAtomic 是很有用的。

libcds

那么我们能从 libcds 库得到什么?我们已经在 x86、amd64、Intel Itanium и Sparc 架构中,以 C ++11 的方式实现了原子性原语。倘若编译器不支持 C ++11,libcds 可以采用自己的原子性实现。构建无锁数据结构,除去常规的原子性写读,最主要的基本类型就是 CAS,而 DwCAS 用的很少。截止目前,libcds 库还没有 DCAS 和 multi-CAS 的实现,但未来这些都很有可能出现。很多研究表明,唯一的制约因素是,实现 DCAS 算法 [Fra03] 太困难了。尽管如此,我已经提到个别高效的准则在无锁的世界已经存在。目前效率低下的是硬件部分,相信随后的日子针对不同的硬件和任务,这些都会变得极其高效。

以上就是“C++ 无锁数据结构的原子性、原子性原语分析”这篇文章的所有内容,感谢各位的阅读!相信大家阅读完这篇文章都有很大的收获,丸趣 TV 小编每天都会为大家更新不同的知识,如果还想学习更多的知识,请关注丸趣 TV 行业资讯频道。

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-08-03发表,共计8545字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)