在忙等待循环中是否需要内存屏障或原子操作?

22
考虑以下关于spin_lock()的实现方式,最初来自this answer
void spin_lock(volatile bool* lock)  {  
    for (;;) {
        // inserts an acquire memory barrier and a compiler barrier
        if (!__atomic_test_and_set(lock, __ATOMIC_ACQUIRE))
            return;

        while (*lock)  // no barriers; is it OK?
            cpu_relax();
    }
}

我已经了解的内容:

  • volatile 防止编译器在每次 while 循环迭代中优化掉 *lock 的重新读取;
  • volatile 不会插入内存或编译器屏障
  • 这样的实现实际上在 GCC 中适用于 x86(例如在 Linux 内核中)和一些其他架构;
  • 对于通用架构,spin_lock() 实现中至少需要一个内存和编译器屏障;此示例在__atomic_test_and_set()中插入它们。

问题:

  1. 在这里使用volatile是否足够,或者是否有任何体系结构或编译器需要内存或编译器屏障或原子操作在while循环中?

    1.1 根据C++标准?

    1.2 实际上,对于已知的体系结构和编译器,特别是对于GCC和它支持的平台而言?

  2. 这个实现在GCC和Linux支持的所有体系结构上是否安全?(至少在某些体系结构上效率低下,对吧?)
  3. while循环根据C++11及其内存模型是否安全?

有几个相关的问题,但我无法从中构建出明确且明确的答案:


3
第一个假设是错误的 - 在多 CPU 系统中, volatile 读取不能保证同步缓存。它应该用于设备接口,而不是用于线程。请参见 Volatile and multithreading: is the following thread safe? - Bo Persson
2
@BoPersson 我相信他的意思只是强制编译器不要将内存加载操作提升出循环,以便它至少重新读取本地处理器的缓存。问题在于是否实际存在这样一种架构,即缓存一致性实际上会成为一个真正的问题而没有内存屏障,这意味着 OP 理解 volatile 不会创建这样的屏障。 - davmac
@davmac,是的!这正是我所询问的。 - gavv
1
作为对你1.2问题的部分回答,这里是LLVM如何实现volatile和atomic内存顺序的链接:http://llvm.org/docs/Atomics.html。所以在那里不安全。 - Davislor
1
@g-v 相关的gcc手册章节:https://gcc.gnu.org/onlinedocs/gcc/Volatiles.html 请注意,与volatile指针相比,g++对于volatile引用并不提供相同的保证。 - Davislor
显示剩余3条评论
3个回答

13

很重要的一点是:在C ++中,volatile与并发无关! volatile的目的是告诉编译器不要对受影响的对象进行优化访问。它没有告诉CPU任何东西,主要是因为CPU已经知道内存是否是volatilevolatile的目的实际上是处理映射到内存的I/O。

C ++标准在1.10节 [intro.multithread] 中非常明确地指出,多个线程访问一个对象时,如果一个线程修改该对象并且另一个线程访问(修改或读取)该对象,则未同步访问将导致未定义的行为。避免未定义行为的同步原语是库组件,例如原子类或互斥量。此条款仅在信号(即作为volatile sigatomic_t )和前进(即线程最终会执行具有可观察效果的操作,例如访问volatile对象或进行I / O)的上下文中提到volatile。它没有与同步结合使用的volatile的任何提及。

因此,对跨线程共享的变量进行未同步的访问会导致未定义的行为。无论它是否声明为volatile,在这种未定义的行为中都无关紧要。


谢谢。所以问题3.的答案绝对是否定的,根据C++标准,它是不安全的。 - gavv
1
话虽如此,GCC手册指出对volatile*进行解引用实际上会重新从内存中加载值,而不会被优化掉,因此在GCC上,OP关于其含义的理解是正确的。 - Davislor
@Lorehead:不,那个结论是错误的:编译器会导致数据被加载,但这是否影响多核系统中CPU的操作完全无关。一些CPU实际上会重新读取数据(例如当前的x86系统),而另一些则不会。 - Dietmar Kühl
1
我认为我们完全没有分歧,Dietmar。原帖意识到volatile不能保证缓存一致性。 - Davislor

5

来自内存屏障维基百科页面:

... 其他架构,例如Itanium,提供了单独的“获取”和“释放”内存屏障,分别从读者(sink)或写入者(source)的角度解决读后写操作的可见性。

对我来说,这意味着Itanium需要适当的屏障才能使读/写对其他处理器可见,但实际上可能仅用于排序目的。我认为,问题实际上归结为:

是否存在一种体系结构,其中处理器如果没有被指示更新本地缓存,则可能永远不会更新其本地缓存?我不知道答案,但如果您以这种形式提出问题,则可能有人知道答案。在这样的架构中,您的代码可能进入一个无限循环,其中对*lock的读取始终看到相同的值。

在通用的C++合法性方面,你示例中的原子测试和设置不够,因为它只实现了一个单一的栅栏,这将允许你在进入while循环时看到*lock的初始状态,但不能看到它何时发生变化(这会导致未定义的行为,因为你正在读取在另一个线程中更改的变量而没有同步)- 因此你的问题(1.1/3)的答案是
另一方面,在实践中,问题(1.2/2)的答案是肯定的(考虑到GCC's volatile semantics),只要架构保证了缓存一致性而无需显式内存栅栏,这对于x86和可能对于许多架构都是正确的,但我不能确定它是否适用于GCC支持的所有架构。然而,如果可以在不这样做的情况下获得相同的结果,那么故意依赖技术上根据语言规范来说是未定义行为的代码的特定行为通常是不明智的。

顺便提一下,既然存在memory_order_relaxed,在这种情况下似乎没有理由不使用它,而是尝试通过使用非原子读取进行手动优化,即将您示例中的while循环更改为:

    while (atomic_load_explicit(lock, memory_order_relaxed)) {
        cpu_relax();
    }

例如,在x86_64上,原子加载变为普通的mov指令,优化后的汇编输出与您的原始示例基本相同。

对我来说,这意味着Itanium需要适当的屏障才能使读/写操作对其他处理器可见。真的吗?在Itanium(或其他架构)上,更改可能没有屏障是不可见的吗?我认为屏障只限制此类更改的顺序,但不影响可见性。 - gavv
2
因为volatile通常被认为意味着地址可能指向由硬件控制的值(即内存映射IO地址)的位置,所以我认为你可以相当确定GCC和其他编译器会在这里做你想要的事情。但请参见修改后的答案 - 当您可以使用带有memory_order_relaxed的原子加载时,没有真正需要依赖此功能。 - davmac
1
@g-v 我相当确定任何原子操作都必须在没有缓存一致性的情况下安全。内存排序约束是针对非原子读/写周围的。这里有一个长讨论,虽然它是关于C++的,但我认为同样的原则适用于C:https://dev59.com/questions/210a5IYBdhLWcg3wHlil#30692887 - davmac
1
@Lorehead 谢谢,我已经将这个链接添加到答案中了。 - davmac
显示剩余8条评论

1
  1. volatile是否足够,或者在哪些体系结构或编译器中需要内存屏障、编译器屏障或原子操作在while循环中?

volatile代码是否会看到更改? 是的,但不一定像内存屏障那样快。 在某个时刻,某种形式的同步将发生,并从变量中读取新状态,但不能保证在代码的其他位置发生了多少。

1.1 根据C++标准?

来自cppreference : memory_order

它是内存模型和内存顺序定义的通用硬件,代码需要在其上运行。为了在线程执行之间传递消息,需要出现线程间发生关系。这要求...

  • A与B同步
  • A在B之前具有std::atomic操作
  • A通过X间接地与B同步。
  • A在X之前排序,X在线程之间发生在B之前
  • A在X之前线程间发生,X在线程间发生在B之前。

由于您没有执行这些情况中的任何一种,因此在某些当前硬件上,您的程序可能会失败。

实际上,时间片的结束会导致内存变得一致,非自旋锁线程上的任何形式的屏障都将确保缓存被刷新。

不确定易失性读取获取“当前值”的原因。

1.2 实际上,对于已知的架构和编译器,特别是对于GCC和它支持的平台?

由于该代码与通用CPU不一致,自C++11以来,该代码可能无法在试图遵循标准的C++版本中执行。

来自cppreference:const volatile限定符 易失访问阻止优化从其之前移动工作或从其之后移动工作。

“这使易失对象适用于与信号处理程序进行通信,但不适用于与另一个执行线程进行通信”

因此,实现必须确保从内存位置读取指令,而不是任何本地副本。但它不必确保易失性写入通过缓存刷新以在所有CPU上产生一致的视图。在这个意义上,对易失性变量进行写入后多长时间才会对另一个线程可见没有时间界限。

另请参见 kernel.org why volatile is nearly always wrong in kernel

这种实现在GCC和Linux支持的所有体系结构上都安全吗?(至少在某些体系结构上效率低下,对吧?)

不能保证易失性消息从设置它的线程中传出,因此不太安全。在Linux上可能是安全的。

根据C++11及其内存模型,while循环是否安全?

不安全-因为它不创建任何跨线程通信原语。


谢谢!非常清晰的回答。您能否澄清一下这个问题?“由于代码与通用CPU不一致,从C++11开始,这段代码可能无法在试图遵循标准的C++版本中执行。”您有没有具体的例子可以说明会发生什么? - gavv

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