C++ 原子操作和内存模型
2011-07-30 22:21
246 查看
最近有一个困扰我的问题:如何使C++的原子变量高效而且可移植?
我知道Java volatile是怎么工作的——它强制实行顺序一致性(sequential consistency),但是这个方法并不总是效率最高的。
C++0x原子变量在默认模式下也一样强制实施顺序一致性。如果没有特别的顺序注记(annotation),它们和Java volatile几乎一模一样(有趣的是,Java的volatile并不强制原子性——尽管有个atomic library来实现这一点)。
但是C++可以在不同程度上放松顺序一致性的限制,如果使用得当的话,将会产生效率更高的代码。
在学习了一些x86的内存模型的知识后,我认识到一些基本的lock-free pattern无锁编程模式(比如我在double-checked locking重复检查锁模式中就发现了一种)可以直接运行而无需任何栅障同步(fence)。我们需要一种C++编程思路,当编译成x86代码时,不产生栅障,而在编译成alpha或Power PC这样的非x86代码时,产生需要的栅障。
让事情更加有趣的是,一些其他的算法,如Peterson锁,在x86上还是需要内存栅障(请看我之前的blog)。所以也不是简单的取消所有栅障就能搞定的。
我将我的问题缩短成:如何编写课移植的C++代码,使之可以在x86上转化成不多不少恰好正确的栅障数量?
指定C++程序的内存顺序
C++0x原子库提案在变成现在这样子之前经历了很多改变。现在已经有了一个完整的C++0x草案(译者注:本文写作于2008年12月1日)。我很感谢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);
如你所见,原子对象拥有store和load方法用来读写下层共享数据。默认情况下,这些方法强制顺序一致性。就是说这些代码和Java volatile拥有同样的语义。因此在x86上面,它将在每次store的时候生成内存栅障。
但是我们知道这些栅障对公共数据的安全保障不是必要的。我们如何编写生成最少栅障的x86代码呢?
为了开启这样的优化,C++0X原子库允许程序员对每个load和store操作指定顺序需求。接下来我将解释不同的顺序选项;现在我们想看看公共数据模式的优化版本:
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: 所有重排序都没有问题。
就像我之前讨论的一样,x86对load保持acquire语义而对store保持release语义,所以load(memory_order_acquire)和store(x, memory_order_release)不会生成栅障。因此实际上,我们的公共数据模式的优化版本在x86上会生成最优的汇编代码(我推测在其它CPU上也是这样的)。
但是我以前也展示过,在x86上,Peterson算法没有栅障就不能工作。所以,只使用memory_order_acquire和memory_order_release来实现它是不够的。实际上,Peterson锁的问题是重排序load和store。而能够阻止这类重排序的最弱的约束是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的评论——特别是Anthony和Dmitriy的,他们的评论让我服输认错。总之,关键在于,除非另一个线程写到(“release”)同一个变量,否则memory_order_acq_rel 的“acquire”部分没有意义。因为只有线程0写入_interested[0],也只有线程1写入_interested[1],这个同步其实什么也没有做(在非x86体系)。Dmitriy的实现是正确的,他同步了_victim(但是为了理解Anthony对它的的证明,我不得不问了很多问题)。
结论
这个例子最打击我的是证明这个实现(是正确的)的理由太难了。我不得不仔细查看x86汇编才认识到我需要这些儿不是其他的顺序约束(而且我最终还是搞错了!)。和它比较,老掉牙的汇编编程看起来是小事一桩。
无论何时只要你脱离了顺序一致性,你就把问题的复杂度增加了几个数量级。
微软volatile
我不是在说股票市场。我之前提到过C++ volatile与多线程毫不相干。这不是完全正确的,因为一些编译器厂商冒昧的对volatile添加了非标准语义(仅仅是出于对0x之前的C++标准的绝望,因为他们要支持多处理器代码,但是标准在这方面没有任何帮助)。至少在微软的编译器中,这种新语义不包括顺序一致性。相反它爆炸acquire和release语义(在x86上不会生成任何栅障)。所以,尽管公共数据模式可以在微软编译器上以volatile变量的形式执行,Peterson锁仍然不能执行!我想这是一个有趣的小事(trivia,平凡,琐碎的事情)
我知道Java volatile是怎么工作的——它强制实行顺序一致性(sequential consistency),但是这个方法并不总是效率最高的。
C++0x原子变量在默认模式下也一样强制实施顺序一致性。如果没有特别的顺序注记(annotation),它们和Java volatile几乎一模一样(有趣的是,Java的volatile并不强制原子性——尽管有个atomic library来实现这一点)。
但是C++可以在不同程度上放松顺序一致性的限制,如果使用得当的话,将会产生效率更高的代码。
在学习了一些x86的内存模型的知识后,我认识到一些基本的lock-free pattern无锁编程模式(比如我在double-checked locking重复检查锁模式中就发现了一种)可以直接运行而无需任何栅障同步(fence)。我们需要一种C++编程思路,当编译成x86代码时,不产生栅障,而在编译成alpha或Power PC这样的非x86代码时,产生需要的栅障。
让事情更加有趣的是,一些其他的算法,如Peterson锁,在x86上还是需要内存栅障(请看我之前的blog)。所以也不是简单的取消所有栅障就能搞定的。
我将我的问题缩短成:如何编写课移植的C++代码,使之可以在x86上转化成不多不少恰好正确的栅障数量?
指定C++程序的内存顺序
C++0x原子库提案在变成现在这样子之前经历了很多改变。现在已经有了一个完整的C++0x草案(译者注:本文写作于2008年12月1日)。我很感谢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);
如你所见,原子对象拥有store和load方法用来读写下层共享数据。默认情况下,这些方法强制顺序一致性。就是说这些代码和Java volatile拥有同样的语义。因此在x86上面,它将在每次store的时候生成内存栅障。
但是我们知道这些栅障对公共数据的安全保障不是必要的。我们如何编写生成最少栅障的x86代码呢?
为了开启这样的优化,C++0X原子库允许程序员对每个load和store操作指定顺序需求。接下来我将解释不同的顺序选项;现在我们想看看公共数据模式的优化版本:
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: 所有重排序都没有问题。
就像我之前讨论的一样,x86对load保持acquire语义而对store保持release语义,所以load(memory_order_acquire)和store(x, memory_order_release)不会生成栅障。因此实际上,我们的公共数据模式的优化版本在x86上会生成最优的汇编代码(我推测在其它CPU上也是这样的)。
但是我以前也展示过,在x86上,Peterson算法没有栅障就不能工作。所以,只使用memory_order_acquire和memory_order_release来实现它是不够的。实际上,Peterson锁的问题是重排序load和store。而能够阻止这类重排序的最弱的约束是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的评论——特别是Anthony和Dmitriy的,他们的评论让我服输认错。总之,关键在于,除非另一个线程写到(“release”)同一个变量,否则memory_order_acq_rel 的“acquire”部分没有意义。因为只有线程0写入_interested[0],也只有线程1写入_interested[1],这个同步其实什么也没有做(在非x86体系)。Dmitriy的实现是正确的,他同步了_victim(但是为了理解Anthony对它的的证明,我不得不问了很多问题)。
结论
这个例子最打击我的是证明这个实现(是正确的)的理由太难了。我不得不仔细查看x86汇编才认识到我需要这些儿不是其他的顺序约束(而且我最终还是搞错了!)。和它比较,老掉牙的汇编编程看起来是小事一桩。
无论何时只要你脱离了顺序一致性,你就把问题的复杂度增加了几个数量级。
微软volatile
我不是在说股票市场。我之前提到过C++ volatile与多线程毫不相干。这不是完全正确的,因为一些编译器厂商冒昧的对volatile添加了非标准语义(仅仅是出于对0x之前的C++标准的绝望,因为他们要支持多处理器代码,但是标准在这方面没有任何帮助)。至少在微软的编译器中,这种新语义不包括顺序一致性。相反它爆炸acquire和release语义(在x86上不会生成任何栅障)。所以,尽管公共数据模式可以在微软编译器上以volatile变量的形式执行,Peterson锁仍然不能执行!我想这是一个有趣的小事(trivia,平凡,琐碎的事情)
相关文章推荐
- 第五章 C++的内存模型和原子操作
- Cpp Concurrency In Action(读书笔记4)——C++内存模型和原子类型操作
- C++0x 内存模型和原子操作 (std:::atomic memory order等相关资料)
- OpenGL高级特性之利用Image内存模型&计算着色器&原子操作实现(直方图模型)通用计算
- C++0x 内存模型和原子操作 (std:::atomic memory order等相关资料)
- 《C++ Concurrency in Action》读书笔记四 c++内存模型和原子类型
- 模型内存顺序和原子操作顺序(1)
- C/C++学习之C提高----函数调用模型、指针做函数参数、字符串的基本操作、一级指针内存模型建立
- 图说C++对象模型:对象内存布局详解
- [c/c++ 深入探讨数组内存模型]答‘杨建伟 ’网友问
- c++对象内存模型【内存布局】
- c++对象模型-默认构造函数的构造操作
- 《C++ Primer Plus》第9章 内存模型和名称空间 学习笔记
- 图说C++对象模型:对象内存布局详解
- C++PrimerPlus第九章学习笔记——内存模型和名称空间
- C++ 06 继承与组合 (has-a is-a) 以及类大小的计算 虚基类对内存模型的影响(不考虑虚函数)
- C/C++ 内存操作函数集合(Buffer Manipulation)
- C++内存分配操作
- C++学习之C++对象内存模型(上)
- C++对象的内存模型