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

Posted

技术标签:

【中文标题】忙等待循环中是不是需要内存屏障或原子操作?【英文标题】:Is memory barrier or atomic operation required in a busy-wait loop?忙等待循环中是否需要内存屏障或原子操作? 【发布时间】:2015-12-17 02:48:30 【问题描述】:

考虑以下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 重新读取; volatileinserts neither memory nor compiler barriers; 这样的实现实际上在 GCC 中适用于 x86(例如在 Linux 内核中)和一些其他架构; 在通用架构的spin_lock() 实现中至少有一个内存和编译器屏障is required;此示例将它们插入到__atomic_test_and_set()

问题:

    这里的volatile 是否足够,或者是否有任何架构或编译器在while 循环中需要内存或编译器屏障或原子操作?

    1.1 按照C++标准?

    1.2 在实践中,对于已知的架构和编译器,特别是对于 GCC 及其支持的平台?

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

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

Q: Memory barrier in a single thread

原则上:是的,如果程序执行从一个内核移动到下一个内核,它可能不会看到前一个内核上发生的所有写入。

Q: memory barrier and cache flush

在几乎所有现代架构上,缓存(如 L1 和 L2 缓存)都由硬件确保一致。无需刷新任何缓存即可使内存对其他 CPU 可见。

Q: Is my spin lock implementation correct and optimal?

Q: Do spin locks always require a memory barrier? Is spinning on a memory barrier expensive?

Q: Do you expect that future CPU generations are not cache coherent?

【问题讨论】:

第一个假设不正确 - 在多 CPU 系统上,volatile 读取不能保证同步缓存。它应该用于设备接口,而不是线程。见Volatile and multithreading: is the following thread safe? @BoPersson 我相信他只是意味着它强制编译器不要将内存加载操作提升到循环之外,以便它至少会重新读取本地处理器的缓存。问题是是否真的存在这样一种架构,在没有内存屏障的情况下,缓存一致性实际上是一个真正的问题,这意味着 OP 确实理解 volatile 不会创建这样的屏障。 @davmac,是的!这正是我要问的。 作为对 1.2 的部分回答,以下是 LLVM 如何实现易失性和原子内存排序:llvm.org/docs/Atomics.html。所以那里不安全。 @g-v gcc 手册的相关部分:gcc.gnu.org/onlinedocs/gcc/Volatiles.html 请注意,g++not 对 volatile 引用做出与 volatile 指针相同的保证。 【参考方案1】:

来自Wikipedia page on memory barriers:

... 其他架构,例如 Itanium,提供单独的“获取”和“释放”内存屏障,从读取器(接收器)或写入器(来源)。

对我来说,这意味着 Itanium 需要一个合适的栅栏来使读/写对其他处理器可见,但这实际上可能只是为了排序。我认为这个问题真的可以归结为:

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

就一般 C++ 合法性而言,在您的示例中设置一个原子测试和设置是不够的,因为它只实现了一个栅栏,允许您在进入 while 循环时看到 *lock 的初始状态但看不到它何时发生变化(这会导致未定义的行为,因为您正在读取在另一个线程中更改而没有同步的变量) - 所以您的问题(1.1/3)的答案是 no .

另一方面,在实践中,(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(或其他一些架构)上,如果没有障碍,更改可能是不可见的,这真的是真的吗?我虽然障碍仅限制此类更改的顺序,但不会影响可见性。 “这取决于 cpu_relax 的作用”——让我们假设它什么也不做或不做任何与屏障/缓存刷新/等相关的事情。 “这是来自 Linux 内核的顺便说一句吗?” ——不,这只是一个例子。内核版本在这里:lxr.free-electrons.com/source/arch/x86/include/asm/… 因为volatile 通常被认为是指地址可能是指一个位置,其值由硬件控制(即内存映射的 IO 地址),所以我认为您可以相当确定GCC 和其他编译器会在这里做你想做的事。但是请参阅修改后的答案 - 当您可以使用 memory_order_relaxed 的原子负载时,实际上没有必要依赖它。 @g-v 我相当肯定任何原子操作都必须是安全的,无论如何都没有缓存一致性。内存排序约束用于围绕非原子的读/写。在这里进行了长时间的讨论,虽然它是针对 C++ 的,但我认为同样的原则也适用于 C:***.com/questions/30691135/… @Lorehead 谢谢,我已将此链接添加到答案中。【参考方案2】:

这很重要:在 C++ 中,volatile 与并发完全无关volatile 的目的是告诉编译器它不应优化对受影响对象的访问。它确实告诉 CPU 任何事情,主要是因为 CPU 已经知道内存是否为 volatilevolatile 的目的是有效地处理内存映射 I/O。

C++ 标准在第 1.10 节 [intro.multithread] 中非常清楚,对在一个线程中修改并在另一个线程中访问(修改或读取)的对象的非同步访问是未定义的行为。避免未定义行为的同步原语是库组件,如原子类或互斥锁。该子句仅在信号上下文(即volatile sigatomic_t)和向前进展的上下文中提及volatile(即,线程最终将执行具有可观察效果的操作,例如访问volatile 对象或做 I/O)。没有提到 volatile 与同步一起使用。

因此,对跨线程共享的变量的不同步评估会导致未定义的行为。是否声明 volatile 与这种未定义的行为无关。

【讨论】:

谢谢。所以3.这个问题的答案肯定是no,根据C++标准是不安全的。 也就是说,GCC 手册说取消引用 volatile* 实际上会从内存中重新加载值,而不是被优化掉,所以在 GCC 上,OP 的含义是正确的。跨度> @Lorehead:不,这个结论是错误的:编译器会导致数据被加载,但这是否会影响 CPU 在多核环境中的工作系统完全不相关。一些 CPU 实际上会重新读取数据(例如,当前的 x86 系统),而另一些则不会。 我认为我们完全没有意见分歧,Dietmar。 OP 意识到volatile 不保证缓存一致性。【参考方案3】:
    这里的 volatile 是否足够,或者是否有任何架构或编译器在 while 循环中需要内存或编译器屏障或原子操作?

易失性代码会看到变化吗?是的,但不一定像存在记忆障碍那样快。在某些时候,会发生某种形式的同步,并且会从变量中读取新状态,但无法保证代码中其他地方发生了多少。

1.1 根据C++标准?

来自cppreference : memory_order

内存模型和内存顺序定义了代码需要处理的通用硬件。对于在执行线程之间传递的消息,需要发生线程间发生之前的关系。这需要...

A 与 B 同步 A 在 B 之前有一个 std::atomic 操作 A 间接与 B 同步(通过 X)。 A 在 X 之前排序,线程间发生在 B 之前 线程间发生在 X 之前,而 X 线程间发生在 B 之前。

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

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

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

1.2 在实践中,对于已知的架构和编译器,特别是对于 GCC 及其支持的平台?

由于代码与通用 CPU 不一致,从 C++11 开始,该代码很可能无法在尝试遵守标准的 C++ 版本中执行。

来自cppreference : const volatile qualifiers 易失性访问阻止优化将工作从之前移动到之后,从之后移动到之前。

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

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

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

此实现在 GCC 和 Linux 支持的所有架构上是否安全? (至少在某些架构上效率很低,对吧?)

无法保证 volatile 消息会脱离设置它的线程。所以不是很安全。在 linux 上它可能是安全的。

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

否 - 因为它不创建任何线程间消息传递原语。

【讨论】:

谢谢!一个非常明确的答案。你能否澄清这一点? As the code is not consistent with the generalized CPU, from C++11 then it is likely this code will fail to perform with versions of C++ which try to adhere to the standard.你有一个例子,到底会发生什么?

以上是关于忙等待循环中是不是需要内存屏障或原子操作?的主要内容,如果未能解决你的问题,请参考以下文章

互斥锁,自旋锁,原子操作原理和实现

原子操作的硬件与java实现

volatile与java内存模型

Linux内核常见的几种同步手段

CUDA 内核中映射固定主机内存上的原子操作:做还是不做?

深入理解Atomic原子操作和volatile非原子性