C++11原子操作x86内存顺序

6
在C++0x中的原子变量文档中,关于内存顺序的描述中提到:
释放-获取顺序
在强序系统(x86、SPARC、IBM大型机)上,自动执行释放-获取顺序。对于此同步模式,不会发出任何其他CPU指令,只会影响某些编译器优化...
首先,x86是否遵循严格的内存顺序?似乎总是强制执行这一点非常低效。这意味着每个写和读操作都需要一个栅栏吗?
另外,如果我有一个对齐的int,在x86系统上,原子变量是否有任何作用?
4个回答

12

没错,x86架构有严格的内存顺序,参见Intel手册第3A卷第8.2章。早期的x86处理器(如386)提供了真正严格的顺序(称为强顺序)语义,而更现代的x86处理器在少数情况下略微放松了一些条件,但这些都不是您需要担心的问题。例如,Pentium和486允许读高速缓存未命中的操作比缓冲写先执行,当写是高速缓存命中(因此与读的地址不同)时。

是的,这可能效率低下。有时,由于此原因,高性能软件仅针对具有较松散内存排序要求的其他架构编写。

是的,在x86上原子变量仍然有用。它们具有特殊的语义,可以使典型的读-修改-写操作在原子级别进行。如果您有两个线程同时增加原子变量(我指的是在C++11中类型为std::atomic<T>的变量),则可以确保值将增加2;如果没有std::atomic,您可能会得到错误的值,因为一个线程在执行增量时将当前值缓存在寄存器中,即使在x86上对内存的存储是原子的。


1
谢谢。你在最后一段描述的内容可以通过“volatile”实现。我只是想知道为什么他们要费这么大的力气将其添加到标准中,当似乎大多数架构已经具有强有序性。 - excalibur
3
使用volatile关键字只能在某些编译器中起作用(作为扩展)。这就是为什么标准添加了atomic<>的原因。 - Bo Persson
5
不行,volatile并不一定能使读-改-写操作具有原子性。如果你对一个volatile变量执行x++操作,编译器可以将x读入寄存器,增加寄存器的值,然后将寄存器的值存回内存中。 - Adam Rosenfield
1
更强烈的要求:volatile 的可观察行为必须是单独的读和写。 - MSalters
@Adam:read-modify-write...你是在说原子比较交换吗?如果我用两个线程简单地执行x=x+1,而x是原子的,那么我不会得到与非原子相同的行为吗? - excalibur
2
@excalibur:不,我说的是operator++。你不能使用std::atomic<T>x=x+1,因为这些对象不可复制分配,原因正是这样做是非原子的。 - Adam Rosenfield

5

在x86架构中,所有存储操作都具有释放语义,而所有加载操作都具有获取语义,这是真实的。

但这不会且不应该影响你编写C++的方式:为了编写并发、无竞争的代码,你必须使用std::atomic结构或锁。

架构细节的意义在于,在x86上,只要您最多要求获取/释放排序,对原子字大小类型的操作就几乎不会产生额外的代码(顺序一致性会生成mfence指令)。然而,你仍然必须使用C++的原子类型来编写正确且符合规范的程序。原子变量的一个重要特性是它们可以防止编译器重新排序,这对于程序的正确性至关重要。

(在C++11之前,你需要使用编译器提供的扩展,例如GCC的__sync_*函数套件,来让编译器正确执行。如果你真的想使用裸露的变量,你至少需要自己插入编译器屏障。)


"顺序一致性将发出mfence" - 如果它是强排序的,那么意味着屏障已经存在了吗?否则,获取/释放语义将如何工作? - excalibur
@excalibur:acquire/release 只是半透明的围栏。mfence 是双向完全内存栅栏。它非常昂贵,这就是为什么你总是希望尽可能地松弛到 a/r(在 x86 上是免费的)。 - Kerrek SB

2
请注意,release/acquire语义并不一定意味着每个指令后都有mfence。在x86上,如@Adam Rosenfield所引用的手册或在Wikipedia上快速查看中可以看到,它保持enter image description here。尽管如此,在x86上,存储具有release语义,而加载具有acquire语义。
来自Kerrek SB答案的内容:
“架构细节的意思是,在x86上,只要您要求最多获取/释放排序,对于原子字大小类型的操作将生成非常少或没有额外代码。(顺序一致性会发出mfence指令。)”
请注意,顺序一致性是默认值!(例如,请参阅cppreference)。
这意味着...
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string*> ptr;

void producer()
{
    std::string* p  = new std::string("Hello");
    ptr = p;
}

void consumer()
{
    std::string* p2;
    while (!(p2 = ptr))
        ;
    assert(*p2 == "Hello"); // never fails
}

(在x86上)使用(g++ -std=c++11 -S -O3)实际上会在生产者函数中发出一个mfence,以解决上述松弛问题(enter image description here)。

而对于...

#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string*> ptr;

void producer()
{
    std::string* p  = new std::string("Hello");
    ptr.store(p, std::memory_order_release);
}

void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // never fails
}

(g++ -std=c++11 -S -O3在x86上的表现)
...因为x86对存储具有释放语义并对加载具有获取语义,所以不会插入任何mfence。

1

有一个很好的表格列出了不同的重新排序操作,这些操作可能会发生,例如x86几乎不进行任何操作。其他架构(尤其是Alpha)则几乎可以进行任何操作。

对于由标准定义的内存模型,x86等架构本质上是符合规范的。

关于原子变量的问题有稍微不同的答案。对变量的任何修改都涉及竞争条件,因此当多个线程更新相同的变量时,更新可能会丢失。原子变量被定义为具有原子操作的正确类型,这些操作消除了这种竞争条件。因此,它们的目的之一不仅仅是用于排序。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接