无锁数据结构

来源:互联网 发布:q叔淘宝店 编辑:程序博客网 时间:2024/06/07 18:56

Andrei Alexandrescu

December 16, 2007

译者:张桂权

12/25/2007

初稿阶段,没有得到许可不得引用,否则后果自负

泛型编程(Generic<Programming>)被删除之后(我知道,认为母校(毕业的学校)要求一切,不仅仅是100%的个人时间,是非常天真的),对于本篇文章来说,到目前为止,就没有丰富的题材了。一个备选主题是构造器的讨论,尤其是前沿的构造器,异常处理和双角色(two-stage)对象构造。另一个主题——再瞥一眼Yaslander技术[2]——创建不完全类型的容器(比如,lists, vectors, maps),凭借一些有趣的技巧完全可以实现,但是没有标准容器的保证。

当两个主题都很喜欢的时候,他们又不能给无锁数据结构(lock-free data structures)一个机会,这可是所有多线程编程社区的共同期望。在今年的PLDI会议(http://www.cs.umd.edu/~pugh/pldi04/)上,Michael Maged展示了世界上第一个无锁内存分配器[7],这,在很多测试上,超越了其他更复杂,精心设计的基于锁的分配器。这是不久前出现的许多无锁数据结构和算法的最新成果,……不过,让我们从头开始吧。
 
1 何谓无锁
这就是曾经我最想问一个问题。作为一名真正的多线程程序员,我最熟悉基于锁的多线程算法。在经典的基于锁的编程中,不论何时如果你需要共享一些数据,那么你需要串行访问它。那些改变数据的操作必须是原子的,这样没有其它的线程干扰,并破坏数据的不变性。即使像++count_,这样简单的操作,其中count_是整数类型,也必须加锁,因为++确实是一个三步操作,但是没有必要原子操作。
 
总之,在基于锁的多线程编程中,你需要保证对竞争条件很敏感的共享数据上的任何操作,都通过加锁或解锁一个独占(mutex)来实现原子操作。锁上独占是非常明智的,只要锁上独占之后,你就可以执行几乎任意的操作,推心置腹的说,没有线程会欺骗你的共享状态。
 
确切的来说,当一个独占被加锁之后你的操作是随机的,也是有问题。比如说,你可以,读取键盘输入或执行一个缓慢的I/O操作,这就意味着你延迟了其它任何等待同一个独占的线程。糟糕的是,你可以决定,你想去访问其它的一些共享数据,并试图锁上它的独占。如果另外一个线程已经锁上了最后一个独占,并且试图去访问你的线程持有的第一个独占,马上两个进程都被挂起,所以你可以称之为死锁
 
进入无锁编程。在无锁编程中你几乎不能以原子的方式做任何的事情。仅仅只有极少数的操作可以通过原子方式处理,这些限制使得无锁编程的方法更难了。(事实上,世界上大概有半打无锁编程专家,但是你真的不在此列。然而,庆幸的是,本文将给你提供基础的工具、参考书和热心,助你成为其中的一员)。作为这么一个稀有的框架的回报就是,你可以提供线程进度和线程之间的交互更好的保证。但是,无锁编程中你可以原子化处理的这些少量的事(small set of things)究竟是什么呢?事实上,允许实现任何无锁算法的最小的原子原语(atomic primitives)集是什么呢——如果存在这样一个集合?
 
如果你认为这是一个简单十足的问题,那有必要为它设立一个大奖?但是别人就这样干的。2003年,Maurice Herilihy因为其萌芽之作《无等待同步》而获得分布式计算领域的Edsger W.Dijkstra奖。(看看http://www.prodx.org/dijkstra/2003.html吧。其中还包括一个这篇论文的连接)。在这篇绝技(tour-de-force)论文中,Herlihy证明了建立无锁数据结构的好的和不好的原语。这促使了一个表面上很热火的硬件架构立即废弃,同时明晰未来的硬件中应该实现什么样的同步原语。
 
例如,在Herlihy的论文中给出的不可能的结果,表明原子操作,如,test-and-set(检测-设置)、swap(交换)、fetch-and-add(获取-增加)或即使是atomic queues(原子队列)都不适合同步两个以上的线程。(这实在让人感到惊讶,因为队列的原子pushpop操作应该提供非常强的抽象。)很精明的是,Herlihy还给出了通常的结果,证明了一些非常简单的结构足以实现支持任意数量线程的无锁算法。
 
最简单,最流行,并且贯穿本文的通用原语,是比较-交换(compare-and-swap,CAS)操作:
 
template <class T>
bool CAS(T* addr, T exp, T val)
{
       if (*addr == exp)
        {
                  *addr = val;
               return true;
      }
return false;
}
 
CAS用一个期望值来比较一个内存地址的内容,如果比较成功,则用一个新的值来替换这个内容(上下文)。这个过程是原子的。许多现代的处理器实现了CAS或等价不同位长的原语(为了更合理,我们把它作为template,假设一个实现采用元编程(metaprogramming)来约束可能的T)。一个让人称道的规则是,CAS能够原子的比较和交换的位越多,越容易用它来实现无锁数据结构。当今的32位处理器实现62位的CAS;比如,Intel的汇编器称之为CMPXCHG8(你将喜欢这些汇编器的助记术)。

2 一句忠言

通常一篇C++论文内嵌C++代码段和实例。理想的情况是,这些代码是标准C++和Generic<Programming>尽力去支撑自己的观点/理论。当书写多线程代码是,给出一个标准C++代码的实例几乎不可能。标准C++中没有Thread(线程),所以你不能编写不存的东西。与此同时,本文的代码是“pseudocode”(伪码),并不意味标准C++代码是兼容编译的。

以内存界线(memory barriers)为例。现实的代码需要其中的算法的汇编语言的转化或至少少量的C++代码,所谓的“内存界线”——处理器相关(processor-dependent)的magic,强制适当顺序的内存读和写。本文不想在无锁数据结构之外,展开内存界线的解析。如果感兴趣,你可以看看Butenhof的优秀图书[3]或一个简短的简绍[6]。本文的目标是,我们仅仅假设编译器没有做时髦的优化(比如,忽略“冗余”变量的读取,单线程下的有效优化)。从技术上讲,这是连续地始终如一(sequentially consistent)的模型,其中读和写的执行完全按照源代码中的顺序进行。

3 无等待和无锁 vs 有锁(Wait-Free and Lock-Free versus Locked)

为了明确这些术语,让我们做一些定义。一个“无等待”过程,是一个可以在有限数量的步骤中完成的,不论其它线程的相对速度如何,过程。

一个“无锁”过程,保证执行这个过程的至少一个线程的进度。也就是,有些线程可以被随意的延迟,但是保证所有线程中至少有一个线程,每一步都有进展。从统计学上来说,在一个无锁过程中,所有的线程都会有进展。

基于锁的程序不能提供以上的任何保证。如果任何一个线程在持有一个独占锁时被延迟,那么等待同一个独占的线程都不会有进展;在通常情况下,基于锁的算法会去捕获死锁——每一个等待被其它线程锁住的独占(mutex)——和活锁(livelock)——每一个试图躲避其它线程的加锁行为,简直就像走廊上的两个人都试图经过另外一个人,最后以同步中交谊舞式的左右摆动结束。我们人类以非常漂亮的微笑来终止这种尴尬的场面;然而,处理器经常喜欢这样,直到重启,把它们分开。

无等待和无锁算法享有从它们的定义中派生出来的好处:

(1)     <!--[endif]-->线程杀死免疫(Thread-killing immunity):系统中任意一个线程被强行的杀死,不会延迟其它的线程。

(2)      <!--[endif]-->信号免疫(Signal immunity): 通常,在信号和异步中断中,子例程,比如malloc不能被调用。这是因为正当这个子例程持有锁的时候可能发生中断。拥有无锁例程,就不会再有这种问题了:线程可以自由的交错执行。

(3)      <!--[endif]-->优先反演免疫(Priority inversion immunity): 优先反演在低优先级的线程持有一个高优先级的线程需要的独占的锁时发生,在这种情况下,CPU的资源必须视为加锁特权。这是一种技巧,并且由OS的内核提供。无等待和无锁算法对这种问题有免疫。

已经进行了必要的介绍,现在让我们来分析一个精简设计的无锁实现。

4 一个无锁WRRM Map

列写提供限定首字母缩写词的好处,所以让我们定义WRRM(“少写多读”,“write rarely read many”)的maps作为变化之前进行很多次读取的maps。实例包括对象工厂(object factories)[1],许多观察者设计模式[5]的实例,映射货币名字到兑换率,这些被查找许许多多次,仅被比较慢的流和其他的查找表更新。

WRRM maps可以通过std:map或以前标准中的hash_map来实现,但是作为Modern C++ Design争论的assoc_vector(一个排过序的vector 或 pairs)是WRRM map的最佳候选,因为它能够加快查找速度。不论采用什么结构,我们的无锁切面正交其中。我们将把我们的后端称为Map<Key, Value>。同样,我们不在乎map提供的迭代切面;我们把map视为提供查找key或更新key-value对的手段的表。

为了翻新一个“lockful”(基于锁)的实现,我们应该将一个Map对象和一个Mutex对象联合起来:

// WRRMMap的一个有锁实现

template <class K, class V>

class WRRMMap {

Mutex mtx_;

Map<K, V> map_;

public:

V Lookup(const K& k) {

Lock lock(mtx_);

return map_[k];

}

void Update(const K& k,

const V& v) {

Lock lock(mtx_);

map_.insert(make_pair(k, v));

}

};

像石头一样结实——但是需要代价。每一个查找加锁或解锁Mutex,虽然(1)并行查找不需要连锁(interlock),和(2)通过spec,比起Lookup来说,Update被调用的次数更少。哎哟,现在让我们提供一个更好的实现。

5 垃圾收集器,你在哪儿?

我们的第一个WRRMMap的实现中,没有体现如下的思想:

(1)      <!--[endif]-->读几乎没有锁

<!--[if !supportLists]-->(2)   <!--[endif]-->更新产生了整个map的一个副本。更新这个副本,然后试图用旧的map CAS它。当CAS操作不成功时试图在循环中重复尝试copy/update/CAS过程。

<!--[if !supportLists]-->(3) <!--[endif]-->因为CAS在交换的位数上有限制,所以我们把Map作为一个指针存储,而不是直接作为一个WRRMMAP成员。

// WRRMMap的第一个无锁实现

// 仅当你有GC(垃圾收集器)时,才正常工作

template <class K, class V>

class WRRMMap {

Map<K, V>* pMap_;

public:

V Lookup(const K& k) {

//看好了哦,没锁

return (*pMap_)[k];

}

void Update(const K& k,

const V& v) {

Map<K, V>* pNew = 0;

do {

Map<K, V>* pOld = pMap_;

delete pNew;

pNew = new Map<K, V>(*pOld);

pNew->insert(make_pair(k, v));

} while (!CAS(&pMap_, pOld, pNew));

// 不要delete pMap_;

}

};

这段代码可以正常工作。在一个循环中,Update子例程对map做了一个完整的拷贝,并往其中增加一个新项,然后尝试交换指针。进行CSA非常重要,并且这不是简单的赋值;否则,下列连续事件可能破坏我们的map:

<!--[if !supportLists]-->(1)    <!--[endif]-->线程A拷贝这个map;

<!--[if !supportLists]-->(2)    <!--[endif]-->线程B拷贝这个map的同时往其中增加一个新项;

<!--[if !supportLists]-->(3)     <!--[endif]-->线程A增加另外的一些项;

<!--[if !supportLists]-->(4)    <!--[endif]-->线程A用自己的map版本——一个没有包含B增加的项的版本,来替换这个map。

 

有了CAS,就万事亨通了,因为每一个线程都想“假设自从我看它,拷贝它之后,这个map没有发生变化,否则我将从头来过”。

请注意,这里实现了无锁的Update,但是没有上面定义的无等待。如果很多线程并行的调用Update,每一个特定的线程都可能不确定的循环,但是能保证在整个过程中有些线程成功的更新这个结构,这样全局的进度在每一步都有进展。庆幸的是,Lookup是无等待的。

在一个有垃圾回收的环境里,我们算是完成任务了,本文将以升调(upbeat note)结束。没有垃圾回收,但是,这个内容很丰富哦,会很难忍受的(原因之一,你不得不看我的更多文字);这是因为不管愿不愿意我们不可以简单的处置旧的pMap_;否则,当我们正试图delete它时,如果其它的一些线程通过Lookup函数来访问pMap_那将会发生什么事情?你知道,一个垃圾收集器会访问所有线程的数据和私有的堆。当一个pMap_不再使用时,应该有一个很好的透明机制,并进行认真的检查。没有垃圾收集器,事情就变得很困难了。实际上,更加困难了,所以确定性的内存释放是无锁数据结构中一个最基础的问题。


6 写锁(Write-LockedWRRM Maps

为了了解敌人的邪恶,首先尝试一个经典的引用计数实现,并弄清失败的原因是非常具有教育意义的。所以,让我们思考一个使用map指针的引用计数,用WRRMMap存储一个指向这种形式的机构的指针。

template <class K, class V>

class WRRMMap {

typedef std::pair<Map<K, V>*,

unsigned> Data;

Data* pData_;

...

};

美极了。现在,Lookup对pData_->second加1,然后查找map中所有它想要的项,最后把pData_->second减1。当引用计数值为0时,pData_->first可以被delete了,然后pData_自身也被delete了。似乎连傻子都懂(foolproof),但是……

但是,这实在太“愚蠢(foolful)”了(或“连傻子都懂”的任意反义词)。试想,正当某个线程发现引用计数值为0,并且开始删除pData_时,另外一个线程……不,更好点:一个bazillion线程已经锁住垂死的pData_,并且准备读取其中的数据项。无论你的方案有多灵巧,都将碰上这个基础的catch-22(竞争):读取指向数据的指针,一个需要增加引用计数;但是计数器必须是数据本身的一部分,所以没有访问指针之前它不能读取数据。这有点像电子栅栏,在其顶端有一个开-关按钮:为了安全的攀越栅栏,你首先需要关掉它,但是关掉它的目的不是你需要攀越它。

所以,让我们思考其他的方法来恰当的delete旧的map。一个解决方案是等待,然后delete。旧的pMap_对象在处理器运行一毫秒之内将会被少之又少的线程查找;这是因为新的查找将使用新的map,一旦CAS之间活动的查找结束,pMap_已经准备好Hades(倾斜)了。因此,一个方案应该在一个循环中给某个“蟒蛇”(boa serpent)线程排列旧的pMap_的值。这个线程休眠,大约,200毫秒,然后唤醒,delete最近的map,进入消化休眠。

这不是一个理论上安全的方案(虽然,实际上在边界之内很不错)。不管出于什么理由,最邪恶的一件事情是,如果一个查找线程被延迟太久了,“蟒蛇”线程将在这个线程的脚下delete这个map。可以通过一直给“蟒蛇”线程赋一个低于其它任何线程的优先级,但是作为一个整体的方案,有了“臭气”之后,就很难消除了。如果你也同意,用严肃的表情很难防御这种技术,那就让那个我们继续吧。

另外一个方案[4],依赖于一个经过扩展的DCAS原子指令,它可以在内存中比较和交换两个非共边(non-contiguous)的字。

template <class T1, class T2>

bool DCAS(T1* p1, T2* p2,

T1 e1, T2 e2,

T1 v1, T2 v2) {

if (*p1 == e1 && *p2 == e2) {

*p1 = v1; *p2 = v2;

return true;

}

return false;

}

很自然了这两个位置是指针和引用技术本身。DCAS已经在Motorola 68040处理器上实现非常的低效),而不是其它的处理器。因为基于DCAS的方案被认为只有理论价值。

第一发子弹目标是一个具有确定性解构的方案,依赖于很少需求的CAS2。前面曾经提到,很多32位机器实现了64位的CAS,通常被称作CAS2。(因为它仅操作共边的字,很显然CAS2没有DCAS有效。)为了启动器,我们将引用计数的值保存到它看守的指针之后:

template <class K, class V>

class WRRMMap {

typedef std::pair<Map<K, V>*,

unsigned> Data;

Data data_;

...

};

注意了现在我们把计数保存到它保护的指针的后面这样我们就可以摆脱前面提到的catch-22问题了。我们将看它在启动一分钟内的代价。)

当访问map之前,我们改变Lookup来给引用计数加1,之后进行减1。为了简明扼要,在下面的代码段中,我们将忽略异常安全问题(谨慎使用标准计数可以实现):

V Lookup(const K& k) {

Data old;

Data fresh;

do {

old = data_;

fresh = old;

++fresh.second;

} while (CAS(&data_, old, fresh));

V temp = (*fresh.first)[k];

do {

old = data_;

fresh = old;

--fresh.second;

} while (CAS(&data_, old, fresh));

return temp;

}

最后Update用一个新的来替换这个map——但是仅当引用计数为1时有机会窗口的才可以。

void Update(const K& k,

const V& v) {

Data old;

Data fresh;

old.second = 1;

fresh.first = 0;

fresh.second = 1;

Map<K, V>* last = 0;

do {

old.first = data_.first;

if (last != old.first) {

delete fresh.first;

fresh.first =new Map<K, V>(old.first);

fresh.first->insert(make_pair(k, v));

last = old.first;

}

} while (!CAS(&data_, old, fresh));

delete old.first; // whew(嚄, 哨声)

}

这里Update是如何工作的呢我们使用到现在很熟悉的变量oldfresh。但是现在的old.second(计数)从来不会通过data_.second赋值;它一直都是1。这就意味着,Update将一直循环,直到它有一个用另外一个拥有计数器值为1的指针来替换指向一个计数器的值为1的指着的机会的窗口。用浅显的英语来说,这个循环会说“我将用一个新的map替换这个旧的,更新了一个之后,我将开始禁戒其它任何一个更新这个map,但是当且仅当只有一个map存在的时候我才进行替换。”变量last和相关的代码仅仅是一个优化:避免在旧的map还没有替换完(仅这计数器),而反反复复的重新构建map。

够整洁吧?差远了。Update现在加锁了:在有机会更新map之前,它需要等待所有的Lookup结束。随风飘扬是无锁数据结构最好性质。尤其是,很容易使Update饿死(starve Update to death):足够高的频率查找map——但是引用计数将永远都不会降到1。所以到目前为止我们得到的不是一个WRRM(Write-Rarely-Read-Many,少写多读)map,而是一个WRRMBNTM(Write-Rarely-Read-Many-But-Not-Many,少写多读,但不是太多)map。


7 结束语

无锁数据结构是非常有希望的。它们通过线程消亡、优先级反演和信号安全等展示了很多优秀的特性。它们从来不会死锁或活锁。在测试中,最近的无锁数据结构通过一个很大的容限超过了它们加锁的副本。

然而,无锁编程是一种技巧,尤其与内存分解有关。一个有垃圾收集的环境是必要的,因为它拥有一些手段来终止和审查所有的线程,但是如果你想要确定性的解构,你需要硬件或内存分配器的特殊支持。泛型编程(Generic<Programming>)的下一部分将了解在执行确定性解构时支持无锁的WRRMMap的优化方法。如果这一个部分中基于垃圾收集和WRRMBNTMmap不能满足于你,那么这儿有一个省钱的方法:不要去看电影,《Alien vs. Predator》,除非你喜欢“这么坏,太滑稽了(so bad it’s funny)”的电影。

 

8 致谢

特别感谢Krzysztof Machelski,他审核了代码的实现,并提示其中的两个bug

 

参考文献

[1] Andrei Alexandrescu. Modern C++ Design.Addison-Wesley Longman, 2001.

[2] Andrei Alexandrescu. GenerichProgrammingi:yasli::vector is on the move. C++ Users Journal,June 2004.

[3] D.R. Butenhof. Programming with POSIX Threads. Addison-Wesley, Reading, Massachusetts,USA, 1997.

[4] David L. Detlefs, Paul A. Martin, Mark Moir,and Guy L. Steele, Jr. Lock-free reference counting.In Proceedings of the twentieth annual ACM symposium on Principles of distributed computing,pages 190199. ACM Press, 2001. ISBN 1-58113-383-9.

[5] Erich Gamma, Richard Helm, Ralph E. Johnson,and John Vlissides. Design Patterns. Addison-Wesley, Reading, MA, 1995.

[6] Scott Meyers and Andrei Alexandrescu. The Perils of Double-Checked Locking. Dr. Dobbs Journal,July 2004.

[7] Maged M. Michael. Scalable lock-free dynamic memory allocation.

In Proceedings of the ACM SIGPLAN 2004 conference on Programming language design and implementation,pages 3546.ACM Press, 2004. ISBN 1-58113-807-5.


---------------源文档------------------
Lock-Free Data Structures
Andrei Alexandrescu
December 17, 2007
http://erdani.org/publications/cuj-2004-10.pdf
最后一次访问时间:2007年12月30日