无锁共享内存访问中的C-易失性和内存障碍



嗨,我有一个关于C中易失性和内存屏障的使用的一般问题,同时在共享内存中进行内存更改,由多个线程在没有锁的情况下并行访问。据我所知,易失性和记忆屏障有以下一般用途

  1. 记忆障碍

A)确保所有挂起的内存访问(读/写(取决于屏障))都在屏障之前正确完成,然后才执行屏障之后的内存访问。

B) 确保编译器不会跨越障碍重新排序加载/存储指令(取决于障碍)。

基本上,点A的目的是处理无序执行和写缓冲区刷新延迟情况,其中处理器本身最终重新排序由编译器生成的指令或由所述指令进行的存储器访问。B点的目的是,当C代码被翻译成机器代码时,编译器本身不会在汇编中移动这些访问。

  1. 现在适用于volatilevolatile基本上是指松散的方式,这样编译器在优化使用volatile变量编写的代码时就不会进行优化。服务于以下广泛目的

A)在将C代码转换为机器级代码时,内存访问不会缓存在cpu寄存器中,每次发生读入代码时,都会转换为加载指令,通过汇编中的内存执行。

B) 当编译器将C代码转换为机器代码时,具有其他易失性变量的汇编中的存储器访问的相对顺序保持相同,而具有非易失性的变量的汇编的存储器访问可以交错。

我有以下问题

  1. 我的理解是否正确和完整?比如有没有我遗漏的案例或者我说的不正确的话。

  2. 因此,每当我们在多个线程同时访问的共享内存中编写更改内存的代码时,我们都需要确保我们有障碍,这样就不会发生与点1A和1B对应的行为。对应于2.B的行为将由1.B处理,对于2.A,我们需要将指针强制转换为易失性指针以进行访问。基本上,我试图理解我们是否应该总是将指针投射到一个易失性指针,然后进行内存访问,以便我们确信2.a不会发生,或者是否存在只使用屏障就足够的情况?

  1. 我的理解正确且完整吗

是的,看起来是这样的,除了没有提到C11<stdatomic.h>使所有这些几乎都过时了。

如果没有volatile(或者更好的_Atomic),可能会发生更多你没有列出的糟糕/奇怪的事情:LWN的文章谁害怕一个糟糕的优化编译器?详细介绍了诸如发明额外负载(并期望它们读取相同的值)之类的事情。它针对的是Linux内核代码,C11_Atomic不是它们的工作方式。

除了Linux内核之外,新代码应该总是使用<stdatomic.h>,而不是使用volatile和针对RMW和屏障的内联asm来滚动自己的原子。但是确实继续工作,因为我们运行线程的所有现实世界的CPU都有一致的共享内存,所以在asm中进行内存访问就足以实现线程间的可见性,比如memory_order_relaxed。请参阅何时将volatile与多线程一起使用?(基本上从来没有,除了在Linux内核中,或者可能在其他一些已经有了很好的手工实现的代码库中)。

在ISO C11中,两个线程对同一对象进行不同步的读写是数据竞赛的未定义行为,但主流编译器确实定义了行为,只是按照您期望的方式进行编译,因此硬件保证或缺乏硬件保证会发挥作用。


除此之外,是的,除了最后一个问题2之外,看起来是准确的:memory_order_relaxed原子有用例,它就像没有障碍的volatile,例如exit_now标志。

或者是否存在仅使用屏障就足够的情况?

不,除非你运气好,编译器碰巧生成了正确的asm。

或者,除非其他同步意味着此代码仅在没有其他线程读取/写入对象的情况下运行。(C++20有std::atomic_ref<T>来处理这样的情况:代码的某些部分需要对数据进行原子访问,但程序的其他部分没有,你想让它们自动向量化或其他什么。C还没有这样的东西,除了使用带有/不带有GNU C__atomic_load_n()和其他内建的普通变量之外,这就是C++头文件实现std::atomic<T>的方式,也是相同的底层支持C11_Atomic编译到的。可能还有像stdatomic.h中定义的atomic_load_explicit这样的C11函数,但与C++不同,_Atomic是一个真正的关键字,没有在任何标头中定义。)

就标准而言,volatile限定内存访问的语义被明确地描述为实现定义的。他们的特点是这样的,即寻求销售编译器的人将比委员会更好地理解和满足客户的需求。

寻求与为其他实现编写的低级别代码最大限度兼容的实现会将volatile限定的访问视为前后都是对编译器一无所知的函数的调用,这些函数可能会修改此类函数能够修改的任何存储。根据执行环境的配置,这种处理可能足以或不足以解决竞争条件。这样的处理在大多数单核(通常是嵌入式)环境上,或者在配置为与特定程序相关联的所有线程一次只在一个核上运行的环境上是足够的,并且在不首先刷新缓存的情况下不会在核之间迁移。如果有足够的独立任务来保持所有核心繁忙,那么设计用于这种环境的代码可能比使用多处理器同步原语的代码更高效。

不幸的是,即使每个编译器都需要能够处理易失性访问,该访问之前和之后都是对函数的调用,而该函数的行为在实现中一无所知,但没有标准的强制性方式来指示应该以符合这种语义的方式来处理所有访问对象。最好的方法可能是定义一个编译器供应商特定的宏,该宏可以在volatile访问之前和之后使用,这些访问可能会触发影响抽象机器状态的操作。在一些编译器上,这些宏不需要做任何事情,但在其他编译器上,它们可以使用编译器特定的语法来强制执行"宏";内存崩溃";。

最新更新