深入理解 CPU、内存与并发
深入理解 CPU、内存与并发
本文采取自底向上的方法,从 CPU、内存、线程、指令流水线、缓存一致性协议、内存模型,到Java 内存模型及其
happens-before,系统性梳理从单线程到多线程的底层原理。
为了避免出现“懂的人不需要看,需要看的人看不懂”,文章叙述风格以自然语言为主,并致力于用简洁的叙述来解释相关概念,大部分章节并不会掺杂专业名词。
通过理解硬件的原理和语言层面的协作,可以更好地理解程序行为,写出高性能且线程安全的代码。
内存
人们通过写代码的方式使用编程语言描述解决问题的算法,这些代码会被转为指令和数据交由 CPU 来计算解决。而内存就是计算机中用于存储
指令与数据的介质,它由一系列存储单元组成,每个单元可以存储一个比特(0 或 1)。
在本文的语境中,特指静态随机存储器,又称静态RAM static random-access memory, SRAM
,是随机存储器的一种。所谓“静态”,是指这种存储器只要保持通电,存储的数据就可以恒常保持。相对之下,动态随机存储器(DRAM)里面所存储的数据就需要周期性地更新。然而,当电力供应停止时,SRAM存储的数据还是会消失(被称为易失性存储器),这与在断电后还能存储数据的ROM或闪存是不同的。
CPU
中央处理器 Central Process Unit 为计算机的主要设备之一,功能主要是解释
指令以及处理计算机软件的数据。
我们往往会看到电商平台、新闻文章中会介绍某 CPU,M核N线程,其描述了一颗商品的 CPU 的关键性能信息。M 核,指该商品 CPU
中封装了几个物理计算单元,N线程,指该商品 CPU 中一共有多少个线程。一般情况下 N=2M(超线程技术)或 N=M,线程我们可以理解为
CPU 的执行流,更具体的后文会介绍。
基于一些历史性和工程实现方面的原因,在现代操作系统中物理 CPU 的每一个线程都会被识别为一个逻辑 CPU,在 Linux 环境下,可以使用
lscpu 来查看
root@ubuntu24:~$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i5-6300HQ CPU @ 2.30GHz
CPU family: 6
Model: 94
Thread(s) per core: 1
Windows 平台中,可以通过任务管理器查看
- 按
Ctrl + Shift + Esc打开任务管理器 - 切换到 “性能” 选项卡 → 点击左侧 “CPU”
- 查看右下角信息:
- “内核” :物理核心总数
- “逻辑处理器” :逻辑 CPU(线程)总数
在后文的叙述中,我们先假设讨论的是单核单线程的场景,这意味着计算机在任意时刻,只会执行一条指令。
软件、进程与线程
软件是一系列按照特定顺序组织的数据和指令
,是计算机中的非有形部分。整体来说,计算机中的有形部分称为硬件,由计算机的外壳及各零件及电路所组成。计算机软件需有硬件才能运作,反之亦然,软件和硬件都无法在不互相配合的情形下进行实际的运作。
软件分为多个层次,操作系统、编程语言、应用程序以及一些中间件,计算机启动,将操作系统的数据与指令加载到内存中,并由用户在操作系统之上启动应用程序,并加载、映射二进制文件到内存中,并将它们管理起来。为方便起见,我们称被操作系统加载、映射到内存,并管理起来的指令与数据称为
进程。
并行
我们使用操作系统启动进程,不可能只启动一个进程,但同样的,我们也不可能执行完一个进程再去执行另一个。假设我们登录了操作系统,启动了一个终端
A,然后在终端 A 内启动了一个服务 B,
./B &
此时,我们就有了终端 A,与服务 B 两个进程。我们需要的是 A,B 两个进程都在同一时间都在执行,毕竟就当前使用的感受上来说,我们显然并不会以为“只有退出终端
A 进程, CPU 才能执行服务 B 的指令”。但是 CPU 并不关心自己运行的指令到底归属于哪一个进程,对 CPU
来说,内存中有没有需要它执行的指令,有取来执行,没有就空闲。而进程 A 的指令与进程 B 的指令也并不需要真的同时在 CPU 上运行,只要
看起来是同时的就行了,因此操作系统就负责将 A,B 的指令和数据交替交给 CPU 处理,称为时间片调度
- 进程 A 运行几毫秒;
- 然后保存 A 的上下文(寄存器、堆栈指针等);
- 再加载进程 B 的上下文继续执行;
- 不断交替切换。
这种机制叫 伪并行(pseudo-parallelism) 。
线程
进程已经囊括了一个软件执行所需的所有指令和顺序,理论上说,应用程序就可以按照代码的编写顺序来执行任务了。但理论上可行,并不代表实际上可行。如果运行一个文件重命名程序,我们当然可以直接编写执行,很快就可以执行结束,得到重命名后的文件,并不需要关心中间是什么状态。
但相应的,当我们需要关注进程中间状态时,这种运行模式就不再可接受了。比如浏览器,单独打开浏览器时,渲染出首页、地址栏,可以一次性执行到位。但输入
URL 并按下回车后,浏览器需要做如下事情
- 解析 URL
- DNS 解析
- 建立 TCP 连接
- 发生 HTTP 请求(并阻塞)
- 等待响应
- 接受并渲染
此时由于是单进程,因此整个浏览器的体验都是卡顿的,在执行一个任务时,别的任务都不能执行。比如 HTML
中有其他静态资源的下载任务,就会整个卡住等待下载完毕后才能继续渲染,在渲染的同时用户的点击行为也是无效的,这在现代是可以称为“异常”的现象,在单进程的情况下是正常。
为了解决这个问题,结合伪并行技术,出现了线程
的概念。进程包含自身的全部指令和数据,而不同的任务的指令和数据是不同的,因此可以将各种任务分门别类,各自放在一个专门的内存区域中(存在于进程内部)。这个区域由进程分配并管理,于是一个进程中就有了多个被管理起来的”微进程“(实际并非进程,只仅作类比),称为
线程。它是进程内部的执行流,多线程可以利用伪并行技术,使它们看起来像是同时执行,从而显著减少界面卡顿,提高交互体验。
但多线程的引入不是没有代价的,代价就是线程安全。由于线程共享进程的内存空间,因此就存在一些共享数据,针对共享数据的不同访问顺序,就可能导致线程安全问题。解决线程安全问题需要多方面的配合,会在后文中详细介绍。
指令执行
CPU 功能主要是解释计算机指令以及处理计算机软件的数据,那么它是如何执行指令的?CPU
并不是一个魔法盒子,它的内部是由一系列更小的单元构成的,单元间相互配合来完成指令的执行。主要有如下这些单元
| 单元 | 功能 |
|---|---|
| 1. 程序计数器(PC) | 指向下一条要执行的指令地址 |
| 2. 指令寄存器(IR) | 存放当前正在执行的指令 |
| 3. 控制单元(CU) | 解码指令,产生控制信号(如“读内存”、“ALU加法”) |
| 4. 算术逻辑单元(ALU) | 执行算术/逻辑运算(如加、与、移位) |
| 5. 寄存器 | 存放数据,并能与 ALU 交换数据 |
CPU 执行指令就是靠上述单元协作进行的,更具体来说,CPU 执行指令主要有如下五个步骤
- 取指:
- 从通过程序计数器获取指令地址,依据地址从内存中加载指令到指令寄存器中
- 译码
- 控制单元解析指令,确定操作数和功能单元
- 执行
- 在算数逻辑单元中完成计算
- 访存(可选)
- 分为内存读、写,用于指令执行后、执行中需要内存数据的场景
- 写回
- 将计算结果存入寄存器
指令流水线
有了上述的流程和硬件支持,CPU 已经可以处理任意指令了,但还是存在瑕疵,因为 CPU 虽然分了单元,但驱动单元的动力是共享的,也就是说,PC、IR、CU、ALU
与寄存器,它们本身是无时无刻不在运行的。
举例来说,就像一条传送带,传送带两端有一系列机器人,能够将传送带上流转的原材料搓圆、捏扁、拉长、扭转、打包。传送带与机器人们共享电源和动力,因此就算根本没有原材料,传送带会继续转,机器人们也会执行它们预设的行为:即使没有原料时行为毫无意义。
我们把该传送带的速度放慢能够更直观地理解,假如存在一个按钮,按一下按钮,传送带前进一格,机器人们执行一次操作。那么需要按 5
次按钮才能执行完毕一个指令,而取指需要的单元,在按第二次按钮时就闲置了,后续也不需要它的参与,所以我们可以在每一次按按钮时都执行
取指。
由于理想状态下传送带总是向前传递、指令与指令间不存在依赖关系,那么所有机器人都不会空闲,从第 5
次按按钮起,每次都有一条指令执行完毕,吞吐量增加了 5 倍,这种技术被称为指令流水线,而“按按钮”就是 CPU 的执行频率。
下述为一条理想条件下的指令流水线
1. ADD R1, R2, R3 ; R1 = R2 + R3
2. SUB R4, R5, R6 ; R4 = R5 - R6
3. LW R7, 0(R8) ; R7 = Memory[R8 + 0]
| 时钟周期(按钮) | 取指 | 译码 | 执行 | 访存 | 写回 |
|---|---|---|---|---|---|
| 1 | ADD | ||||
| 2 | SUB | ADD | |||
| 3 | LW | SUB | ADD | ||
| 4 | (nop) | LW | SUB | ADD | |
| 5 | (nop) | (nop) | LW | SUB | ADD |
| 6 | (nop) | (nop) | (nop) | LW | SUB |
| 7 | (nop) | (nop) | (nop) | (nop) | LW |
流水线冒险
理想流水线要求指令之间无依赖、无跳转,但真实程序往往不满足,不满足的场景主要有以下三种,称为流水线冒险。
- 结构冒险
- 通俗来说就是流水线中的指令需要操作同一个资源,但该资源在同一时间,只能被一条指令使用,一般都是通过加资源的方式解决;
- 数据冒险
- 后一条指令依赖前一条指令的结果,简单的解决方案是暂停流水线(bubble),前一条执行完毕后,后一条才继续执行;
- 控制冒险
- 遇到了分支指令,只有当前指令执行完毕后,才知道下一条指令是什么。执行分支预测,猜对继续执行,猜错清空流水线。
如果后一条指令依赖前一条指令的执行结果,或存在硬件竞争,或当前指令执行完毕后才知道下一条指令是什么
- 遇到了分支指令,只有当前指令执行完毕后,才知道下一条指令是什么。执行分支预测,猜对继续执行,猜错清空流水线。
乱序执行
在上文中,数据冒险
会导致流水线停顿,虽然客观上停顿保证了指令的顺序执行规则,但停顿本身的闲置代价也是巨大的。因为只要指令间存在依赖关系,整条流水线就会退化为最原始的串行,这会极大降低吞吐量,性能损耗不可接受,必须要解决这个问题。
最符合直觉也是最简单的方案是把阻塞的指令拿下来放在准备区,让后续的指令先执行,当准备区的指令准备完毕(前一条指令执行完毕),再把它重新加载到流水线上继续执行。
这种后续指令先行,减少流水线空闲的技术,称为乱序执行(也是一种指令重排序),乱序执行的结果不符合指令的线性顺序(serial
),因此需要在流水线的最后存在一个缓冲区,恢复指令顺序后,统一提交。也就保证了as-if-serial的语义。
缓存
到目前为止,我们描述了这样的计算机世界:操作系统将应用程序的指令和数据以进程/线程的方式组织、存放在内存中,CPU
从内存中读取指令和数据,执行,写回内存供应用程序、操作系统使用。
这个流程已经足够完备,但还存在一个缺点:它没法用。或者说,它的“经济性”决定了它无法出现在现实世界。因为CPU是很金贵的东西,对CPU性能的压榨要非常非常极致才行,但由于
CPU 的运算速度与内存之间存在数量级的差距,因此上述架构中的 CPU 绝大多数时间都在闲置,等待内存响应它的取指、取数、写回请求。
为了解决这个问题,现代计算机系统中采用了缓存的方案。那么面临的第一个问题是:如何设计缓存?
缓存的硬件基础
内存为什么慢?简单来说是内存的硬件基础是电容,电容自然状态下就会漏电,需要不间断刷新、读写都需要额外的电路,甚至读内存会使该内存地址放电需要写回。那么有什么不需要刷新、读写极快还不会导致额外的充放电的硬件存储器呢?有的,
触发器就满足缓存的几乎一切需求。触发器基于晶体管构成,不需要刷新、速度极快、读不破坏数据,自然就被工程师拿去做了缓存的硬件基础。现在硬件基础有了,第二个问题是:缓存什么?
局部性原则
在现代 WEB 等系统中,对于要缓存什么是很显然的:读多写少的业务数据。
但 CPU 并不知道哪些东西读多写少,因为 CPU 根本不知道自己要处理什么,自然也不知道该去缓存什么。
但好在计算机科学家们在设计实现计算机时,进程的内存分布对CPU来说看起来连续的,这就很适合用局部性原理来指导缓存内容。
局部性原则有两个
- 时间局部性:刚刚访问的数据,短时间内很可能再次访问,
- 空间局部性:刚刚访问的内存地址,附近的地址也很可能被访问。
CPU 需要执行指令/访问操作数时,就从缓存读取,若缓存中不存在(miss)就从内存中拉取一个缓存行(一般 64 字节),存于缓存中。
现在还剩下一个问题:缓存放在哪?
缓存位置
虽然缓存的数据传输速度与电流速度一致,但以 CPU 的运行速度来说,在主板上如果距离 CPU
稍远,那么延迟也是不可接受的,考虑到额外的体积、散热等现实问题,现代计算机系统中缓存(cache)被设计在 CPU
内部,并配备多级缓存进一步降低延迟。由此诞生了经典的计算机存储层次
CPU 寄存器(最快)
↓
L1 Cache
↓
L2 Cache
↓
L3 Cache
↓
主内存 DRAM(慢)
↓
SSD/HDD(更慢)
缓存读写
现代 WEB 系统的缓存往往不需要写回,但 CPU 的缓存并非如此,CPU 的缓存既缓存读操作,也缓存写操作。上文谈到 CPU 访问缓存,若
miss 则从内存中拉取 64 字节,其实不够精确,因为访问分为读与写,它们的处理策略是不同的。
缓存读
缓存读的策略非常直白,读 miss 直接把数据拉入缓存。
缓存写
写策略相对来说就复杂得多,因为 CPU 的写行为与内存紧密相关。针对缓存的写策略的设计需要考虑两个维度。
- 维度一:写入
- 维度二:写 miss
写入
对缓存的写入,是否同步写回内存?
自然有写回与暂时不写回两种方案。
- Write-through 直写
- 每次写缓存都直接写回内存
- 延迟过大
- Write-back 写回
- 只写缓存,并标记缓存行被修改
- 被替换时(缓存满,但又需要新数据时,就旧数据需要被换出)
写 miss
当写入的数据不存在于缓存中时,如何处理?应用化归法,可以直接把数据加载到缓存中,问题就由写入策略来解决,这种方案称为写分配。
- Write-allocate 写分配
- 将整行从内存中拉入缓存,再修改缓存
- No-Write-allocate 非写分配
- 直接写内存
策略组合
| 写策略组合 | 效果 | 频率 |
|---|---|---|
| 直写 + 非写分配 | 简单,但速度慢 | 旧CPU |
| 直写 + 写分配 | 两次访问内存 | 几乎不用 |
| 写回 + 写分配 | 主流方案 | 常用 |
| 写回 + 非写分配 | 不用 | 不用 |
实际上对 CPU 来说,写操作的指令发出后,针整个对流水线来说,数据就已经变更,事实上是不需要再花费额外的时间去缓存中读写的,缓存只是暂存数据的一个区域,因此现代
CPU 为了不暂停流水线,会把写操作再次“缓存”下来,这一层写缓存被称为 Store Buffer,它比 L1 缓存还要再往前。
mov [x], 1 ; 写操作
mov rax, 5 ; 后续指令
若没有 Store Buffer 时,mov rax,5 需要在 mov [x], 1 执行完毕至少是写入到 L1 后才能执行。
为了避免写后读会因为 Store Buffer 导致读取到旧值的情况,CPU 做了 store-to-load forwarding,该机制允许直接从 Store Buffer
读取已更新但尚未写入缓存、内存中的数据。
CPUs
到目前为止,我们描述的都是单核单线程的场景,理论上讲,只要处理速度足够快,就可以解决任意计算问题。
但客观世界是有边界的,计算的代价是功耗和散热,随着摩尔定律发力,晶体管数量飙升,CPU
频率很快撞上了功耗与散热的天花板。
尝试加深流水线提升吞吐量,往往会因为流水线冒险失败清空流水线的损失而抵消;功耗伴随频率呈指数级上升,流水线加深也无法换来可观收益,晶体管数量年年增长,单核的能效比的边际效应凸显,而消费市场对更强、更快的
CPU 的需求也逐年递增,在权衡诸多方面后,多核作为更现实的工程方案进入了人们的视野。
多核带来了性能总量的增长和吞吐量的提升,但这一切并非简单的在主板上多放几个 CPU
就能够完成的。因为多核是基于单核的拓展,它必须兼容单核时代的软件与生态,而这些软件与生态建立在典型的冯诺伊曼结构上:
- 数据与指令存储在同一内存
- CPU 和内存分离通过总线连接
而在本文到目前为止的部分中,能够看出该架构的现实工程实现有如下特点
- 只有一个执行流
- 只有一个CPU
- 指令顺序执行(as-if-serial)
- CPU 与内存的速度差距依靠缓存来缓解
多核的引入对上述特点做了很大程度的打破
- 不止一个执行流,每核至少一个
- 不止一个 CPU
- 每个 CPU 都有缓存
可见在多核的场景下,存在多个计算单元用于执行线程,因此多核时代下就有了与上文中“伪并行”相对的真并行
,即CPU中可以同时执行多个线程。需要注意的是,由于伪并行是CPU的固有特性,因此伪并行与真并行是同时存在的。
线程调度
单线程时代,由于CPU只有一个计算单元,“线程在哪个CPU上执行”是不言自明的,而多线程时代,这个问题不再显然。由于 CPU
使用缓存来缩减访问内存的速度差异,而多核时代每个 CPU 都存在缓存,缓存的数据是在当前 CPU
上执行的线程需要的数据,由于伪并行的存在,线程不可避免地会停顿,如果此时线程被调度到别的 CPU 上执行,则被调度到的 CPU
需要访问内存来刷新缓存,当前 CPU 的缓存也会因为该线程已经在其他 CPU 上执行而失效,这会极大影响线程的执行速度。
因此操作系统需要承担分配 CPU 的责任,它必须实时判断线程是否就绪,CPU 是否空闲,将 CPU
分配给哪个线程等等。在没有优先级设定的前提下,操作系统必须兼顾公平、速度和整体吞吐量。同时,为了解决上面线程在不同 CPU
上执行导致的执行速度下降,操作系统会倾向于将线程分配到运行过它的 CPU 上,这种倾向性就是所谓CPU 亲和性
。当然,在真正的调度算法设计中,这种亲和性的是综合考量的一部分,并非绝对的准则。
缓存一致性
多核 CPU
各自拥有缓存,每个核心在同一时刻运行不同的线程,而这些线程可能属于同一进程,因此就有可能都在读写同一个变量,那么内存中的同一个变量就可能会有多个副本存在于多核的缓存中。
问题紧接着就出现了:针对该变量的写,谁的值是真的?如果一个核心写了一个新值,但另一个核心仍然在使用旧值计算,那么程序的行为就会不可知。
因此必须存在一种机制,使得在多核场景下,缓存的数据需要保持一致,这样在多线程下,程序的行为才具备正确性。约束缓存间保持一致的协议,称为缓存一致性协议。
我们可以简单理解为,缓存一致性协议用于描述给定任意缓存中的某个缓存行与内存、其他缓存间的关系。
由于内存永远作为最终权威的数据存储位置,因此缓存与内存的关系为一致性;由于针对同一个缓存行,多核缓存可能同时缓存也可能不同时缓存,因此缓存间的关系为
共享性。
一致性存在如下状态
- 不存在:系统初始状态,未缓存某个缓存行
- 一致:已缓存,只读;或未写
- 不一致:缓存写,但未写回
共享性则存在如下状态
- 无缓存:当前缓存不持有该缓存行/该缓存行失效
- 不共享:当前缓存唯一持有该缓存行
- 共享:多缓存同时持有该缓存行
由此,基于某缓存行在任意缓存中的一致性与共享性,可以列出如下表格(每个组合代表任意缓存中的某个缓存行)
| 状态 | 不存在 | 一致 | 不一致 |
|---|---|---|---|
| 无缓存 | 初始/不可用 | 非法 | 非法 |
| 共享 | 非法 | 多个缓存持有该缓存行,且均与内存一致 | 多个缓存持有该缓存行,且只有当前缓存与内存不一致 |
| 不共享 | 非法 | 只当前有缓存持有该缓存行,且与内存一致 | 只当前有缓存持有该缓存行,且与内存不一致 |
由于内存与缓存为一对多;缓存与缓存为多对多,因此一致性与共享性的映射关系会非常庞杂,尽管客观上只需要关注某个缓存行的状态,但在具体实现中会存在诸多瞬间状态,因此上述表格仅作近似性描述。
上述中,“非法”是指这种场景的存在违背客观规律,该组合不会发生。而上述表格中 共享-不一致
的场景实际上也是非法的,因为它就是“没有保持缓存一致”的现状,因此该表格需要更新如下
| 状态 | 不存在 | 一致 | 不一致 |
|---|---|---|---|
| 无缓存 | 初始/不可用 | 非法 | 非法 |
| 共享 | 非法 | 多个缓存持有该缓存行,且均与内存一致 | 非法 |
| 不共享 | 非法 | 只当前有缓存持有该缓存行,且与内存一致 | 只当前有缓存持有该缓存行,且与内存不一致 |
这样我们只需要处理 不共享-不一致 的场景,由于只有当前缓存与内存不一致,因此只要简单将当前缓存写回内存,就可以直接实现了缓存一致性。
MESI 协议
MESI
协议是目前使用最广泛的缓存一致性协议,它通过给每个缓存行添加两个比特位(四种组合)来描述当前缓存行的状态:
- Modified (已修改 - M):
- 缓存行数据已被修改,且与主存中的数据不一致(“脏”数据)。
- 该缓存行仅存在于当前 CPU 的缓存中(独占)。
- 在数据被写回主存之前,其他任何试图读取该内存地址的操作都必须得到该缓存的响应,并将数据写回主存。
- Exclusive (独占 - E):
- 缓存行数据与主存中的数据一致(“干净”数据)。
- 该缓存行仅存在于当前 CPU 的缓存中(独占)。
- 由于是独占,本地写操作不需要通知其他核心,可以直接将状态变为 M。
- Shared (共享 - S):
- 缓存行数据与主存中的数据一致(“干净”数据)。
- 该缓存行的副本可能存在于一个或多个其他 CPU 的缓存中。
- 本地写操作需要向总线广播一个使缓存行失效(Invalidate)的信号,将其他核心的副本状态变为 I,然后将自己的状态变为 M。
- Invalid (已失效 - I):
- 缓存行数据无效,不可用。
- 当 CPU 需要使用该缓存行数据时,必须从主存或其他核心的缓存中重新获取
基于简介原则,本文并未出现总线的介绍,但在 MESI 协议处,
总线嗅探是多个独立 CPU
核心自治性维护共享内存数据、更新自身缓存状态的关键机制,无法忽略。但就整体而言,总线可以理解为 CPU 内部、CPU
与内存进行数据交换的硬件基础。
综合当前存在的 MESI 协议我们可以更新表格为
| 状态 | 不存在 | 一致 | 不一致 |
|---|---|---|---|
| 无缓存 | I | I | 非法 |
| 共享 | 非法 | S | 非法 |
| 不共享 | 非法 | E | M |
作用
有了缓存一致性协议,多核 CPU 在各自拥有独立缓存的前提下,对同一内存地址的读写行为一致、可见、线性化,不会产生多个版本的值,但并不保证除此之外的任何行为。更精简一点说,MESI
保证了写完后数据全局可见,但不保证写的是对的,
或者说不保证写的结果符合符合程序的as-if-serial的语义(因为硬件范畴,并无程序语义)。
假设存在两个核 Core0、Core1,缓存变量初始值为A,两个核同时发起了对某一缓存行的写操作,Core0 写 B,Core1 写 C。
在 T0 时刻,两个核同时从内存中加载数据,缓存行状态均为 S
T0: Data A
Core0 store B
Core1 store C
T1 时刻,Core0、Core1 计算完毕,将写指令存放于 Store Buffer,并同时发起写缓存请求,经总线仲裁 Core0 可以写,Core0 的缓存行由 S
-> M,Core1 的缓存行由 S -> I,
随后 Core0 写入,变量值为 B,
T1: Data B
Core0 other
Core1 store C
T2 时刻,Core1 的 Store Buffer 请求写,缓存行 I -> M,Core0 失去 M 状态,发起 write back,将数据写回内存(或将值转给竞争到 M
的核),缓存行状态 M -> I,
随后 Core1 写入,变量值为 C。
T1: Data C
Core0 other
Core1 other
继续其他操作。
上面的场景中, MESI 或者说 CPU ,提供了完全正确的行为,保证了写入的可见性与一致性。但对程序编写者来说,这还是有点不太够。如果程序员认为
A、B、C 存在先后的次序关系,
共享变量 value = A
线程 1:value = B
线程 2:value = C
...
那结果自然是没有任何问题的,但如果在 Core0 与 Core1 竞争时,总线裁定 Core1 先写,写入顺序就变成了
A、C、B。这对 CPU 来说不会有任何问题,但对程序员来说,这就是出了 Bug。
这就是我们上文提到的线程安全问题
多线程的引入不是没有代价的,代价就是线程安全。由于线程共享进程的内存空间,因此就存在一些共享数据,针对共享数据的不同访问顺序,就可能导致线程安全问题。
多个线程同时读写同一份可变数据,导致程序的执行结果不可预测、不可重复或错误,这被称为 竞态条件 Race Condition。
编程语言引入多线程就出现了线程安全问题,现代 CPU 的多核多线程又进一步加剧了线程安全的复杂度,虽然使用 MESI 协议保证了缓存的一致性,但
MESI 无法解决竞态条件。多线程编程的客观需求和多线程并行的性能提升都是实打实的,工程师必须提供线程安全的多线程编程方案。
多线程编程
由于代码是人来编写,人来读,人来维护的,因此它必须符合人类的思维方式。而人类的思维方式就是线性 Serial
的,因此代码也必须是线性的,于是最开始出现的就是单线程。
在单线程模型中,程序的每一行代码都按照出现的先后顺序依次执行,上一行执行的结果对当前行可见,当前行执行是一次性的,下一行不会在当前行之前执行。
在这种顺序下,程序的运行被抽象为一条从上到下的直线,开发者可以依据这种线性、可推理的因果关系来理解和验证程序的行为。
单线程的程序编写很直观,但我们也知道,如果执行时严格按照单线程的顺序执行,CPU 吞吐量会下降,性能损耗完全无法接受,因此现代
CPU 都会采用各种性能优化策略,如乱序执行、分支预测等,只要保证执行的结果符合线性的语义就可以了。
- 上一行写入能被当前行看见
- 当前行的执行是不可分割的
- 下一行不会在当前行之前执行
我们可以进一步将其归纳为
- 可见性
- 原子性
- 有序性
从编译器到CPU,无论采用什么优化手段,只要对外表现出没有破坏可见性、原子性、有序性,程序的执行结果就与线性模型保持一致,实现了性能提升的同时不改变语义。
线程安全
多核多线程自然是优化程序性能的手段之一,在面对单线程或多线程无共享变量时,可见性、原子性和有序性由线程本身保证,而多线程含共享变量时就会发生线程安全问题。
- 原子性
这是一个非常经典的描述原子性被打破导致线程安全问题的案例
共享变量 value = 0
线程1 value++
线程2 value++
两个线程,分别执行一次自增操作,按照线性的顺序,最终 value=2。但实际上,value 的最终值也可能是 1。这是因为 value++
虽然看起来只有一行,但实际上对应 CPU 的三个指令:加载、计算、写回。因此实际上代码展开是这样的(temp 为线程内部临时变量,非共享变量)
共享变量 value = 0
线程1:
temp = value
temp = temp + 1
value = temp
线程2:
temp = value
temp = temp + 1
value = temp
可以看到原本value++ 被分解为三个步骤,线程之间可以在这三个步骤间任意交错执行,导致最终结果与期望不一致。
另外,这种交错顺序,也包括 CPU 时间片交错,因此在单核单线程的 CPU 上这种场景也会发生。
只有保证了原子性,读写操作不会被打断,单行语义才能保证正确。
- 可见性
MESI 协议保证了共享变量在线程间的可见性,但这种可见性是硬件兜底的最终可见,并不保证可见的时机:
共享变量 flag = false
线程1: flag = true // 写入(但未立即刷新到其他核心可见)
线程2: while (flag == false): // 一直读缓存中的旧值
由于线程1 延迟写入(如写操作暂存于 Store Buffer 中),线程2 会一直读取缓存,造成无意义的性能损耗。
- 有序性
一方面存在竞态条件的场景下,针对共享变量的写入是无序的。另一方面,线程本身的运行顺序也可能是交错的,如上文 MESI
协议章节中针对共享变量的写入,写入本身是原子的,线程间对变量的写入也是可见的,但结果仍然不是正确的。
可见性、原子性和有序性相互之间并非绝对的并列关系,而是存在诸多交叉,在工程实践中往往需要相互组合来实现其基本的语义。它们共同组合来确保了这样一件事:多线程间对共享变量的所有读写,都能够形成一条线性的因果关系。
这个因果关系不是天然存在的,而是硬件发展与编程语言设计相互作用得到的。多核时代,CPU
在原有的指令与存储的能力上又增加了用于跨核心协作的机制,而语言设计者们则基于这些机制构建规则,实现清晰、可用的多线程支持。
CPU 的硬件机制
由于使用不同指令集的 CPU 机制不同,CPU 不再是通用的模型。本节仅介绍 x86 CPU
提供的关键机制,考虑到篇幅和复杂度方面的问题,本节并不关注诸如字撕裂等方面的解决方案。
CPU 为了维护顶层软件最低限度的可用性提供了一系列机制,这些机制被称为并发原语
- 原子读-改-写指令 Atomic Read-Modify-Write,RMW
- 将读-改-写合并为一步
- 内存模型 Memory Model
- 确定对内存读写的可见顺序
- 内存屏障 Memory Fence / Barrier
- 禁止 CPU 重排屏障前后的指令
RMW
现代 CPU 采用 cache line 独占锁定的方式,结合 MESI 协议实现原子的读改写操作。需要注意的是,由于 CPU 存在 Store
Buffer,写操作的发起可能会被延迟执行,
为了避免这个问题,RMW 采用专门的写通路来实现直写,不经过 Store Buffer,将值直接写入到 L1 缓存,并传播到其他 CPU。
下面是一些典型的 RMW 指令(x86)
LOCK CMPXCHG [addr] reg- 如果内存值与reg 相等,将 reg 写入 addr
- 否则什么也不做
- 硬件的 Compare And Swap, CAS 操作
LOCK XADD [addr] reg- 将内存值 + reg
XCHG [addr] reg- 交换内存与 reg 的值
- 用于实现 spinlock 自旋锁(性能略差);简单互斥量
LOCK 只是指令前缀,
LOCK CMPXCHG是一个指令而非两个指令组合
addr:内存地址,[addr],代表指向该内存地址的值,也可以使用 *addr 表示
reg:寄存器,此处指寄存器内的值
CAS
利用硬件的 CAS 原语,可以构建编程语言层的同步原语
# 将 *addr 与 expected 比较,相等则写入 new
# 返回 true 表示写成功;false 表示写失败
atomic_cas(addr, expected, new):
atomically:
if *addr == expected:
*addr = new
return true
else:
return false
同步:多个执行流在某一点处对齐,使执行流的先后关系可推导。
同步原语:用于构建同步操作的指令、对象等。
基于比较、交换的同步操作,如果旧值没变,就写入,如果变了,本次操作失败,重新再来。这种“乐观”的策略,不阻塞 CPU,不锁住任何对象,符合
CPU 的天然的执行语义。又因为它做的事情
仅仅是比较、交换,对于新旧值的定义或程序逻辑毫无定义,给了用户极大的自由:可以借助这一点来实现任意的原子指令。
因此现代编程语言几乎都将其作为自身实现同步原语的首选
- Java
Atomic* - C++
std::atomic - Go
atomic.CompareAndSwap* - Rust
Atomic*::compare_exchange
自旋锁
当多个线程需要进入同一段包含共享数据的代码块(称为临界区)时,必须避免发生线程安全问题。传统的解决方案是互斥锁,先来的线程进入临界区后将临界区锁住,后来的线程发现临界区被锁,操作系统就将其“挂起”,等待后续调度,挂起会涉及缓存切换、时间片浪费等,当临界区过短时,这种资源浪费不可接受。
因此另一个方案是将锁改为一个原子的交换操作,具体流程是这样的:
- 抢锁:设置为 1,同时得到旧值
- 如果旧值是 0 → 抢锁成功
- 如果旧值是 1 → 锁已被占用 → 回到抢锁
这被称为自旋锁
// lock = 0: unlocked
// lock = 1: locked
spin_lock(lock):
r = 1
while (XCHG(lock, r) == 1):
pause
spin_unlock(lock):
lock = 0
使用 XCHG 指令可以原子地进行交换操作,自旋锁可以显著提升性能,它的等待时长跟线程切换导致的性能损失比起来收益显著。
但回看上面的逻辑,依然存在问题,XCHG 实现的自旋锁,先将值设置为 1
,然后再依据旧值判断是否抢锁成功,也就是说,无论锁是否抢成功,都会进行一次内存的写入,我们知道内存的写入是很昂贵的。
那么能否先判断能否抢锁,如果能抢,抢锁;如果不能,重新再抢?这里透漏着一股 CAS 的气息,我们可以使用 CAS 来实现更高性能的自旋锁
spin_lock(lock):
while (true):
if (atomic_cas(lock, 0, 1)):
return
pause
spin_unlock(lock):
lock = 0
虽然 CAS 性能更进一步提升,但它并不能解决所有问题。上面我们提到自旋锁适用于临界区较短的场景,同时线程数也不宜过多。如果临界区长、线程数过多,那么自旋锁等待的线程会不断占用
CPU 时间,原本避免线程
挂起节省的时间又会因为线程无意义的自旋浪费掉,因此互斥锁依然有它的使用场景,即使它看起来“很重”。
Java 的 synchronized 关键字用于标定临界区,在最开始只有互斥锁,但由于互斥锁性能不佳,因此早期很多人都在吐槽它慢。后面随着条件成熟,JVM
对 synchronized 做了多级的优化。
最开始通过 JVM 的锁竞争分析发现,很多锁在全生命周期都只有一个线程持有它,并且完全没有其他线程竞争。如果可以断定全生命周期不会存在,JIT
会消除锁,如果还是存在潜在的锁竞争关系,Java 会采取由轻到重的锁升级策略
- 偏向锁:认为不存在竞争,仅记录持有锁的线程,每次进入临界区都会对比是否为持有锁的线程,若是直接放行,否则升级为 CAS
- 乐观锁:CAS + 自旋
- 互斥锁:竞争激烈时,升级为互斥锁,避免大量占用资源
内存模型
需要说明的是,这里的内存模型其实并非望文生义的诸如“内存结构”之类的东西,它实际上描述的是
In computing, a memory model describes the interactions of threads through memory and their shared use of the data.
在计算机领域,内存模型(Memory Model)描述了线程之间如何通过内存进行交互以及它们共享数据的使用方式。
对于程序来说,给变量赋值(Store)与取变量值(Load)是存在着线性关系的,如
// 共享变量
int X = 0;
int Y = 0;
int r1 = 0;
int r2 = 0;
// 线程 1
Thread1() {
X = 1; // Store
r1 = Y; // Load
}
// 线程 2
Thread2() {
Y = 1; // Store
r2 = X; // Load
}
程序的线性关系是这样的,以线程1 为例
- Store X
- Load Y
- Store r1
按照线性关系不可能出现 r1 == 0 && r2 == 0 的场景(可以依据线性执行顺序写代码或头脑风暴验证),但在 x86 平台执行时,会出现
r1 == 0 && r2 == 0的情况。这是因为 x86 平台采用 Total Store Order, TSO 内存模型。
TSO
TSO
是在单核视角下,对内存的读(Load)、写(Store)顺序,以程序编写的线性顺序为基准,进行的重排序约定,TSO 保证了很大程度上的线性关系。
需要注意的是,这里描述的读与写,为无差别的内存访问,并非针对某个具体变量的读写。
以线性序来看,读写序有如下组合,这也是全局的内存可见的顺序
- Load-Load
- 读后读
- Store-Store
- 写后写
- Load-Store
- 读后写
- Store-Load
- 写后读
在 CPU 内部,读操作读到的值有两个来源:
- 利用 store-to-load forwarding 从自身的 Store Buffer 中读
- 从缓存/内存中读取
而读取到的值具体来自哪里,取决于哪个地址最新被变更过,如果变量的写指令在 Store Buffer,尚未写到缓存中,没有触发 MESI
协议,则该写操作被视为未完成,而读操作已完成,于是这被视为 Store-Load 重排序,这也是 TSO 唯一允许的重排序场景,因为它并没有破坏线性的语义,
保证了as-if-serial。其余的操作或因为硬件限制,或因为逻辑一致,不被 TSO 允许。
于是上文中 r1 == 0 && r2 == 0 在 CPU 内部为线程1、线程2 同时执行
- 线程1 的写操作
X = 1,在当前核心的 Store Buffer 中,未完成,其他核心不可见 - 线程2 的写操作
Y = 1,在当前核心的 Store Buffer 中,未完成,其他核心不可见 - 由于
2.未完成,线程1 的读操作从内存中加载到“旧值”0 - 由于
1.未完成,线程2 的读操作从内存中加载到“旧值”0
虽然 x86 的 TSO 内存模型极大限制了重排,属于是强模型,而诸如 ARM 的 CPU 中,读写可以任意重排,属于弱模型。而无论强弱都不能保证针对共享变量的读写是同步的(其他核立刻可见)。
// 共享变量
int X = 0;
int Y = 0;
int r1 = 0;
int r2 = 0;
// 线程 1
Thread1() {
X = 1; // Store
r1 = Y; // Load
}
// 线程 2
Thread2() {
Y = 1; // Store
r2 = X; // Load
}
在这段伪代码中,出现 r1 == 0 && r2 == 0 是因为写入延迟了,那么如果能够保证线程1 与线程2 的写入立刻对所有核心可见,就能够符合线性的语义,针对
CPU 内存模型对线性语义的破坏(在 TSO 中只有 Store-Load 重排序),需要引入内存屏障来强制刷新 Store Buffer,并禁止Store-Load
重排,从而恢复线性的执行流程。
内存屏障
内存屏障用于给 CPU 的读写序重排添加屏障点,禁止乱序行为的发生。在 x86 下有如下内存屏障
- MFENCE
- Full Fence:禁止所有内存重排
- SFENCE
- Store Fence: 禁止 Store→Store 重排(TSO 本来就禁止);不 flush store buffer
- LFENCE
- Load Fence: 禁止 Load→Load 重排(TSO 本来就禁止)
其中真正用于同步操作的只有 MFENCE,其他架构的 CPU 会存在更多相关屏障用于禁止重排序。
应用该屏障改写 r1 == 0 && r2 == 0 的代码
// 共享变量
int X = 0;
int Y = 0;
int r1 = 0;
int r2 = 0;
// full fence: 刷新 store buffer,并禁止所有乱序
void FullFence() {
// x86 对应 mfence
}
// 线程 1
Thread1() {
X = 1; // Store(进入 store buffer)
FullFence(); // 强制 X=1 对其他核心立即可见 + 禁止重排
r1 = Y; // Load(不会被移动到 Store 之前)
}
// 线程 2
Thread2() {
Y = 1; // Store(进入 store buffer)
FullFence(); // 强制 Y=1 对其他核心立即可见 + 禁止重排
r2 = X; // Load(不会被移动到 Store 之前)
}
这样一来,每个线程的读写对另一个线程都是立即可见的,但由于两个线程在时空上交错执行,即使读写可见,这段代码依然会产生三种合法输出
r1 == 0 && r2 == 1r1 == 1 && r2 == 0r1 == 1 && r2 == 1
至于具体是哪一种,需要程序员实现时,再添加额外的约定机制。
内存屏障的原理是 Flush Store Buffer,让写直接可见,而上面提到的一些原子操作也有这样的功能,因此原子操作本身也可作为内存屏障使用,比如我们期望上面线程稳定输出
r1 == 0 && r2 == 1,这需要线程1 必须在线程2 之前执行完毕,利用原子操作的 CAS 来做如下设定(&flag 是获取变量 flag
内存地址的操作)
int X = 0
int Y = 0
int r1 = 0
int r2 = 0
int flag = 0
boolean atomic_cas(addr, expected, new)
# Thread1
Thread1():
# 抢占状态,从 0 → 1
while (!atomic_cas(&flag, 0, 1)):
# 自旋等待
r1 = X # 必定是 0
Y = 1
# 完成阶段,从 1 → 2
atomic_cas(&flag, 1, 2)
# Thread2
Thread2():
# 等待 Thread1 完成,从 2 → 3
while (!atomic_cas(&flag, 2, 3)):
# 自旋等待
X = 1
r2 = Y # 必定是 1
JMM
上面我们描述了硬件如何保证正确性的As-If-Serial
语义。但在最后我们也看到当线程间包含共享变量,且多线程在时空上交错执行时,必须使用额外的手段才能保证程序运行结果稳定、可推测。
可见硬件并不能天然保证As-If-Serial,在多线程且共享变量
场景下并不会让代码表现可控,必须使用特定指令,手动控制变量的可见性,比如让执行停顿,让线程自旋等待。但硬件相关的指令危险不说,还非常繁杂,上文中介绍的仅仅是一些屏蔽了巨量细节下的高层次抽象。如果将所有实现细节均纳入解决方案,由程序员手动维护共享变量的可见性、同步、CAS
等操作,那么可移植性、健壮性、可维护性都将不可接受。
因此诸多高级语言都实现了其自身的内存模型,这里以 JMM 为例,介绍一下 Java 的解决方案。
这里开始需要对Java的一些基本概念有一定的了解,比如Java 虚拟机(JVM)、实例对象、静态字段、方法、形参等等
全序与偏序
Java 为了形式化描述它的内存模型,必须保证程序的行为可定义、可推导,因此Java官方采用了一些数学工具去描述,其中核心的为
序理论 Order Theory
。序理论研究的对象为“先后”、“大小”这种直觉观念,并通过一套严密的规则来将这些直觉定义出来。在本文中,我们关注的是先后
,这种序,那么针对任意两个事物<A,B>,就必然会存在如下先后关系之一
- A 在 B 前;
- B 在 A 前;
- A,B 没有关系。
其中第三条似乎有些不好理解,举个例子来说,烧开水和泡泡面,是具备显然的先后关系的,但无论是烧开水还是泡泡面,都跟楼下路过一辆电瓶车没有关系。
为什么 “A,B 同时发生” 不在讨论范围之内呢? 因为 “同时” 太模糊了,它本身就受限于精度和观察,无法上升到理论的层级。
至于 A,B 没有关系,是指他们不存在比较关系,至于它们真实发生的时机是否存在先后,是否同时,对各自的结果没有影响,因此也就不存在序。
于是我们从这四种关系中,抽象出两种描述先后的方式
- 全序
- 在一组事物中,任意两个都能明确比较前后,没有例外。
- 偏序
- 在一组事物中,部分存在先后关系,其他的不强行排序。
Java Memory Model
与 CPU 内存模型类似 Java 内存模型描述的也是线程间的读写规则。Java
内存模型的实现是检查执行过程中每一次读取,并基于确定的规则来验证当前读取的是否合法。
The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as
long as all resulting executions of a program produce a result that can be predicted by the memory model.
内存模型描述了程序的可能行为。内存模型的实现可以自由生成和执行代码,只要其结果可被内存模型推导。-- Java language
specification 21
我们前面提到,程序的本质是指令和数据,Java 程序也是一样,指令和数据由进程管理,存放在内存中,而线程
是执行指令、修改数据的实体。上面我们谈到线程间存在共享变量时,就可能导致线程安全问题,内存模型很大程度上就是为了解决共享变量的可见性问题。所以必须对共享变量进行一次明确的认知。
共享变量
能够被多个线程同时访问的内存,称为共享内存,或者也被称为堆内存 Heap Memory。Java
运行时的实例对象(包括字段、静态字段)、数组元素等,都在堆内存中,这些东西被统一称为变量。线程执行过程中的局部变量、方法的形参等等属于线程私有,˚不会在线程间可见,因此也不受内存模型影响。
针对共享变量的两次访问(读或写),且至少有一次是读时,那么这两次访问被认为是冲突的。
这里描述了诸多 Java 文档中都会出现的
堆,这里的堆实际上并不指代任何具体 JVM
实现,它是一种概念上、用来存放共享数据的一块内存空间。至于这块空间的实现,可以是任意的。JMM 不关注指令,只关注数据。
若无特殊说明,后文中的数据、变量等均为多线程共享的。我们知道内存模型会检查执行过程中的读取,这种“检查”实际是线程间对共享变量的操作是可见的,比如说线程1
与线程2 共享变量 A,线程1 修改变量 A,这个操作对线程2 是可见、可影响、可干扰的。
这种跨线程的操作,就是Java 内存模型关注的线程间行为。
行为
线程间行为,指一个线程做了某件事,另一个线程可能察觉到,或直接受它影响。
JMM 将这些行为分为几类:普通读写、同步动作、线程启动或终止、可被外部环境观察的动作、线程发散行为。具体的行为如下
- 普通读写
- 读:线程读取一个变量的值
- 写:线程修改一个变量的值
- 同步动作
- volatile 读:读取一个 volatile 变量,保证读到最新值,形成可见顺序
- volatile 写:写入一个 volatile 变量,使写入对其它线程可见
- lock:获取对象监视器,用于进入临界区
- unlock:释放对象监视器,其他线程可以进入临界区
- 线程的第一个和最后一个动作:标记线程开始与结束,作为顺序参考
- 启动线程或检测线程结束
- 外部操作
- 对外部世界可观察到行为,比如输出文件,发起网络连接等,可被其他系统线程感知到
- 线程发散行为
- 该线程不再对其他线程产生任何可观察的影响,比如进入了死循环,且循环中无上述任何行为
volatile 读/写:指 Java 由 volatile 关键字修饰的变量,它的读写由内存屏障保证线程间可见性,但不保证变量的原子性,因此无法避免竞态条件,也就不保证线程安全,如
static volatile int value = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) { value++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) { value++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(value); }这段代码多执行几次就会必然出现
value值小于 200000 的情况,这就是我们上文提到的仅有可见性但缺失原子性导致线程安全问题的具体代码。lock/unlock 操作:每一个 Java 对象头都有一个唯一的监视器 monitor,当线程期望访问临界区时,会尝试获取监视器,获取成功则会安全访问临界区,称为
lock,相应的释放监视器称为 unlock。英文为
action可翻译为行为、操作,本文中的行为、操作很大程度上是同义词,仅为通读连贯,会交替使用。
线程执行序
在现实中,线程会交错执行、编译器会重排、CPU 会乱序、缓存会延迟可见,如果没有可推导的规则,我们无法得知当前读到的值是否被修改过,也就出现了线程安全问题。
对 JMM 来说,没有规则但存在共享变量,那么所有执行结果都是合法的,就如上文中那个 value++
的例子,即使结果完全是不可预测的,但这种“不可预测”也被视为程序的功能,而非bug。
而为了多线程行为可推导,JMM 提供了 happens-before 来约束变量的可见性和访问逻辑。happens-before 由 JMM 约定,JVM/CPU
遵守,程序员使用同步原语来建立 happens-before 关系。
happens-before 由程序顺序、同步顺序共同构建。
程序顺序
程序顺序是线程内的语法顺序,也就是代码编写的顺序,自上而下执行,JMM 保证了单线程内程序顺序与执行顺序一致,也就是我们一直在提的
as-if-serial 语义。
直觉上理解多线程的执行是顺序一致的。一组操作是 顺序一致 sequentially consistent,当且仅当所有操作按某个全序关系(即执行顺序,execution
order)发生,且该执行顺序满足以下两个条件:
- 与每个线程的程序顺序一致;
- 对变量
v每一次读取r,都能看到某个对v的写入w产生的值,且满足:- 在执行顺序中,
w位于r之前; - 不存在其他对v的写入
w',使得在执行顺序中w位于w'之前,且w'位于r之前(即w是r之前最近的一次写入)。
- 在执行顺序中,
直观理解就是把所有线程的执行全部都排序,线程会依据定义顺序逐个执行,每个操作都是原子的,并且对所有线程同时可见。
显然顺序一致对 CPU、编译器等执行优化操作存在极大限制,性能会下降到无法接受的程度,因此顺序一致
仅作为程序员对多线程执行的直观建模,并不被工程现实接受。
同步顺序
我们之前描述过同步是指多个执行流在某一点处对齐,使执行流的先后关系可推导。每次执行都存在一个同步顺序 synchronization
order,同步顺序就是对所有的同步操作的一个全序关系,对任意一个线程来说,同步操作的顺序,与程序顺序一致,即同步操作自上而下逐个发生。
同步顺序引入了 同步于 synchronized-with 关系,它的定义如下
- 对监视器
m的解锁操作,同步于后续 subsequent所有对m的加锁操作; - 对
volatile变量v的写入操作,同步于任意线程后续 subsequent所有对v的读取操作; - 启动线程的操作,同步于该线程启动后的第一个操作;
- 对每个变量的默认值(零值、
false或null)写入操作,同步于每个线程的第一个操作; - 线程
T1中的最后一个操作,同步于另一个线程T2中所有检测到T1已终止的操作,
T2可通过调用T1.isAlive()或T1.join()实现该检测; - 若线程
T1中断线程T2,则T1的中断操作,同步于任意其他线程(包括T2)中所有确定T2已被中断的时刻(通过抛出
InterruptedException、调用Thread.interrupted()或Thread.isInterrupted()实现)。
同步于 synchronized-with 关系的源头操作称为 释放 release,目标操作称为获取 acquire。
这里的 release 与 acquire 实际上是建立可见性的最小因果关系,它并不局限于 JMM,而是广泛存在于任意场景下的并发设计中,比如
C/C++ 内存模型,操作系统并发理论的 publish(release)和 consume(acquire)。release 表示 我做完了一件事,这件事的结果可被看到
,acquire 表示 我看到了,于是我会依赖它。
需要说明的是
后续 subsequent并非指的是线程执行的前后,// Thread A synchronized (m) { // lock(m) x = 1; } // unlock(m) // Thread B synchronized (m) { // lock(m) y = x; }这里线程A 与线程B 可以任意交错执行,但解锁操作会发生在加锁之前,解锁-加锁与线程的交错、调度等无关。volatile 变量的写入-读取同理。
happens-before
happens-before 是一种逻辑上的偏序关系,定义了行为的可见性。如果一个行为x happend-before 另一个行为y
,则前者对后者可见,且在顺序上,在后者之前,记为hb(x,y)。
具体有如下场景
- 若
x和y是同一线程内的操作,且程序顺序中,x位于y之前,则hb(x,y); - 对象构造函数的结束操作,
happend-before该对象终结器的开始操作; - 若操作
x与后续操作y存在synchronizes-with关系,则hb(x,y); - 若
hb(x,y)且hb(y,z),则hb(x,z),也就是存在传递性。
需要注意的是,happend-before(以下简称hb) 描述的是一种逻辑关系,也就是说,现实中并不一定要按照顺序执行,hb
规则并不禁止任意重排序,只要保证结果一致即可,这就给单线程无共享变量的优化提供了
语义层面的依据。
若两个操作存在先行 hb,对于所有与这两个操作均无 hb
的代码而言,这两个操作未必会表现出该顺序。例如,一个线程中的写入操作与另一个线程中的读取操作存在数据竞争时,这些写入操作可能会相对于读取操作表现出乱序。这个例子可能不太好理解,下面以伪代码的形式来再次说明
线程1
x = 1; // A
x = 2; // B
由于单线程内,操作A 与操作B 存在hb
线程2
r1 = x
r2 = x
线程2 与线程1 不存在 hb,那么即使操作A 与操作B 是 hb 的,但对线程2 来说,读取到的值依然可能是无序的。这里就是数据竞争
Data Race:当程序中存在访问冲突,且没有 hb 时,称该程序存在数据竞争。数据竞争发生在未建立 hb 的场景下,因此任何
hb 保证的语义、结果,都无法消除数据竞争。也就是说,消除数据竞争需要对读写建立明确的 hb。
happens-before consistent
在 hb 的偏序中,若变量v的读取操作r满足以下条件,则称r允许观察到对v的写入操作w
- 不存在
hb(r,w)即rnon-happens-beforew; - 不存在对
v的中间写入操作w'
如果操作集合 A 满足:对于 A 中的所有读取操作 r(设 W(r) 为 r 观察到的写入操作),既不存在 hb(r, W(r)),也不存在
A 中对 r.v 的写入操作 w 使得 hb(W(r), w)且 hb(w, r),则称 A 是 先行发生一致 happens-before consistent 的。
这一串的定义描述了在 happens-before consistent 的操作集合中,每个读取操作都能观察到它被 hb 允许观察的写入操作。
上述定义很难理解,简单来说 happens-before consistent 是在声明:正确建立了 hb 后,重排序的执行需要保证读取 hb
语义。对程序员来说,就是正确使用 synchronized、volatile、Atom* 等关键字、类。
执行与良构执行
程序的一次执行 E 可以用一个元组描述<P,A,po,so,W,V,sw,hb>,组成如下
P- 一个程序
A- 一组操作
po- 程序顺序:对每个线程
t,po是对A中线程t执行的所有操作的全序关系
- 程序顺序:对每个线程
so- 同步顺序:对
A中所有同步操作的全序关系
- 同步顺序:对
W- 写可见函数:对
A中的每个读取操作r,W(r)返回执行E中r所观察到的写入操作
- 写可见函数:对
V- 值写入函数:对
A中的每个写入操作w,V(w)返回执行E中w所写入的值
- 值写入函数:对
swsynchronizes-with:对同步操作的偏序关系
hbhappens-before:对所有操作的偏序关系
sw与hb由其他组件和良构执行 well-formed executions 唯一确定。
如果该执行的 A 满足happens-before consistent,那么该执行 happens-before consistent(An execution is
happens-before consistent if its set of actions is happens-before consistent.)。
实际上执行描述的仅仅是数学上可能的组合,其中存在大量完全不可能出现的组合,我们并不知道哪些组合完全不可能出现,因此需要定义一个可能出现的组合的下限,
这个组合称为良构执行。
若执行 E = <P,A,po,so,W,V,sw,hb>,则称其为良构执行:
- 每个读取操作都能观察到对同一变量的写入操作
hb是偏序关系- 自反性:对任意操作
x,有hb(x, x); - 传递性:若
hb(x, y)且hb(y, z),则hb(x, z); - 反对称性:若
hb(x, y)且hb(y, x),则x = y(无循环依赖)。
- 自反性:对任意操作
- 执行遵循线程内一致性
- 对于每个线程
t,其在A中执行的操作,与该线程孤立运行时按程序顺序生成的操作完全一致
- 对于每个线程
- 执行满足
happens-before consistent- 每个读取操作仅观察到其被
hb允许的写入操作
- 每个读取操作仅观察到其被
- 执行遵循同步顺序
A中所有volatile的读不存在以下任一场景- 同步顺序中
r位于w(r)之前(读看到未来的写); A中存在写入操作w,使得w.v = r.v,且同步顺序中W(r)位于w之前、w位于r之前(**读跳过中间的写
**)。
- 同步顺序中
良构执行为 JMM 编写者们建立了统一的数学模型,方便后续的形式化推理,将程序执行抽象成可分析、可证明的对象。
但本文不会出现后续的集合运算等操作,本小结仅作介绍和一些简要澄清。
结语
全文单核单线程出发,基于硬件的发展,描绘了单线程到多线程编程,从 CPU 内存模型到 Java
内存模型,叙述了多层级的线程安全策略。
但限于篇幅和精力,对硬件原理和编程语言设计做了诸多简化,有兴趣的读者可自行寻找切入点查询详细资料。JMM 的部分绝大多数内容参考
The Java Language Specification - 21 的 第17章第四节 Memory Model,水平有限如有错漏欢迎指出。

浙公网安备 33010602011771号