并发基础

2019-11-17  本文已影响0人  端碗吹水

想要深入并发编程,必然需要了解一些底层基础的知识点,例如什么是CPU的多级缓存,缓存之间的一致性又是如何实现的?以及什么是乱序执行,又会带来什么样的问题?在JVM层面又是如何通过JMM规范让Java程序在各种平台下都能达到一致的并发效果的?对于这几个知识点,本文将会进行简要的介绍,目的是让大家能够对此有一个初步的了解。

CPU多级缓存之缓存一致性

在一开始,CPU可不像现在拥有多级缓存,而只有一级高速缓存。下图就简单展示了一级缓存与其他组件之间的关系,可以看到CPU对数据的读取和存储都会经过一个高速缓存。CPU核心与高速缓存间有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS)这条总线同时还用于其他组件的通信:


image.png

在高速缓存出现后不久,系统变得越来越复杂,高速缓存与主存之间的速度差异被拉大。直到加入了另一级缓存,新加入的这级缓存比第一缓存更大,速度上会稍微慢一些,这就是所谓的二级缓存。甚至有些系统已经拥有了三级缓存,对于这种有多个级别的缓存体系通常称之为多级缓存。如下图:


image.png

为什么需要CPU cache:

CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,这样就会浪费资源。所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题(结构:CPU -> cache -> memory)

缓存的容量远远小于主存,因此出现缓存不命中的情况在所难免,既然缓存不能包含CPU所需要的所有数据,那么缓存的存在真的有意义吗?

CPU cache是肯定有它存在的意义的,至于CPU cache有什么意义,那就要看一下它的局部性原理了:

1.时间局部性:如果某个数据被访问,那么在不久的将来它很可能再次被访问
2.空间局部性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问

在多级缓存中需要保证缓存的一致性,这就涉及到MESI了。MESI是一个协议,这协议用于保证多个CPU cache之间缓存共享数据的一致性。它定义了Cache Line的四种数据状态,而CPU对cache的四种操作可能会产生不一致的状态。因此缓存控制器监听到本地操作与远程操作的时候需要对地址一致的Cache Line状态做出一定的修改,从而保证数据在多个cache之间流转的一致性。CacheLine的四种状态如下:

  • M: Modified 修改,指的是该缓存行只被缓存在该CPU的缓存中,并且是被修改过的,因此他与主存中的数据是不一致的, 该缓存行中的数据需要在未来的某个时间点(允许其他CPU读取主存相应中的内容之前)写回主存,而当数据被写回主存之后,该缓存行的状态会变成E(独享)
  • E:Exclusive 独享 缓存行只被缓存在该CPU的缓存中,是未被修改过的,与主存的数据是一致的,可以在任何时刻当有其他CPU读取该内存时,变成S(共享)状态,同样的当CPU修改该缓存行的内容时,会变成M(被修改)的状态
  • S:Share 共享,当前CPU和其他CPU中都有共同数据,并且和主存中的数据一致;意味着该缓存行可能会被多个CPU进行缓存,并且各缓存中的数据与主存数据是一致的,当有一个CPU修改该缓存行时,在其他CPU中的该缓存行是可以被作废的,变成I(无效的) 状态
  • I:Invalid 无效的,代表这个缓存是无效的,可能是有其他CPU修改了该缓存行;数据应该从主存中获取,其他CPU中可能有数据也可能无数据,当前CPU中的数据和主存被认为是不一致的;对于invalid而言,在MESI协议中采取的是写失效(write invalidate)。

MESI状态流转示意图:


image.png

Cache Line有四种数据状态(MESI),而引起数据状态转换的CPU cache操作也有四种:

因此要完整的理解MESI这个协议,就需要把这16种状态转换的情况理解清楚,状态之间的相互转换关系,可以使用下图进行表示:


image.png

在一个典型的多核系统中,每一个核都会有自己的缓存来共享主存总线,每个相应的CPU会发出读写(I/O)请求,而缓存的目的是为了减少CPU读写共享主存的次数。一个缓存除了在 Invalid 状态之外,都可以满足CPU的读请求。

一个写请求只有在该缓存行是M状态,或者E状态的时候才能够被执行。如果当前状态是处在S状态的时候,它必须先将缓存中的该缓存行变成无效的(Invalid)状态,这个操作通常作用于广播的方式来完成。这个时候它既不允许不同的CPU同时修改同一个缓存行,即使修改该缓存行不同位置的数据也是不允许的,这里主要解决的是缓存一致性的问题。一个处于M状态的缓存行它必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。

一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。

因此,对于M和E两种状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。


CPU多级缓存之乱序执行优化

什么是乱序执行优化:

例如,我现在有两个变量a和b,a的值为10,b的值为200,我要计算a乘以b的结果。而我在代码上写的是:

a=10;
b=200;
result=a*b;

但是到了CPU上的乱序执行优化后,可能就变成了:

b=200;
a=10;
result=a*b;

如下图:


image.png

从上图中,可以看到CPU乱序执行优化后的代码并不会对计算结果造成影响,但这也只是其中一种没被影响的情况而已。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标。但是在多核环境下则并非如此,因为在多核环境下同时会有多个核心在执行指令,每个核心的指令都可能被乱序。另外处理器还引入了L1、L2等多级缓存机制,而每个核心都有自己的缓存,这就导致了逻辑次序上后写入的数据未必真的写入了。如果我们不做任何防护措施,那么处理器最终处理的结果可能与我们代码的逻辑结果大不相同。比如我们在一个核心上执行数据写入操作,并在最后写一个标记用来表示之前的数据已经准备好了。然后从另外一个核心上通过判断这个标记来判定所需要的数据是否已准备就绪,这种做法就存在一定的风险,标记位可能先被写入,而数据并未准备完成,这个未完成既有可能是没有计算完成,也有可能是缓存没有被及时刷新到主存之中,这样最终就会导致另外的核心使用了错误的数据,所以我们才经常在多线程的情况下保证线程安全。


Java内存模型

以上我们简单介绍了在多核并发的环境下CPU进行乱序执行优化时所带来的线程安全问题,为了保证线程安全,我们需要采取一些额外的手段去防止这种问题的发生。

不过在介绍如何采用实际手段解决这种问题之前,我们先来看看Java虚拟机是如何解决这种问题的:为了屏蔽各种硬件和操作系统内存的访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,所以Java虚拟机规范中定义了Java内存模型(Java Memory Model简称JMM)。

Java内存模型是一种规范,它定义了Java虚拟机与计算机内存是如何协同工作的。它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步地访问共享变量。


image.png

在明确了Java内存模型是做什么的之后,我们来看一下其中内存分配的两个概念

Java内存模型要求调用栈和本地变量存放在线程栈(Thread Stack)上,而对象则存放在堆上。一个本地变量也可能是指向一个对象的引用,这种情况下这个保存对象引用的本地变量是存放在线程栈上的,但是对象本身则是存放在堆上的。

一个对象可能包含方法,而这些方法可能包含着本地变量,这些本地变量仍然是存放在线程栈上的。即使这些方法所属的对象是存放在堆上的。一个对象的成员变量,可能会随着所属对象而存放在堆上,不管这个成员变量是原始类型还是引用类型。静态成员变量则是随着类的定义一起存放在堆上。

存放在堆上的对象,可以被持有这个对象的引用的线程访问。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,那么它们都将会访问这个方法中的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。


硬件内存架构

现代硬件内存模型与Java内存模型有一些不同。理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。这部分描述了通用的硬件内存架构,下面的部分将会描述Java内存是如何与它“联手”工作的。

下图简单展示了现代计算机硬件内存架构:


image.png

运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

当CPU需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。CPU缓存可以在某一时刻将数据局部写到它的内存中,和在某一时刻局部刷新它的内存。它不会再某一时刻读/写整个缓存。通常,在一个被称作“cache lines”的更小的内存块中缓存被更新。一个或者多个缓存行可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。


Java内存模型和硬件内存架构之间的桥接

上面已经提到,Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件而言,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:


image.png

线程和主内存的抽象关系

Java内存模型抽象结构图:


image.png

每个线程之间的共享变量存储在主内存里面,每个线程都有一个私有的本地内存,本地内存是Java内存模型的一个抽象的概念,并不是真实存在的。它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器的优化,本地内存中存储了该线程已读或写共享变量的拷贝的一个副本。

从一个更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。

Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

如果上图中的线程A和线程B要通信,必须经历两个步骤:

  1. 首先线程A要把本地内存A中更新过的共享变量刷新到主内存里
  2. 然后线程B再到主内存中去读取线程A更新的共享变量,这样就完成了两个线程之间的通信了

因此,多线程的环境下就会出现线程安全问题。例如我们要进行一个计数的操作:线程A在主内存中读取到了变量值为1,然后保存到本地内存A中进行累加。就在此时线程B并没有等待线程A把累加后的结果写入到主内存中再进行读取,而是在主内存中直接读取到了变量值为1,然后保存到本地内存B中进行累加。此时,两个线程之间的数据是不可见的,当两个线程同时把计算后的结果都写入到主内存中,就导致了计算结果是错误的。这种情况下,我们就需要采取一些同步的手段,确保在并发环境下,程序处理结果的准确性。

采取同步手段时的八种操作

同步规则

同步操作与规则:


image.png

并发的风险与优势

image.png
上一篇 下一篇

猜你喜欢

热点阅读