深入理解并发编程艺术之计算机内存模型

2023年 10月 27日 58.4k 0

了解java内存模型不得不先了解计算机内存模型,我们接下来就从计算内存模型说起

计算机发展

我们都知道 CPU 和 内存是计算机中比较核心的两个东西,任何在计算机上运行的程序其实都是对数据的存取和处理计算,最终都会映射成cpu和内存之间的频繁交互,最原始计算机就是cpu读取内存进行处理,然后回写内存。

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,cpu的处理速度不断增速,其处理速度远远超出了内存的读写速度,导致的后果就是cpu大量的时间都花费在磁盘 I/O、网络通信或者数据库访问上,cpu大部分的时间都处于空闲的等待状态。

为了充分压榨cpu的性能,避免cpu性能浪费,就必须使用一些手段去把处理器的运算能力“压榨”出来,最容易想到的就是让计算机同时处理几项任务。为了实现这一目标,计算机系统不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

图片图片

上图为计算机多核cpu多级缓存图,即当下流行的cpu架构,计算机内存模型主要涉及到的组件:处理器,寄存器,高速缓存,内存,缓存行。

处理器:负责做逻辑运算,程序代码都会变成运算指令或计算公式,在处理器里面其实就是二进制的各种组合,处理器计算后会得到一个结果。

寄存器:离处理器最近的一块存储介质,可以说位于内存模型的顶端,它的速度非常之快,快到可以和处理器相媲美,处理器从里面拿数据,运算完之后又把数据存回去。寄存器是处理器里面的一部分,处理器可能有多个寄存器,比如数据计数器,指令指针寄存器等等。

高速缓存:是一个比内存速度快很多接近处理器速度的存储区域,目的是把处理器要用到的一堆数据从主内存中复制进来供处理器使用,处理器运算处理完了之后又把结果同步回主内存,这样处理器只做自己的事,而高速缓存就成了传话筒。高速缓存有分为一级缓存,二级缓存和三级缓存,离处理器最近的是一级缓存,依次往后排。存储器存储空间大小:内存>L3>L2>L1>寄存器存储器速度快慢排序:寄存器>L1>L2>L3>内存

缓存行:缓存是由最小的存储区块-缓存行(cacheline)组成,缓存行大小通常为64byte。缓存行是什么意思呢?比如你的L1缓存大小是512kb,而cacheline = 64byte,那么就是L1里有512 * 1024/64个cacheline,也是cpu中寄存器从缓存中取数据的最小单位,即取数为x=0,那么在缓存中找到x=0后不是只把x=0取走,而是把x=0所在的缓存行取走。

内存:就是我们通常讲的内存,比如现在的电脑动不动8G,16G啊等等,在内存模型中叫做主内存,它比磁盘的读写速度快很多,但是又跟高速缓存没法比,因此,程序启动的时候,程序相关的数据会加载到主内存,然后处理器处理某块逻辑的时候,比较占空间的东西会丢到主内存,比如Java里面的对象,就是存放在堆上面的,而Java虚拟机里面的堆就是放在主内存的。

在CPU访问存储设备时会遵循一定的原理,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就是局部性原理。这也是cpu架构提高性能的一个关键性因素。

时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。

空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。比如顺序执行的代码、连续创建的两个对象、数组等。

带有高速缓存的CPU执行计算的流程:1.程序以及数据被加载到主内存2.指令和数据被加载到CPU的高速缓存3.CPU执行指令,把结果写到高速缓存4.高速缓存中的数据写回主内存

讲到这里我们知道以上新型cpu架构是为充分压榨cpu性能而来,那么就单看以上架构,在不做任何优化的情况下,当多核cpu并发工作的时候必然会引入缓存一致性问题。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?例如:假设主内存中存在一个共享变量 x,现在有 A 和 B 两个内核(也可以直接说分布在两个核上的线程)分别对该变量 x=1 进行操作,A/B 核各自高速缓存中存在共享变量副本 x。假设现在 A 想要修改 x 的值为 2,而 B 却想要读取 x 的值,那么 B 读取到的值是 A 更新后的值 2,还是更新前的值 1 呢?答案是,不确定,即 B 有可能读取到 A 更新前的值 1,也有可能读取到 A 更新后的值 2,这是因为高速缓存是每个核私有的数据区域,而 A 在操作变量 x 时,首先是将变量从主内存拷贝到 A 的高速缓存中,然后对变量进行操作,操作完成后再将变量 x 写回主内存,而对于 B 也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如 A 修改完后正在将数据写回主内存,而 B 此时正在读取主内存,即将 x=1 拷贝到自己的工作高速缓存中,这样 B 读取到的值就是 x=1,但如果 A 已将 x=2 写回主内存后,B 才开始读取的话,那么此时 B 读取到的就是 x=2,但到底是哪种情况先发生呢,在并发访问过程中这些都是不确定的。

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证,顾名思义,当单线程运行的时候,无论怎样乱序,最终的结果都是预期的结果,但是当多线程的时候呢,就不一定了,特别是存在共享变量的或者说一个线程依赖于另一个线程的计算结果的时候,就很有可能因为乱序带来不正确的结果。

通过以上可以得知,cpu架构自身存在数据一致性的问题和乱序重排问题,其实也可以理解为java的并发访问的原子性问题,可见性问题,有序性问题。

计算机内存模型

在多核cpu架构中,每个核心都有自己的L1 L2高速缓存,同个cpu的多个核心共享L3缓存,不同cpu之间共享主内存,为了保证共享内存的正确性,内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:1.限制处理器优化2.使用内存屏障

我们来看下内存模型的具体做法

解决缓存不一致问题

解决缓存不一致的方法有很多,比如:总线加锁(此方法性能较低,现在已经不会再使用)MESI 协议:当一个 CPU 修改了 Cache 中的数据,会通知其他缓存了这个数据的 CPU,其他 CPU 会把 Cache 中这份数据的 Cache Line 置为无效,要读取数据的话,直接去内存中获取,不会再从 Cache 中获取了。当然还有其他的解决方案,MESI 协议是其中比较出名的。

MESI 协议中的状态CPU 中每个缓存行使用的 4 种状态进行标记(使用额外的两位 bit 表示)

图片图片

  • M 和 E 的数据都是本 core 独有的,不同之处是 M 状态的数据是 dirty(和内存中的不一致),E 状态的数据是 clean(和内存中的一致)
  • S 状态是所有 Core 的数据都是共享的,只有 clean 的数据才能被多个 core 共享
  • I-表示这个 Cache line 无效

E 状态只有 Core 0 访问变量 x,它的 Cache line 状态为 E(Exclusive)。

图片图片

S 状态3 个 Core 都访问变量 x,它们对应的 Cache line 为 S(Shared)状态。

图片图片

M 状态和I状态之间的转化Core 0 修改了 x 的值之后,这个 Cache line 变成了 M(Modified)状态,其他 Core 对应的 Cache line 变成了 I(Invalid)状态 在 MESI 协议中,每个 Cache 的 Cache 控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。每个 Cache line 所处的状态根据本核和其它核的读写操作在 4 个状态间进行迁移

图片图片

MESI 协议通过标识缓存数据的状态,来决定 CPU 何时把缓存的数据写入到内存,何时从缓存读取数据,何时从内存读取数据。

MESI 协议看似解决了缓存的一致性问题,但是并不那么完美,因为当多个缓存对数据进行了缓存时,一个缓存对数据进行修改需要同过指令的形式与其他 CPU 进行通讯,这个过程是同步的,必须其他 CPU 都把缓存里的数据都置为 Invalid 状态成功后,我们修改数据的 CPU 才能进行下一步指令,整个过程中需要同步的和多个缓存通讯,这个过程是不稳定的,容易产生问题,而且通讯的过程中 CPU 是必须处于等待的状态,那么也影响着 CPU 的性能。

为了避免这种 CPU 运算能力的浪费,解决 CPU 切换状态阻塞,Store Bufferes 被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认都接收到时,数据才会最终被提交。

指令重排问题

public class config{
    // 此变量必须定义为
 1   boolean initialized = false;
 2   public Object cache(@NotNull String key) {
 3       if (!initialized) {
 4           doSomethingWithConfig();
 5       }
 6       configText = readConfigFile("pz");
 7       processConfigOptions(configText, "xx");
 8       initialized = true;
 9       if (!initialized) {
 10           doSomethingWithConfig();
        }
    }  
}

拿上面的代码来说明下乱序,简单来讲就是initialized = false;cpu为了高效,避免再次去缓存取值,很有可能接着执行initialized = true(判断为无依赖关系的情况下),这个时候6、7行还没有执行,单线程情况下不会有问题,但是并发情况下就会有问题。下一篇我们详细讲解。

指令重排序解决方案:硬件工程师其无法预知未知的程序逻辑场景,所以一些问题还是遗留给了软件工程师,但是他们给我们提供了一套对应场景的解决方案就是“内存屏障指令”,我们的软件工程师可以同内存屏障来针对不同场景来选择性的“禁用缓存”内存屏障,又称内存栅栏,是一个CPU指令,硬件分为下面几种:

lfence(读屏障 load Barrier):在读取指令前插入读屏障,让缓存中的数据失效,重新从主内存加载数据,保证数据是最新的。Sfence(写屏障 store Barrier):在写入指令后插入屏障,同步把缓存的数据写回内存,保证其数据立即对其他缓存可见。Mfence(全能屏障):拥有读屏障和写屏障的功能。Lock 前缀指令:Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

注意:不同的硬件缓存一致性协议和内存屏障可能不同

总结

随着计算机高速发展,CPU 技术远超过内存技术,所以多级缓存被使用,解决了内存和 cpu 的读写速度问题,随着多线程的发展,缓存一致性问题油然而生,好在可以通过缓存一致性协议来解决,比较出名的缓存一致性协议是MESI,MESI协议的引入,微微降低了 cpu 的速度。

为了更好的压榨 cpu 的性能,于是Store Bufferes 概念被引入,将 cpu 写入主存从同步阻塞变为异步,大大提高了 cpu 执行效率

指令重排序问题预期而至,这时候祭出终极武器:内存屏障指令,在代码里面禁用缓存。

至此,计算机发展中遇到的问题都一一解决,而这一系列问题解决方案,都是内存模型规范的。

内存模型就是为了解决计算机发展中遇到的缓存一致性、处理器优化和指令重排、并发编程等问题的一系列规范,他定义了共享内存系统中多线程程序读写操作行为的规范,通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论