当前位置: 首页 > 科技观察

原子操作vs非原子操作

时间:2023-03-14 00:28:39 科技观察

网上已经有很多介绍原子操作的内容,通常都集中在原子读-修改-写(RMW)操作上。然而,这些并不都是原子操作,还有同样重要的原子加载和原子存储。在本文中,我将在处理器级别和C/C++语言级别比较原子加载和存储与非原子对应项。在此过程中,我们将阐明C++11中“数据竞争”的概念。共享内存中的原子操作是指是否完成了线程相关的单步操作。当原子存储作用于共享变量时,其他线程无法监视该值的未决修改。当原子加载作用于共享变量时,它会读取整个值,就好像它是单个时刻一样,而非原子加载和存储不提供这些保证。没有这些保证,无锁编程将是不可能的,因为您不能让不同的线程同时对共享变量进行操作。我们可以制定如下规则:任何时候两个线程同时操作一个共享变量,当其中一个是写操作时,这两个线程必须使用原子操作。如果违反这条规则,每线程使用非原子操作,就会看到C++11标准中提到的datarace(不要和Java中datarace的概念混淆,两者是不一样的,或者说是更普遍的竞争情况)。C++11标准不会告诉您为什么数据竞争很糟糕,但只要您有数据竞争,就会发生“未定义的行为”(第1.10.21节)。造成这种不良数据竞争的原因很简单:它们会导致读取和写入中断。内存操作可以是非原子的,因为它使用非原子多CPU指令,即使使用单CPU指令时也是如此,因为您无法简单地想象您编写的可移植代码。让我们看几个例子。非原子性是由于多个CPU指令,假设您有一个初始化为0的64位全局变量。uint64_tsharedValue=0;在某些时候,您为该变量分配一个64位值。voidstoreValue(){sharedValue=0x100000002;}在32位x86环境下使用GCC编译该函数时,会生成如下机器码。$gcc-O2-S-masm=inteltest.c$cattest.s...movDWORDPTRsharedValue,2movDWORDPTRsharedValue+4,1ret...此时你会看到编译器会使用两条独立的机器指令来完成这个64位任务。第一条指令将低32位设置为0×00000002,第二条指令将高32位设置为0×00000001。显然,这个赋值操作是非原子的。如果共享变量同时被不同的线程访问,会出现很多错误:如果一个线程在两条机器指令之间先调用存储变量,它会在内存中留下一个类似0×0000000000000002这样的值——这就是写撕裂。此时,如果另一个线程读取共享变量,它将收到一个完全虚假的值,没有人愿意存储。更糟糕的是,如果一个线程首先占用了两个机器指令之间的变量,而另一个线程在第一个线程重新获取该变量之前修改了sharedValue,这将导致永久性的写撕裂:一个线程获得高32位,另一个线程获得低32位32位。在多核设备上,不仅仅是抢占其中一个线程导致写撕裂。当一个线程调用storeValue时,另一个核心上的任何线程都可能同时读取一个明显未修改的sharedValue。同时读取sharedValue会给它带来一系列的问题:uint64_tloadValue(){returnsharedValue;}$g??cc-O2-S-masm=inteltest.c$cattest.s...moveax,DWORDPTRsharedValuemovedx,DWORDPTRsharedValue+4ret...另外,编译器会使用两条机器指令来执行这个加载:第一个将低32位读入eax,第二个将高32位读入edx。在这种情况下,如果您对sharedValue进行同步存储,您会发现它会导致读取撕裂——即使同步存储是原子的。问题不是理论上的。Mintomic的测试集包含一个名为test_load_store_64_fail的测试用例。在这种情况下,一个线程使用常见的赋值操作将许多64位值存储到单个变量中,而另一个线程重复地对该变量执行简单加载,确认每个结果。在多核x86机器上,此测试始终如预期的那样失败。#p#非原子CPU指令内存操作可以是非原子的,即使由单个CPU指令执行也是如此。例如,ARMv7指令集包括strd指令,该指令将两个32位源寄存器的内容存储到内存中的64位值。strdr0,r1,[r2]在某些ARMv7处理器中,此指令是非原子的。当处理器遇到这条指令时,它实际上在后台执行两个独立的32位存储(A3.5.3节)。再一次,另一个线程在单独的内核上运行,可以观察到写撕裂。有趣的是,写撕裂更容易发生在单核设备上:例如,一个用于调度线程上下文切换的系统中断确实可以在两个内部32位存储之间执行!在这种情况下,当线程从中断中恢复时,它会再次调用strd指令。再看一个例子,我们都知道在x86环境下,如果内存操作数是自然对齐的,那么一条32位的mov指令就是原子的,如果不是自然对齐的,那么就是非原子的。换句话说,只有当32位整数的地址恰好是4的倍数时,原子性才能得到保证。Mintomic提供了另一个验证这种保证的测试用例,test_load_store_32_fail。如所写,此测试在x86上总是成功,但如果您修改此测试以强制sharedInt位于未对齐的地址,它将失败。在我的Core2QuadQ6600上,此测试失败,因为sharedInt在寄存器中超出范围。//ForcesharedInttocrossacachelineboundary:#pragmapack(2)MINT_DECL_ALIGNED(staticstruct,64){charpadding[62];mint_atomic32_tsharedInt;}g_wrapper;既然有许多特定于处理器的细节,让我们看看C/C++语言级别的原子性。所有C/C++操作都被认为是非原子的在C和C++中,所有操作都被认为是非原子的,即使是普通的32位整数赋值,除非由另一个编译器或硬件供应商指定。uint32_tfoo=0;voidstoreFoo(){foo=0x80286;}在这种情况下,语言标准没有说明任何关于原子性的内容。也许整数赋值是原子的,也许不是。因为非原子操作没有保证,普通整数赋值在C中根据定义是非原子的。事实上,我们对我们的目标平台了解更多。比如大家知道,在目前的x86、x64、Itanium、SPARC、ARM、PowerPC处理器上,只要目标变量自然对齐,那么普通的32位整数赋值就是原子的。您可以查看您的处理器手册或编译器文档来确认。在游戏行业,我可以告诉你很多依赖这种特殊保证的32位整数赋值的例子。尽管如此,在编写真正可移植的C和C++代码方面有一个长期的传统,即我们只知道语言标准告诉我们的内容。可移植的C和C++代码旨在在任何可能的计算设备上运行,无论是过去的、现在的还是虚拟的。就个人而言,我想设计一台机器,其内存只能按照先到先得的原则进行更改:在这样的机器上,您永远不会希望在普通赋值的同时执行并发读取操作,您最终可以读取一个完整的随机值。在C++11中,有一个最终的解决方案来执行实际的可移植原子加载和存储——C++11原子库。通过使用C++11原子库来执行原子加载和存储,甚至可以在虚拟计算机上运行,??即使这意味着C++11原子库必须默默地添加互斥量以确保每个操作都是原子的。还有我上个月发布的Mintomic库,它不支持那么多平台,但是可以在很多以前的编译器上运行,优化过,保证无锁。宽松的原子操作让我们回到最开始的sharedValue例子,我们将重写它以使用Mintomic,这样所有的操作都可以在Mintomic支持的任何平台上以原子方式执行。首先,我们必须将sharedValue声明为Mintomic原子数据类型之一。#includemint_atomic64_tsharedValue={0};mint_atomic64_t类型保证所有平台上原子访问的正确内存对齐。这非常重要,因为例如,Xcode3.2.5随ARM的GCC4.2编译器一起提供并不能保证普通uint64_t是8字节对齐的。对于storeValue,我们必须调用mint_store_64_relaxed,而不是执行普通的非原子分配。voidstoreValue(){mint_store_64_relaxed(&sharedValue,0x100000002);}同样,在loadValue中,我们调用mint_load_64_relaxed。uint64_tloadValue(){returnmint_load_64_relaxed(&sharedValue);}使用C++11术语,这些函数现在没有数据竞争。在执行并发操作时,无论代码运行在ARMv6/ARMv7(Thumb或ARM模式)、x86、x64还是PowerPC上,都绝对没有读写撕裂的可能。想知道mint_load_64_relaxed和mint_store_64_relaxed是如何工作的吗,这两个函数在x86上都扩展为一个内联的cmpxchg8b指令,其他平台请参考Mintomic实现。清楚地在C++11中编写类似的代码:#includestd::atomicsharedValue(0);voidstoreValue(){sharedValue.store(0x100000002,std::memory_order_relaxed);}uint64_tloadValue(){returnsharedValue.load(std::memory_order_relaxed);}您会注意到在Mintomic和C++11示例中都使用了宽松的原子性,带有_relaxed后缀的多个标识符证明了这一点。_relaxed后缀意味着,与普通的加载和存储一样,不能保证内存访问的顺序。宽松的原子加载(或存储)和非原子加载(或存储)之间的唯一区别是宽松的原子操作保证原子性,除此之外别无其他。特别是,在程序指令中,如果由于编译器重新排序或内存重新排序而受到处理器本身中任何先前或后续指令的影响,则宽松的原子操作对内存仍然有效。编译器甚至可以对冗余的宽松原子操作执行优化,就像它对非原子操作一样。在所有情况下,这些操作仍然是原子的。当并发操作同时共享内存时,我认为始终使用Mintomic或C++11原子库函数是一种很好的做法,即使您知道正常的加载或存储在您的目标平台上已经是原子的了。原子库函数就像一个提示,表明该变量是并发数据存储的目标。希望现在大家能更清楚的明白为什么《世界上最简单的无锁哈希表》要使用Mintomic库函数来并发操作不同线程的共享内存。原文链接:http://preshing.com/20130618/atomic-vs-non-atomic-operations/翻译链接:http://blog.jobbole.com/54345/