【并发编程】深入了解 volatile,nginx 负载均衡架构
在知道 volatile 是如何保证变量的可见性之前,我们先要知道内存不可见的两个原因:
1、CPU 的运行速度是远远高于内存的读写速度的,为了不让 CPU 等待读写内存数据,现代 CPU 和内存之间都存在一个高速缓存 cache(实际上是一个多级寄存器),如下图:
线程在运行的过程中会把主内存的数据拷贝一份到线程内部 cache 中,其实就是访问自己的内部 cache。如果线程 B 把数据加载进内部缓存 cache 中,线程 A 再修改了数据。即使重新写入主内存,但是线程 B 不会重新从主内存加载变量,看到的还是自己 cache 中的变量,所以线程 B 是读取不到线程 A 更新后的值。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。 但是,我们也都知道 volatile 只能保证可见性,不能保证原子性。多个线程同时读取这个共享变量的值,就算保证其他线程修改的可见性,也不能保证线程之间读取到同样的值然后相互覆盖对方的值的情况。
[](
)二、防止指令重排
我们再来看指令重排。
[](
)1、定义
指令重排是指在程序执行过程中, 为了性能考虑, 编译器和 CPU 可能会对指令重新排序。
介
绍指令重排之前,首先介绍一下内存交互操作的 8 种指令吧。虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许例外)
| 指令 | 内容 |
| --- | --- |
| lock (锁定) | 作用于主内存的变量,把一个变量标识为线程独占状态 |
| read (读取) | 作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用 |
| load (载入) | 作用于工作内存的变量,它把 read 操作从主存中得到变量放入工作内存的变量副本中 |
| use (使用) | 作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作 |
| assign (赋值) | 作用于工作内存中的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 |
| store (存储) | 作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用 |
| write ?(写入) | 作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中 |
| unlock (解锁) | 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
| 如图所示: | |
既然操作可以被分解为很多步骤, 那么多条操作指令就不一定依次序执行,因为每次只执行一条指令, 依次执行效率太低了。就像小时候学习的煮饭烧水任务时间分配一样,内存也会很聪明的分配时间。
本来想给大家整一个指令重排序的例子的,但是不管是我自己写还是用别人的代码,我的电脑都没办法让它重排序。但是我们都知道,指令重排是确实存在的(CPU 确实会进行重排序,但是这种重排序是无法被我们观测到和控制的)。
一般重排序可以分为如下三种:
1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
[](
)2、原理
我们来看加了 volatile 前后的代码,用的就是阿里规约提供给我们的双重检查锁的代码。我们分别编译了两次,第一个是没有使用 volatile 关键字修饰的,第二个是使用 volatile 关键字来修饰,然后取出他们的的汇编代码(实在是设计的地方太底层,其实这里算是用到了策略模式了)
未使用 volatile 修饰
0x000000010d29e93b: mov %rax,%r10
0x000000010d29e93e: shr $0x3,%r10
0x000000010d29e942: mov %r10d,0x68(%rsi)
0x000000010d29e946: shr $0x9,%rsi
0x000000010d29e94a: movabs $0xfe403000,%rax
0x000000010d29e954: movb $0x0,(%rsi,%rax,1)
使用 volatile 修饰
0x0000000114353959: mov %rax,%r10
0x000000011435395c: shr $0x3,%r10
0x0000000114353960: mov %r10d,0x68(%rsi)
0x0000000114353964: shr $0x9,%rsi
0x0000000114353968: movabs $0x10db6e000,%rax
0x0000000114353972: movb $0x0,(%rsi,%rax,1)
0x0000000114353976: lock addl $0x0,(%rsp)
很明显,在 movb 操作后,加了 volatile 修饰的汇编代码后面多了一条汇编指令 lock addl $0x0,(%rsp),这个操作相当于一个内存屏障,指令重排时不能把后面的指令重排序到内存屏障之前的位置。lock 前缀会强制执行原子操作,它的作用是是的本 CPU 的 cache 写入了内存,该写入动作会引起别的 CPU 无效化其 cache。所以通过这样一个空操作,可让前面 volatile 变量的便是对其他 CPU 可见。
从硬件架构上讲,指令重排序是指 CPU 将多条指令不按程序规定的顺序分开发送给各相应的点,但并不是指令任意重排,CPU 需要能正确处理指令,以保障程序能得出正确的执行结果。lock addl $0x0,(%rsp) 指令把修改同步到内存时,意味着所有值钱的操作都已经执行完成,这样便形成了指令重排序无法越过内存屏障的效果。
[](
)三、内存屏障
既然指令重排和可见性都依赖了 lock,同时 lock 指令引出了内存屏障,我们就来学习一下什么是内存屏障。
[](
)1、定义
内存屏障:保证屏障前的读写指令必须在屏障后的读写指令之前执行,通知被 Volatile 修饰的值,每次读取都从主存中读取,每次写入都同步写入主存。
内存屏障具体又分为写屏障和读屏障 写屏障(Store Memory Barrier):强制将缓存中的内容写入到缓存中或者将该指令之后的写操作写入缓存直到之前的内容被刷入到缓存中,也被称之为 smp_wmb 读屏障(Load Memory Barrier):强制将无效队列(volatile 写操作之后失其作废)中的内容处理完毕,也被称之为 smp_rmb
| 屏障类型 | 指令示例 | 说明 |
| --- | --- | --- |
| LoadLoadBarriers | Load1;LoadLoad;Load2 | 该屏障确保 Load1 数据的装载先于 Load2 及其后所有装载指令的的操作 |
| StoreStoreBarriers | Store1;StoreStore;Store2 | 该屏障确保 Store1 立刻刷新数据到内存(使其对其他处理器可见)的操作先于 Store2 及其后所有存储指令的操作 |
| | | |
| LoadStoreBarriers | Load1;LoadStore;Store2 | 确保 Load1 的数据装载先于 Store2 及其后所有的存储指令刷新数据到内存的操作 |
| | | |
| StoreLoadBarriers | Store1;StoreLoad;Load1 | 该屏障确保 Store1 立刻刷新数据到内存的操作先于 Load2 及其后所有装载装载指令的操作.它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |
[](
)2、原理
内存屏障在 Java 中的体现:
1、volatile 读之后,所有变量读写操作都不会重排序到其前面。
2、volatile 读之前,所有 volatile 读写操作都已完成。
3、volatile 写之后,volatile 变量读写操作都不会重排序到其前面。
4、volatile 写之前,所有变量的读写操作都已完成。
根据 JMM 规则,结合内存屏障的相关分析得出以下结论:
1、在每一个 volatile 写操作前面插入一个 StoreStore 屏障。这确保了在进行 volatile 写之前前面的所有普通的写操作都已经刷新到了内存。
2、在每一个 volatile 写操作后面插入一个 StoreLoad 屏障。这样可以避免 volatile 写操作与后面可能存在的 volatile 读写操作发生重排序。
3、在每一个 volatile 读操作后面插入一个 LoadLoad 屏障。这样可以避免 volatile 读操作和后面普通的读操作进行重排序。
4、在每一个 volatile 读操作后面插入一个 LoadStore 屏障。这样可以避免 volatile 读操作和后面普通的写操作进行重排序。
如下图所示:
[](
)3、as-if-serial 语义
但是用了 volatile 关键字,程序的运行速度必然会受到影响,那么除了 volatile 关键字以外什么时候不会发生重排序呢?这里就要引入 as-if-serial 语义。
评论