一个从腾讯毕业的孩子,将自己的文章搬运出来
并发编程一直围绕着3个要素展开,分别是原子性、有序性、可见性。
对于使用者来说,可以通过学习使用一些并发工具类来保证三要素。
例如Java:
synchronize保证了原子性、可见性。(如果撇开DCL问题的话,所有变量都在同步代码块内处理的话,甚至也可以说保证了不同同步代码块之间的有序性) ReentrantLock等保证原子性、可见性、有序性 volatile保证了可见性、有序性
又例如Golang:
sync.Mutex sync.RWMutex 保证了原子性、可见性 channel技术可以用于保证可见性、有序性
那么本篇文章,主要围绕的是可见性和有序性。语言给定了规则,我们只要遵守相应的规则,使用相应的工具类开发,就能保证并发安全。那么它的底层究竟是怎么工作的,今天的文章希望能给大家带来帮助,同时因为偏底层,如果有错误的地方,欢迎指正~
在进入主题之前,首先我们来看两段经典的伪代码
// cpu0和cpu1方法分别模拟2个CPU正在并行
public class Demo {
int value = 0;
boolean done = false;
void cpu0() {
value = 10;
done = true;
}
void cpu1() {
while (!done) {
}
System.out.println(value == 10);
}
}
package main
var value = 0
var done = false
func setup() {
value = 10
done = true
}
func main() {
go setup()
for !done {
}
println(value == 10)
}
看完上述的代码,它的输出结果是什么吗?以及为什么?
答案是不确定,要分很多种情况讨论。
第一段Java代码中,造成输出结果不确定的原因有
第二段go代码中,造成输出结构不确定的原因有
那么有什么方式能够保证最终输出结果为true呢?它的底层原理是什么?
那么接下来,我们带着这些疑问,一起走进今天的主题来一探究竟。
只有一个CPU,读写都直接操作主存。
优点: 设计简单、实现简单 不存在数据一致性问题 缺点
缺点: cpu和内存的IO性能差了100倍(cpu是1ns级,而内存是100ns级),频繁的与主存交互数据会影响cpu的性能
为了演示方便,多级高速缓存统一抽象为一级Cache
为了解决单核时代,cpu和内存间读写速度差距过大的问题,在两者之间引入了多级高速缓存
工作方式
cpu从主存中读取数据时,会将数据写入一份数据副本到高速缓存中(在高速缓存中以缓存行的形式保存,一个缓存行是64字节,读取一个数据如果小于64直接,则会把数据附近连续的内存数据一起保存下来以填充满一个缓存行。因此这里衍生出一个知识点,伪共享问题,本篇内容不对伪共享展开讨论)
之后cpu如果重复读写这个数据,就不需要再访问主存,而是直接访问高速缓存中对应的缓存行上的数据。这样就能频繁避免读写内存所带来的开销(CPU和高速缓存间的速度差距比CPU和主存之间小的多得多)
优点:提升了cpu读写数据的速度
缺点:单核CPU瓶颈明显。一台机器的性能取决于cpu在一个时钟周期内可执行的单元数量。
cpu、高速缓存、memory的速度差异
为了解决上一个时代,单核cpu的性能瓶颈。超频并不是一个很好的解决方案,因为超频不仅会带来硬件寿命的急剧下降,发热问题也会导致cpu性能下降。并且一味的通过超频来提升cpu性能,它的研发成本和收益并不可观。因此,增加核心数是当下更好的方式。
由于增加了核心数,每个核心又拥有它自己的高速缓存。因此对于同一份主存数据,出现了数据不一致性的问题。
为了解决数据一致性问题,第一阶段采用的是总线锁的问题。总线是cpu连接内存的桥梁,多个cpu和内存之间的交互可以被总线进行管理。因此可以通过在总线上加锁的方式,来控制同一时间内,只有一个CPU能访问内存数据。
#Lock 信号会把总线上的并行化操作变成了串行,使得某个处理器能够独占内存。因此这是一个锁粒度和开销都很大的操作
优点:多核CPU并行工作提升了计算机的处理速度
缺点:
由于通过总线锁的方式来保证一致性所带来的性能开销太大,因为MESI协议的目的就是以一种更优的方式来管理数据一致性,同时保证CPU的高性能。它的思想是通过降低锁的粒度以及减少使用总线锁的频率来提高并行度从而达到性能优化。
它将高速缓存中每个缓存行(Cache Line)赋予了一个状态属性,分别是
当某个数据,只有一个CPU需要使用这个数据时。并且它从主存读到缓存行后,没有进行任何修改操作。那么此时,这份数据的状态就是Exclusive
当某个数据,只有一个CPU需要使用这个数据时。那么这个CPU对当前数据的读写,就不需要马上同步到主存中(因为没有其他CPU需要,不存在数据一致性问题)。cpu直接与cache进行交互,不需要和主存进行交互,从而提升了读写速度
当某个数据存在于多个CPU的缓存行时。数据没有被任意一个CPU修改,因此每一个CPU缓存中所维护的数据副本,都与主存完全一致,数据是有效的
当某个数据存在于多个CPU的缓存行时。其他CPU对数据进行了修改,从而使得当前CPU维护的数据副本失效。位于Invalid状态的数据,CPU在进行读操作时,需要从主存中读取最新的有效数据
而引起缓存行状态的变化,由以下4类事件触发:
本地处理器进行数据写入
本地处理器进行数据读取
其他处理器对数据进行写入
其他处理器对数据进行读取
而CPU是如何感知到其他CPU对共享内存数据进行读写呢?
它是通过总线嗅探机制
总线嗅探机制:CPU对一个缓存行的读写,最终会被其他CPU知道
当内存中某个数据第一次被CPU使用时,CPU将数据从内存中拷贝一份副本到高速缓存中,状态为 E
本地CPU直接从本地缓存中读取数据,状态不发生改变
由于没有其他CPU共享数据,为了减少和内存的交互。cpu直接跟高速缓存进行写入操作。
由于此时,本地缓存的数据和主存的数据不一致,因此状态会变更为 M
M 表示本地缓存行数据是真实有效的,还没同步至主存中
当发生远程读时,说明此时这个数据已经不是当前CPU独占了,存在多CPU共享内存数据的情况。在这种情况下,会由E转变为S。由于远程没有对数据做修改,因此每个CPU维护的数据副本跟主存一致。
当发生远程写时,说明此时数据已经不是当前CPU独占,存在多CPU共享内存数据的情况。
在这种情况下,由于远程将数据做了修改操作,因此本地维护的数据副本数据已经失效,如果本地CPU要在此使用数据,需要从主存中同步最新的数据。因此状态由E变为I,告知CPU缓存行这条数据已经失效了,如果要再次使用,请到主存中拉取。
当内存中的某个数据仅存在于当前CPU缓存中时,并且CPU对数据进行了修改导致缓存与主存数据不一致,此时缓存行的状态为M
CPU直接从缓存中把数据读取使用,状态不需要发生变化
CPU直接修改缓存中的数据,状态不需要发生变化
由于数据此时不再独占,并且当前CPU维护的数据是真实有效的,因此需要先将数据同步到主存中。然后修改状态为 S
由于数据此时不再独占,并且其他CPU要对数据进行写入操作。首先当前CPU会把自己缓存中的数据同步到主存中,然后将自己状态变更为 I (等待下一次使用数据时,强制从主存中获取,保证最新)
多个CPU同时对同一份内存数据进行缓存
CPU首先会把数据写入自己的缓存,然后通知其他CPU将对应的缓存行变更为 I 。
在这之后,由于缓存数据和主存数据不一致,因此变更为M
其他CPU对数据进行修改,会通知当前CPU将缓存行变更为 I 状态。以便下一次使用数据时,强制从主存中获取最新数据
CPU从未使用过内存中某个数据 或 由于其他CPU的修改导致当前CPU失效。都会处于这个状态
分情况讨论
分情况讨论
与我无关
与我无关
相比原来多CPU通过总线锁的方式保证数据一致性,MESI提供更细粒度的控制。能够有效的减少使用总线锁的频率,同时也减少cpu和内存直接交互的频率。
但是此时的MESI还是不够高效,作为一个超底层的技术,它应该关注于优化极致的性能。因此我们来分析一下,此时的MESI还存在哪些性能问题?
还记得上节中提到,当发生本地写时,需要通知其他相关的CPU缓存行状态变为失效。而在这个过程中,完整的描述是这样的
本地写:本地CPU发起本地写事件,并等待其他相关的所有CPU进行失效ACK响应后,自己才会继续工作。因为只有这样,才能保证自己写入的数据,能被其他CPU感知到。
远程写:CPU通过嗅探机制,收到失效请求。处理器需要将对应的缓存行置为失效,然后响应失效ACK。
由此可知,无论是CPU本地写等待失效ACK,还是CPU要处理远程写的失效请求。都会因为保证数据强一致性而带来的延迟。
为此,针对上面的2个场景,CPU再做优化。
优化对象:触发本地写的CPU
优化切入点:CPU等待其他CPU的失效ACK所造成的阻塞
具体做法
引入存储缓存,当发生本地写事件时,不再等待远程ACK响应。而是会将新值写入存储缓存中,然后CPU继续去处理其他事情。当全部失效ACK都响应完成时,才会将存储缓存中的数据同步到Cache中,并将状态变为 M
此时会有一个问题,就是当数据在存储缓存中,还在等待失效ACK时,这条数据的最新值是不位于缓存中的,此时CPU要对这个数据进行读取的话。CPU会先去存储缓存判断是否存在这条数据,存在的话直接读存储缓存的值。这一机制被称为Store Fowarding
存储缓存容量不大,因此当存储缓存已经堆积满时。此时有新的本地写事件,会阻塞CPU。直到存储缓存中有事务完成。
优化手段
通过将同步操作保证强一致性,变为异步操作保证最终一致性。从而达到优化本地CPU的处理效率。
优化对象:触发远程写的CPU
优化切入点:CPU收到远程写事件对应,需要进行失效处理后响应失效ACK所造成的延迟
具体做法
引入失效队列,当收到失效处理请求时。CPU先不处理对应缓存行,而是将失效请求放入失效队列,同时马上响应失效ACK回去。而对于失效队列,会在空闲时逐个进行处理。
优化手段
将耗时的同步操作保证强一致性。改为异步保证最终一致性
经过上面的一系列发展,最终基于这种最终一致性保证的MESI协议下,CPU的处理效率得到很大的提升。但这也意味着,它会带来数据不一致的问题。
我们回到最初的代码,这里引起最终value == 10可能为false的原因。有重排序带来的问题,也有存储缓存带来的问题。cpu0中并不能保证value=10已经成功写入缓存or主存。因此cpu1见到done=true时,并不能保证value一定等于10 。同时这里也牵涉一个概念,叫做数据依赖性。value和done并不存在任何数据依赖,假设他们存在数据依赖。那么当done=true时,value一定等于10是可以保证的。
public class Demo {
int value = 0;
boolean done = false;
void cpu0() {
value = 10;
done = true;
}
void cpu1() {
while (!done) {
}
System.out.println(value == 10);
}
}
在实际工作中,存在很多场景是需要保证同步、保证数据强一致性的。
所以CPU设计者提供了能够保证一致性的手段,它将这些手段的使用权交给用户去考虑。
内存屏障解决的问题
从功能类型上划分,内存屏障主要分为三种:
保证写事务的强一致性。 当CPU遇到写屏障时,必须强制等待存储缓存中的写事务全部处理完毕后,cpu才能继续工作。 其目的是保证当前CPU的写操作,能够通知到其他CPU并响应失效ACK 注意,虽然保证了能等待所有其他CPU响应失效ACK后,才继续工作。但这并不意味着,其他CPU一定会对当前变更后的最新值可见。原因是其他CPU还存在失效队列。写屏障并不能保证,其他CPU将失效请求已经处理完,仅仅只是保证他们都响应了ACK
保证读事务的强一致性 当CPU遇到读屏障时,必须强制处理完当前失效队列的所有无效事务。 其目的是保证,读屏障之后的读指令,能够读到最新的值。
同时包含写、读屏障的功能
而具体的实现,如在X86架构中,C语言定义的内存屏障命令有
// 编译器屏障,只针对编译器生效(GCC 编译器的话,可以使用 __sync_synchronize) #define barrier() asm volatile(“”:::“memory”)
// cpu 内存屏障 #define lfence() asm volatile(“lfence”: : :“memory”) #define sfence() asm volatile(“sfence”: : :“memory”) #define mfence() asm volatile(“mfence”: : :“memory”)
我们来看一下,如果使用内存屏障的话,如何来解决我们之前的代码问题,保证输出true ?
public class Demo {
int value = 0;
boolean done = false;
void cpu0() {
value = 10;
// 插入写屏障,保证value的新值能写入主存,
写屏障();
done = true;
}
void cpu1() {
while (!done) {
}
// 插入读屏障,保证在读取value前,处理完所有失效请求,保证value的值从主存中获取
读屏障();
System.out.println(value == 10);
}
}
上面提到的硬件级指令,会随着CPU的不同而不同。
如果Java需要程序员自行去编写代码使用内存屏障(如上面代码插入的2个内存屏障),那么我们就需要识别不同的处理器来使用不同的指令,写出来的代码可能如下:
// 伪代码
if (cpu is X86) {
cpuX86.mfence();
} else if (cpu is M1) {
cpuM1.storeLoadBarrier();
} else {
// ...
}
这样的编码会给开发人员带来很大的不便,不仅如此需要开发人员具备更深入的知识能力并且存在一定的危险性(如官方不推荐开发人员使用Unsafe这种能够操作最底层的工具类) 为了屏蔽不同CPU的内存屏障指令差异的细节。JMM 把内存屏障定义为四类(而每一类JMM内存屏障具体的底层实现,Java开发者不需要关注)
Load1; LoadLoad; Load2 保证Load1先于Load2执行 确保Load1数据的装载先于Load2及所有后续装载指令
Store1; StoreStore; Store2 保证Store1先于Store2指令执行 确保Store1数据对其他处理器可见(刷新到内存),先于Store2及所有后续存储指令
Load1; LoadStore; Store2 确保Load1数据装载先于Store2及所有后续存储指令刷新到内存
Store1; StoreLoad; Load1
确保Store1数据对其他处理器可见(刷入内存),先于Load2及所有后续装载指令。 StoreLoad指令保证屏障之前所有的内存访问指令(装载和存储)完成之后,才执行屏障后续的内存访问指令 (因此StoreLoad又被称为 全能指令,能同时具备上述3种指令的效果)
上面聊了内存屏障,下面再回到CPU硬件级,再简单介绍2条常见的指令
除了内存屏障外,还有一些指令如#Lock,它虽然不是内存屏障但能达到内存屏障相似的效果。同时它也为原子性提供了有力的支持
当CPU遇到#Lock信号时,会做如下处理
我们Java中用到的Unsafe类CAS操作(或是AtomicXXX类),它的底层是通过CMPXCHG指令实现比较和交换操作。为了保证CAS的硬件级原子性,它会依靠#Lock指令来实现。
// Unsafe compareAndSet()方法,往下挖到HotSpot源码关于x86的实现如下
// cmpxchg本身不具备原子性,只是一个单纯的比较和交换。所以需要依赖lock指令来保证多CPU并行时CAS操作的原子性
__asm__ volatile ("lock cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest)
: "cc", "memory");
我们再一次回到这一段代码
public class Demo {
int value = 0;
boolean done = false;
void cpu0() {
value = 10;
// or StoreStoreBarrier()
写屏障();
done = true;
}
void cpu1() {
while (!done) {
}
// or LoadLoadBarrier()
读屏障();
System.out.println(value == 10);
}
}
虽然 JMM 为我们屏蔽了硬件层面内存屏障指令的细节,提供了LoadLoad,LoadStore,StoreStore,StoreLoad四种内存屏障的封装。但是我们平时开发中,很少会直接跟内存屏障直接打交道。当然还是因为内存屏障的学习使用成本很高。
因此,无论是Java还是Go语言,都为我们再一次屏蔽内存屏障的细节,以至于程序员在进行开发时,可能干到退休都不知道内存屏障的概念。程序员只需要关注,如何编码保证原子性、可见性、有序性即可。 至此,语言级解决可见性、重排序问题的主角登场了
happends-before
概念: happends-before 定义了一系列跟语言有关的规则,来保证操作之间的可见性 如 A happends-before B 则保证了 A操作的结果 对 B操作可见
happends-before的出现,不再需要程序员去关注如何使用内存屏障,程序员只要清楚了解happends-before有哪些规则,在开发过程中注意即可保证可见性,有序性。
规则
例子 挑其中几条规则,做一点讲解来理解
(1) 如volatile规则
volatile int a = 0;
1 a = 10;
2 print(a == 10); // true
基于这条规则,它保证无论是单线程,还是多线程。只要代码1 先行于 代码2 。a = 10就一定可见
(2) volatile + 传递规则
int a = 0;
volatile boolean done = false;
public void cpu0() {
this.a = 10; // (1)
this.done = true; // (2)
}
public void cpu1() {
while (true) {
if (done) { // (3)
print(x == 10); // (4) true
break;
}
}
}
首先是基于程序次序规则、volatile规则得出: (1) happends-before (2)
(3) happends-before (4)
(2) happends-before (3)
得出三面3个公式后,我们再根据传递性可得 (1) happends-before (4) 这也就意味着, (4)的x一定是10,它对(1)的操作可见
(3) 线程启动规则
在父线程中启动子线程,子线程启动前的父线程操作对它可见
// 正例
int a = 0;
a = 10;
new Thread(() -> {
print(a == 10); // true
}).start();
// 反例,不满足线程启动规则
int a = 0;
new Thread(() -> {
a = 10;
}).start();
print(a == 10); // true or false 都有可能。不能保证
(4) synchronize
Object lock = new Object();
int a = 0;
public void cpu0() {
synchronized (lock) {
// cpu0先进入
this.a = 10;
}
}
public void cpu1() {
synchronized (lock) {
// cpu1后进
print(a == 10);
}
}
能够保证cpu1读到的a为10。
内存语义:
为了实现上述的内存语义,编译器在生成字节码的时候。会对volatile变量进行如下处理
volatile写(前面):StoreStoreBarrier volatile写(后面):StoreLoadBarrier volatile读(后面):LoadLoadBarrier volatile读(后面):LoadStoreBarrier
内存语义
规则
例子 (1) Goroutine Creation
var value = 0
func main() {
value = 10;
go func() {
// 规则保证了可见性
println(10 == value)
}
}
(2) channel
package main
var value = 0
var done = false
var ch = make(chan struct{})
func setup() {
value = 10
done = true
<- ch
}
func main() {
go setup()
ch <- struct{}{}
for !done {
}
println(value == 10)
}
上图中jvm实现的内存屏障指向c语言实现内存屏障,并不是指LoadLoad、LoadStore等JMM内存屏障是通过调用lfence、sfence、mfence实现。
对硬件级内存屏障感兴趣的,可以看看c语言、或者open-jdk里面关于orderAccess相关源码。看看都用了什么指令,这些指令有什么作用为什么能达到内存屏障的效果。
首先相信这张图,对于一个Java工程师来说再也熟悉不过了。这是 Java虚拟机对CPU、Cache、主内存的一种抽象设计,它被称作 JMM(Java Memory Model)
CPU-Cache- Memory 和 JMM 之间的逻辑映射关系 CPU-高速缓存 抽象为 线程-工作内存(存放于JVM栈中) 主内存 抽象为 主内存(存放于JVM堆中) 对于CPU-高速缓存-主存来说,都是在物理意义上完全独立的硬件设备。 而对于JMM,实际上在物理硬件上,他们都位于主存(物理硬件设备)。而 JVM对物理主存又划分了5个逻辑区域,分别是堆、栈、方法区、本地方法区、程序计数器。(它只是一种对物理内存上进行逻辑划分的设计) 因此,CPU-Cache-Memory 和 线程-工作内存-堆-主存(堆) 看似相似,但实际并不影响CPU MESI。同样的,JMM这样设计之初,也是尽可能的让线程和工作内存位于CPU对应的寄存器和高速缓存中。两者可以说是相似的,可以从宏观上进行对等理解。
首先回忆CPU-Cache-主存的工作方式,
CPU会从主存中复制一份数据副本到自己的高速缓存,之后的读写操作会基于工作内存。MESI协议用于保证数据一致性,基于这份协议可以对高速缓存的数据何时同步到主存做一个控制。
JMM的工作方式也类似:
线程会从主存(JVM堆)中复制一份数据副本到自己的工作内存,之后的读写操作会基于工作内存。而JMM也有一套机制,用于解决工作内存何时将数据同步到主存(JVM堆)中。
Lock:作用于主存;把一个变量标识为线程独占状态 Unlock:作用于主存;把一个变量从线程独占状态恢复释放 Read:作用于主存;把一个变量传输到工作内存,以便后续的Load操作 Load:作用于工作内存;将Read传输过来的变量数据,放入工作内存的变量副本中 (Read和Load一定是顺序、成对出现的) Use:作用于工作内存;将工作内存的变量副本的值传输给线程(执行引擎)使用;当执行变量的读操作时会触发Use Assign:作用于工作内存;将从执行引擎获取的新值赋值给工作内存的变量副本中 Store:作用于工作内存;将工作内存的变量副本的值,传输给主存,以便后续的Write操作 Write:作用于主存;将Store传输过来的值,写入到主存的变量中(Store和Write一定是顺序,成对出现的)
学好MESI、内存屏障可能不能帮助你写好Java、Golang。
像Java的话,了解底层或许能帮助你对synchronize、volatile、JUC包、AQS有个更好的理解。用好JUC的话其实是能够帮助你在并发编程上用的更加游刃有余。
完。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有