原文连接:atomic-vs-non-atomic-operationsgit
在网上已经写了不少关于原子操做的文章,可是一般都集中在原子的读-修改-写(RMW. read-modify-write)操做。可是这些并是全部的原子操做。一样重要的属于原子操做的仍是有load(译注:读)和store(译注:写)。在这篇文章中,我将会在处理器层面和C/C++语言层面,比较原子性和非原子性的load和store。顺便,咱们将会阐明如下在C++11中的“数据竞争”概念。程序员
若是一个共享变量的操做,它能相对于其余线程,可以一步完成,那么这个操做就是原子性的操做。当对一个共享变量执行原子性的store操做,其余线程只能观察到它已经修改完后的数据。当对一个共享变量执行原子性的load操做,它会读取单一时刻所显示的完整的值。非原子性的store和load不会有上述的保证。github
离开上述的保证,无锁编程(lock-free programming)将变得不可能,由于不能在相同时间,让多个线程操做同一个共享变量。咱们能够将此明确表达为一个规则:编程
任什么时候间,两个线程并发地操做在一个共享变量上,这些操做中的一个执行一个写动做,全部的线程都必须使用原子操做。
若是你违反这个规则,其中有个线程使用了非原子操做,那么你将会陷入一个在C++11标准中称之为数据竞争(不要和Java中的data race概念,以及更通用的race condition搞混淆)的情形。C++11标准没有告诉编程人员为何数据竞争是很差的。可是若是你引起了数据竞争,那么就会获得一个"未定义行为(undefined behavior)"的结果。数据竞争是很差的真正理由只有一个:它们会致使“撕裂读”(torn reads)和“撕裂写”(torn writes。译注:就是一个非完整的读写)。缓存
一个内存操做多是非原子的,由于它使用了多条CPU指令,甚至即便使用单条CPU指令,也多是非原子的。也多是由于程序员写的可移植代码。可是不能简单地作出这个假设。让咱们看几个例子。并发
假设有一个64位的全局变量,初始化为0.app
1 uint64_t sharedValue = 0;
此时,将一个64位的值更新到此变量:
ide
1 void storeValue() 2 { 3 sharedValue = 0x100000002; 4 }
在32位的 x86平台上,使用GCC编译此函数,会产生如下汇编代码:
函数
1 $ gcc -O2 -S -masm=intel test.c 2 $ cat test.s 3 ... 4 mov DWORD PTR sharedValue, 2 5 mov DWORD PTR sharedValue+4, 1 6 ret 7 ...
如你所见,编译器实现一个64位整形的赋值是经过两个单独的机器指令。第一条指令将低32位设置为0x00000002,第二条指令将高32位设置为0x00000001。很明显,这个赋值操做不是原子操做。若是sharedValue被不一样线程并发访问,将会出错。测试
1. 若是一个线程在两条指令之间对sharedValue的访问时独占的,那么在内存中,sharedValue将会被设置为0x0x0000000000000002,
一个“撕裂写(a torn write)”。此时,若是另一个线程读取sharedValue的值,那么将会读到一个彻底虚假的值。
2. 更遭的是,若是一个线程在两条指令之间进行独占访问,此时另外一个在第一个线程恢复前修改变量sharedValue,会
致使一个永久性的“撕裂写(torn write)”:高32位来源于一个线程,低32位来源于另外一个线程。
3. 在多核设备中,线程都不必进行一个会致使“撕裂写”的资源抢占。由于当一个线程调用sharedValue,在不一样核心上的
任意线程在某个时刻均可能会去读sharedValue,此时的sharedValue可能处于修改的一半当中。
并发地从sharedValue读也会带来一些问题:
1 uint64_t loadValue() 2 { 3 return sharedValue; 4 } 5 6 $ gcc -O2 -S -masm=intel test.c 7 $ cat test.s 8 ... 9 mov eax, DWORD PTR sharedValue 10 mov edx, DWORD PTR sharedValue+4 11 ret 12 ... 13
一样,编译器用两条机器指令实现读取操做:第一条指令读取低32位的值到eax寄存器,而后第二条指令读取高32位的值到edx寄存器。在这种状况下,并发地发生一个写的操做,此时会产生一个“撕裂读(torn read)”。即便这个并发的写是原子操做。
这些问题并不仅是存在于理论上。Mintomic的测试套件中包含了一个叫test_load_store_64_fail的测试用例。一个线程使用普通的赋值操做符更新一个64位变量的值,另外一个线程周期性地执行一个从相同变量的读取操做,对每次读取回来的结果进行校验。在x86多核机器上,和预期同样,此测试会常常失败。
即便执行单条CPU指令,一个内存操做也多是非原子性的。例如:在ARMv7指令集中,包含了一个strd指令,实现将两个32位的寄存器的值存储到一个64位的变量中。
1 strd r0, r1, [r2]
在一些ARMv7处理器中,这条指令时非原子性的。当处理器碰到这条指令时,其实是执行2条32位的单独存储动做。再一次,任何运行在其余核心的线程均可能会观察到一个“撕裂读(torn write)”。有意思的是,“撕裂读(torn write)”甚至可能会发生在单核设备中:由于系统中断。在2条32位存储指令中间,可能会发生线程上下文的调度切换。这种状况下,当线程从中断中恢复后,将会从新执行一次strd指令。
另一个例子,是发生在你们熟知的x86平台上。一个32位的mov指令只有在内存操做数是天然对齐的状况下才是原子性的!其余状况下是非原子性的。换句话说,一个32位的整形,只有它的内存地址是4的整数倍状况下,原子性才能有保证。Mintomic有另外一个测试用例test_load_store_32_fail,能够验证此种状况。在写本文的时候(译注:2013年6月),这个测试用例在x86平台上老是成功的。可是若是你将测试变量sharedInt的地址强制修改成非对齐的内存地址,那么测试结果将会失败。在个人Core 2 Quad Q6600机器上,若是sharedInt是跨越了单条缓存行界限(crosses a cache line boundary),那么测试就会失败。
1 // Force sharedInt to cross a cache line boundary: 2 #pragma pack(2) 3 MINT_DECL_ALIGNED(static struct, 64) 4 { 5 char padding[62]; 6 mint_atomic32_t sharedInt; 7 } 8 g_wrapper;
对于特定处理的状况已经说的够多了,接下来看看在C/C++语言层面的原子性。
在C和C++中,每个操做都被假定为非原子性的,即便是普通的32位整形赋值。除非编译器或硬件厂商有特殊说明。
1 uint32_t foo = 0; 2 3 void storeFoo() 4 { 5 foo = 0x80286; 6 } 7
语言标准中没有说起关于以上状况的原子性。也许整形赋值是原子性的,也许不是。由于非原子性的操做不作任何保证,因此在C中定义普通的整形赋值时非原子性的。
在实际中,咱们一般更了解咱们的目标平台。例如:在全部的现代x86,x64,Itanium,SPARC,ARM和PowerPC处理器中,普通的32位整形,只要内存地址是对齐的,那么赋值操做就是原子操做。你能够经过查看处理器手册或者编译器文档来证明。在游戏产业,不少32位的赋值时依赖于这个特别的保证。
尽管如此,当写真正的可移植的C和C++代码时,有一个长期的假装的传统就是,咱们只知道语言标准中所记录的,除此以外,一律不知。可移植的C/C++代码是要运行在每台可能的设备上,过去的设备,如今的设备以及想象中的设备。从我我的来讲,我喜欢想象有台机器,只能被一开始的混乱所改变。
在这样的机器上,你绝对不会想在同一时间执行并发的读操做,即便是普通的赋值。你可能最终只会读到一个彻底随机的值。
在C++11中,有一种方式能够真正执行可移植的load原子操做和store原子操做:C++11 atomic库。使用C++11 atomic库,即便是运行在想象的机器上,也能够执行原子性的load和store。即便在C++11 atomic库的内部秘密地使用互斥锁使每一个操做变得原子性。一样还有一个我上个月发布的叫Mintomic的库(译注:2013年6月,此库目前已废。)。虽然支持的平台可能很少,可是在几个老的编译器上仍是能够正常工做的,它是手工优化的而且保证是无锁的。
让咱们回到原来的sharedValue例子。咱们将会使用Mintomic对其进行重写。这样在Mintomic支持的平台上,全部的操做都是原子性的了。首先,必须将sharedValue声明为Mintomic的原子数据类型的一种。
1 #include <mintomic/mintomic.h> 2 3 mint_atomic64_t sharedValue = { 0 }; 4
mint_atomic64_t类型在不一样的平台上,保证原子访问都有正确的内存对齐。这很重要。由于在一些平台的编译器中并不作出相似的保证。好比ARM上的和Xcode 3.2.5绑定的GCC4.2版,就不保证普通的uint64_t是8字节对齐的。
在修改sharedValue时,再也不调用普通的、非原子的赋值操做,而是调用mint_store_64_relaxed
1 void storeValue() 2 { 3 mint_store_64_relaxed(&sharedValue, 0x100000002); 4 }
一样的,在读取sharedValue变量的值时,咱们使用mint_load_64_relaxed
1 uint64_t loadValue() 2 { 3 return mint_load_64_relaxed(&sharedValue); 4 }
使用C++11的术语来讲,上述方法是无数据竞争(data race-free)的。在执行并发操做时,绝对不可能存在“撕裂读”或“撕裂写”。不论是运行在ARMv6/ARMv7,x86,x64或PowerPC。
下面是C++11的版本
1 #include <atomic> 2 3 std::atomic<uint64_t> sharedValue(0); 4 5 void storeValue() 6 { 7 sharedValue.store(0x100000002, std::memory_order_relaxed); 8 } 9 10 uint64_t loadValue() 11 { 12 return sharedValue.load(std::memory_order_relaxed); 13 } 14
你可能注意到,无论Mintomic仍是C++11版本的代码都使用了relaxed语义的原子操做,也就是带有_relaxed后缀的内存序列参数。
特别地,关于relaxed语义的原子操做,在此原子操做的以前或者以后的指令均可能被影响,也就是被乱序执行。多是由于编译器指令乱序或者处理器的指令乱序。编译器可能仍是在重复的relaxed原子操做上作一些优化,就像在非原子性的操做上同样。在全部的状况下,这个操做都是原子操做。
当并发地操做共享变量,一向地使用C++11 atomic库或者Mintomic是个好习惯,即便是你知道在你所针对的平台上,普通的load或store操做已是原子操做。一个atomic库的方法能够起到一个提示做用,提示这个变量是并发访问的。