内存屏障 -- Part 2

下面简要总结不同语言编译器,解决内存可见性以及重排序的问题。

1. Java volatile

1.1 编译器层面

volatile关键字仅作为标记使用,取消编译层面的缓存以及重排序。volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。

volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

  • 保持可见性

  • 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)

1.2 硬件层面

  • 如果硬件本身已经保证了内存可见性(单核处理器,一致性足够的内存模型)。那么volatile就是一个空标志。不会插入相关语义的内存屏障。

  • 如果硬件架构本身不进行处理器重排序,有更强的重排序语义(能够分析多核间的数据依赖),或者在单核处理器上重排序。volatile就是一个空标记,不会插入相关语义的内训屏障。

  • 如果不保证,以x86架构为例,JVM对于volatile变量处理如下:

    • volatile 变量v之后,插入一个sfence,写屏障之前的所有写操作(包括v)不会重排序到sfence之后,禁用跨sfence的重排序优化;且sfence之前所有的写操作会协会缓存,并标记其他CPU cacheline 中缓存失效。

    • 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。

    • 在另外一些平台上,JVM使用mfence代替sfence与lfence,实现更强的语义。

1.3 其他封装

除volatile外,常见的JVM实现还基于内存屏障作了一些其他封装。借助于内存屏障,这些封装也得到了内存屏障在可见性与重排序上的语义。

1. final关键字

如果一个实例的字段被声明为final, 则JVM 会在初始化final后插入一个sfence 。上述良好性质被称为“初始化安全性”。它保证,对于被正确构造的对象,所有线程都能看到构造函数给对象的各个final字段设置的正确值,而不管采用何种方式来发布对象。初始化安全性为解决部分初始化问题带来了新的思路:如果待发布对象的所有域都是final修饰的,那么可以防止对对象的初始引用被重排序到构造过程完成之前

2. Rust

Rust 抄袭了C11关于原子操作的内存模型。这么做是为了借鉴C的研究成果,但是该模型非常复杂而且有已知的缺陷。C11 的内存模型允许我们接触到程序的因果关系,希望以此满足多个方面的要求。一般来说,就是要确定程序的各个部分以及运行它们的多个线程之前的时间先后关系。在严格的先后关系没有确定的时候,硬件和编译器有足够的空间做一些激进的优化。而关系确定之后,它们的优化就必须很小心了。我们通过 “数据访问” 和 “原子访问” 来控制这种关系。

只依靠数据访问不可能写出正确的同步代码的

原子访问可以告诉硬件以及编译器,程序是多线程的、每一个原子访问都需要关联一种“访问顺序”,以确定其和其他访问之间的关系。也就是说,通过指定原子访问顺序,禁止编译器以及硬件的某些行为。 Rust暴露的排序方式:

  • 顺序一致性(SeqCst)

  • 释放(Release)

  • 获取(Acquire)

  • Relaxed

2.1 顺序一致性

所有排序方式中最强大的,包含了所有其他排序方式的约束条件。直观上看,顺序一致性不能被重排,在同一个线程中SeqCst 之前的访问永远在前,之后的访问永远在后。只使用顺序一致性的原子操作和数据访问,就可以构建一个无数据竞争的程序,好处在于在所有的线程上具有完全一致执行次序。

但是需要更大的开销,在强顺序平台上需要借助内存屏障。

2.1 获取-释放

Acqiure, Release 通常成对出现,适用于获取以及释放锁,确保临界区不会重叠。

  • Acquire 保在它之后的访问不会出现在它之前,但是他之前的操作可能重排到它之后

  • Release 保证他之前的操作永远在它之前,但是之后的操作可能重排到它之前

可以直观理解为,Acquire获取之后保证后续正确性,Release释放之前保证之前正确性。在强顺序平台上,大多数的访问都有释放和获取的语义,释放和获取通常是无开销的。不过在弱顺序平台上不是这样。

2.3 Relaxed

Relaxed 访问是最弱的。它们可以被随意重排,也没有先后关系。但是 Relaxed 操作依然是原子的。也就是说,它并不算是数据访问,所有对它的读 - 修改 - 写操作都是原子的。Relaxed 操作适用于那些你希望发生但又并不特别在意的事情。比如,多线程可以使用 Relaxed 的 fetch_add 来增加计数器,如果你不使用计数器的值去同步其他的访问,这个操作就是安全的。

在强顺序平台上使用 Relaxed 没什么好处,因为它们通常都有释放 - 获取语义。不过,在弱顺序平台上,Relaxed 可以获取更小的开销。

对于主动使用内存屏障,可以使用fence(Ordering::SeqCst) 保证强一致性,或者直接使用相应指令llvm_asm!("mfence" ::: "memory" : "volatile");

Last updated