std::atomic应该加上volatile关键字吗?

33

我正在运行一个线程,直到设置了一个标志为止。

std::atomic<bool> stop(false);

void f() {
  while(!stop.load(std::memory_order_{relaxed,acquire})) {
    do_the_job();
  }
}

我想知道编译器是否可以像这样展开循环(但我不希望它发生)。

void f() {
  while(!stop.load(std::memory_order_{relaxed,acquire})) {
    do_the_job();
    do_the_job();
    do_the_job();
    do_the_job();
    ... // unroll as many as the compiler wants
  }
}
据说,易变性和原子性是正交的,但我有点困惑。编译器是否可以缓存原子变量的值并展开循环?如果编译器可以展开循环,那么我认为我必须将volatile放在标志上,并且我想确定一下。
我应该放置volatile吗?
对于我的曖昧表示抱歉。我(猜测)理解了重新排序和memory_order_*的含义,并确信我完全理解了volatile的含义。
我认为while()循环可以转换为无限个这样的if语句。
void f() {
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  do_the_job();
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  do_the_job();
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  do_the_job();
  ...
}

鉴于给定的内存顺序并没有阻止先前顺序操作被移动到原子加载之后,如果没有使用volatile关键字,我认为可以重新排列。

void f() {
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  if(stop.load(std::memory_order_{relaxed,acquire})) return;
  ...
  do_the_job();
  do_the_job();
  do_the_job();
  ...
}
如果原子操作不意味着易失性,那么我认为,在最坏的情况下,代码甚至可以像这样转换。
void f() {
  if(stop.load(std::memory_order_{relaxed,acquire})) return;

  while(true) {
    do_the_job();
  }
}

永远不会有这样疯狂的实现,但我想这仍然是可能的情况。我认为防止这种情况的唯一方法是将 volatile 放到原子变量中,我正在询问此事。

我提出了很多猜测,请告诉我其中是否有任何错误。


我不这么认为。最近看了很多关于std::atomic的内容,但没有人说它应该是这样的。我猜,在类里面有一个volatile变量。 - Nick
2
可能是并发:C++11内存模型中的原子和易失性的重复。 - Johann Gerell
1
不,它不应该是易失性的。 - Sven Nilsson
1
你是在询问什么是保证还是关于某个特定平台上会发生什么吗?如果是前者,为什么要提到volatile,因为它没有保证的多线程语义?如果是后者,为什么不提及你的平台呢? - David Schwartz
4
@Nick, std::atomic 不需要在内部使用 volatile,因为 volatile 对于线程间正确同步既非必要也不充分。使用 volatile 将没有任何帮助。std::atomic 使用原子操作,而不是 volatile,因为它需要是原子的,不是易变的。它们是正交概念。http://isvolatileusefulwiththreads.com - Jonathan Wakely
显示剩余6条评论
2个回答

13

编译器能够自由缓存原子变量的值并展开循环吗?

编译器不能缓存原子变量的值。

然而,由于你正在使用std::memory_order_relaxed,这意味着编译器可以重新排序对/从此原子变量的加载和存储与其他加载和存储有关的操作。

还要注意,调用在该翻译单元中未提供定义的函数是编译器内存障碍。这意味着调用不能在周围的加载和存储中重新排序,并且所有非本地变量都必须从内存中重新加载,就好像它们都被标记为易失性。(未在其他地方传递地址的局部变量将不会重新加载)。

您希望避免的代码“转换”不是有效的转换,因为那将违反C++内存模型:在第一种情况下,您有一个对原子变量的加载,后跟对do_the_job的调用,在第二种情况下,您有多个调用。转换后的代码的观察行为可能不同。


来自std::memory_order的注意事项:

与易失性的关系

在执行线程中,对所有易失对象的访问(读取和写入)都保证不会相互重新排序,但是此顺序不能保证由另一个线程观察到,因为易失存储器的访问不建立线程间同步。

此外,易失访问不是原子的(并发读写是数据竞争),并且不排序内存(非易失存储器访问可以在易失访问周围自由重新排序)。

这段话中的“非易失存储器访问可以在易失访问周围自由重新排序”也适用于松散原子操作,因为松散加载和存储可以重新排序与其他加载和存储有关的操作。

换句话说,给您的原子变量添加volatile修饰符不会改变代码的行为。


无论如何,C++11原子变量不需要标记为volatile关键字。


这里是g++-5.2如何尊重原子变量的示例。以下函数:

__attribute__((noinline)) int f(std::atomic<int>& a) {
    return a.load(std::memory_order_relaxed);
}

__attribute__((noinline)) int g(std::atomic<int>& a) {
    static_cast<void>(a.load(std::memory_order_relaxed));
    static_cast<void>(a.load(std::memory_order_relaxed));
    static_cast<void>(a.load(std::memory_order_relaxed));
    return a.load(std::memory_order_relaxed);
}

__attribute__((noinline)) int h(std::atomic<int>& a) {
    while(a.load(std::memory_order_relaxed))
        ;
    return 0;
}

使用 g++ -o- -Wall -Wextra -S -march=native -O3 -pthread -std=gnu++11 test.cc | c++filt > test.S 编译后,会生成以下汇编代码:

f(std::atomic<int>&):
    movl    (%rdi), %eax
    ret

g(std::atomic<int>&):
    movl    (%rdi), %eax
    movl    (%rdi), %eax
    movl    (%rdi), %eax
    movl    (%rdi), %eax
    ret

h(std::atomic<int>&):
.L4:
    movl    (%rdi), %eax
    testl   %eax, %eax
    jne .L4
    ret

2
“which guarantees visibility of stores to atomic variables” 的意思是确保存储对原子变量的可见性。在任何有限的时间内,不能保证存储会立即对其他加载操作可见;我们所能做的最好的办法是“实现应该在合理的时间内使原子存储对原子加载可见”,这是规范性的鼓励(“应该”),而不是要求。 - T.C.
1
@MaximEgorushkin:编译器可以消除对同一原子变量的连续加载(如果它们之间没有其他内存屏障),因为你无法区分省略的冗余加载和两个加载,这两个加载在彼此之后如此之快,以至于没有其他线程有时间在其间更改变量。 - MikeMB
1
@MaximEgorushkin:我指的是“被允许”,而不是“能够”或“将会”。实际上,我认为没有任何编译器会这样做,因为这几乎从来不是程序员真正想要的。此外,我认为大多数原子操作的实现都涉及通过volatile指针进行加载或写入。 - MikeMB
1
@MaximEgorushkin:就像我说的那样:因为你无法区分它们(或者至少我不知道如何区分)。 “好像”规则是大多数优化的基础。 - MikeMB
2
@MikeMB是正确的:根据ISO C++11标准,编译器允许消除来自同一非volatile原子变量的连续加载。最大化:当前的编译器不会这样做,或者合并连续的写入,这是一个实现质量问题,而不是因为标准禁止它。(例如,进度条更新存储可以沉没出循环...)为什么编译器不合并冗余的std::atomic写入?C++委员会正在开发新功能,以便程序员可以控制编译器何时允许/不允许优化原子操作。 - Peter Cordes
显示剩余7条评论

3
如果do_the_job()没有改变stop,那么编译器是否能够展开循环都无所谓。 std::memory_order_relaxed只确保每个操作是原子的,但它不能防止重新排序访问。这意味着如果另一个线程将stop设置为true,则循环可能会继续执行几次,因为访问可能会被重新排序。因此,这与展开循环的情况相同:do_the_job()在另一个线程将stop设置为true后可能会执行几次。
因此,不要使用volatile,而要使用std::memory_order_acquirestd::memory_order_release

我明白你的意思。由于加载操作不能保证获取到最后一个值,限制函数调用次数是没有意义的。那么我添加的情况呢? - Inbae Jeong
我觉得如果不知道do_the_job()函数的具体内容以及设置stop的线程的作用,很难对此进行推理。如果它们访问共同的数据,那么它们之间肯定需要更多的同步。你能否提供一个更详细的示例呢? - alain
std::memory_order_acquire 如何防止展开? - curiousguy

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