gorden曹的地盘

 

C++ 原子操作和内存模型

最近有一个困扰我的问题:如何使C++的原子变量高效而且可移植?

    我知道Java volatile是怎么工作的——它强制实行顺序一致性(sequential consistency),但是这个方法并不总是效率最高的。

       C++0x原子变量在默认模式下也一样强制实施顺序一致性。如果没有特别的顺序注记(annotation),它们和Java volatile几乎一模一样(有趣的是,Javavolatile并不强制原子性——尽管有个atomic library来实现这一点)。

    但是C++可以在不同程度上放松顺序一致性的限制,如果使用得当的话,将会产生效率更高的代码。

    在学习了一些x86的内存模型的知识后,我认识到一些基本的lock-free pattern无锁编程模式(比如我在double-checked locking重复检查锁模式中就发现了一种)可以直接运行而无需任何栅障同步(fence)。我们需要一种C++编程思路,当编译成x86代码时,不产生栅障,而在编译成alphaPower PC这样的非x86代码时,产生需要的栅障。

    让事情更加有趣的是,一些其他的算法,如Peterson锁,在x86上还是需要内存栅障(请看我之前的blog)。所以也不是简单的取消所有栅障就能搞定的。

    我将我的问题缩短成:如何编写课移植的C++代码,使之可以在x86上转化成不多不少恰好正确的栅障数量?

指定C++程序的内存顺序

       C++0x原子库提案在变成现在这样子之前经历了很多改变。现在已经有了一个完整的C++0x草案(译者注:本文写作于2008121)。我很感谢Hans Boehm阐明一些细节,因为这个草案并不容易解读。

    如果没有别的问题,下面的代码将是C++原子操作访问公共数据的安全模式(所有重复检查锁模式的前身):(原文:Without further ado, this is how the publication safety pattern (the mother of all double-checked locking patterns) could be written in C++:——publication safety pattern不知道怎么翻译。)

atomic<bool> ready = false;

atomic<int> data = 0;

线程0

data.store(1);

ready.store(true);

线程1

if (ready.load())

assert (data.load() == 1);

    如你所见,原子对象拥有storeload方法用来读写下层共享数据。默认情况下,这些方法强制顺序一致性。就是说这些代码和Java volatile拥有同样的语义。因此在x86上面,它将在每次store的时候生成内存栅障。

    但是我们知道这些栅障对公共数据的安全保障不是必要的。我们如何编写生成最少栅障的x86代码呢?

    为了开启这样的优化,C++0X原子库允许程序员对每个loadstore操作指定顺序需求。接下来我将解释不同的顺序选项;现在我们想看看公共数据模式的优化版本:

atomic<bool> ready = false;

atomic<int> data = 0;

线程0

data.store(1, memory_order_release);

ready.store(true, memory_order_release);

线程1

if (ready.load(memory_order_acquire))

assert (data.load(memory_order_acquire) == 1);

    重要的是这段代码将在所有的主流处理器上正确运行,但是在x86上不会生成栅障。

    警告:即使你知道你的程序只会在x86上运行,你也不能从代码中移除原子性和顺序约束。你需要它们来阻止编译器重新排序你的代码。

精确控制内存顺序

可以用下面的枚举指定内存顺序:

namespace std {

typedef enum memory_order {

memory_order_relaxed,

memory_order_consume,

memory_order_acquire,

memory_order_release,

memory_order_acq_rel,

memory_order_seq_cst

} memory_order;

}

    所有原子变量操作的默认值是和Java volatile一样强制执行顺序一致性语义的memory_order_seq_cst。其他的用来放宽顺序一致性以从无锁算法中获得更好的性能。

l memory_order_acquire: 保证后面的load不会被移动到当前load或更早的load

l memory_order_release: 前面的store不会被移动到当前store或者更靠后的store

l memory_order_acq_rel: 上面2者的结合。

l memory_order_consume: 潜在弱化的memory_order_acquire版本,保证当前load先于其他那些依赖于它的操作(例如,当load一个指针的操作被标记为memory_order_acquire,后面解引用这个指针的操作不会被移动到load之前(对,即使它不能保证被所有的平台支持!))。

l memory_order_relaxed: 所有重排序都没有问题。

    就像我之前讨论的一样,x86load保持acquire语义而对store保持release语义,所以load(memory_order_acquire)store(x, memory_order_release)不会生成栅障。因此实际上,我们的公共数据模式的优化版本在x86上会生成最优的汇编代码(我推测在其它CPU上也是这样的)。

    但是我以前也展示过,在x86上,Peterson算法没有栅障就不能工作。所以,只使用memory_order_acquirememory_order_release来实现它是不够的。实际上,Peterson锁的问题是重排序loadstore。而能够阻止这类重排序的最弱的约束是memory_order_acq_rel

    现在趣事出现了。还没有经过仔细思考,我就决定在所有的写操作上使用memory_order_acq_rel就可以解决问题。这是我最初的代码:

begin quote】这种情况下正确的C++代码是:(译者注:作者可能使用引用功能出错了。)

Peterson::Peterson() {

_victim.store(0, memory_order_release);

_interested[0].store(false, memory_order_release);

_interested[1].store(false, memory_order_release);

}

void Peterson::lock() {

int me = threadID; // either 0 or 1

int he = 1 – me; // the other thread

_interested[me].exchange(true, memory_order_acq_rel);

_victim.store(me, memory_order_release);

while (_interested[he].load(memory_order_acquire)

&& _victim.load(memory_order_acquire) == me)

continue; // spin

}

       memory_order_acq_rel顺序在x86上生成一个mfence指令。这个栅障指令可以保证_interested[he]load指令不会被移动到_interested[me]store指令之前,从而避免了破坏算法的错误。【end quote

    你可以阅读本blog的评论——特别是AnthonyDmitriy的,他们的评论让我服输认错。总之,关键在于,除非另一个线程写到(“release”)同一个变量,否则memory_order_acq_rel 的“acquire”部分没有意义。因为只有线程0写入_interested[0],也只有线程1写入_interested[1],这个同步其实什么也没有做(在非x86体系)。Dmitriy的实现是正确的,他同步了_victim(但是为了理解Anthony对它的的证明,我不得不问了很多问题)。

结论

这个例子最打击我的是证明这个实现(是正确的)的理由太难了。我不得不仔细查看x86汇编才认识到我需要这些儿不是其他的顺序约束(而且我最终还是搞错了!)。和它比较,老掉牙的汇编编程看起来是小事一桩。

无论何时只要你脱离了顺序一致性,你就把问题的复杂度增加了几个数量级。

微软volatile

    我不是在说股票市场。我之前提到过C++ volatile与多线程毫不相干。这不是完全正确的,因为一些编译器厂商冒昧的对volatile添加了非标准语义(仅仅是出于对0x之前的C++标准的绝望,因为他们要支持多处理器代码,但是标准在这方面没有任何帮助)。至少在微软的编译器中,这种新语义不包括顺序一致性。相反它爆炸acquirerelease语义(在x86上不会生成任何栅障)。所以,尽管公共数据模式可以在微软编译器上以volatile变量的形式执行,Peterson锁仍然不能执行!我想这是一个有趣的小事(trivia,平凡,琐碎的事情)

posted on 2011-07-30 22:21  Raffaele曹  阅读(3070)  评论(2编辑  收藏  举报

导航