Andrei Alexandrescu
December 16, 2007
译者:张桂权
12/25/2007
( 初稿阶段,没有得到许可不得引用,否则后果自负)
泛型编程(Generic<Programming>)被删除之后(我知道,认为母校(毕业的学校)要求一切,不仅仅是100%的个人时间,是非常天真的),对于本篇文章来说,到目前为止,就没有丰富的题材了。一个备选主题是构造器的讨论,尤其是前沿的构造器,异常处理和双角色(two-stage)对象构造。另一个主题 —— 再瞥一眼Yaslander技术[2] —— 创建不完全类型的容器(比如,lists, vectors, maps),凭借一些有趣的技巧完全可以实现,但是没有标准容器的保证。
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-Locked)WRRM 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是如何工作的呢?我们使用到现在很熟悉的变量old和fresh。但是现在的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的优化方法。如果这一个部分中基于垃圾收集和WRRMBNTM的map不能满足于你,那么这儿有一个省钱的方法:不要去看电影,《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 190–199. 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. Dobb’s 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 35–46.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日