并发编程 - 可见性、原子性和有序性问题:并发编程 Bug 的源头

并发编程 - 可见性、原子性和有序性问题:并发编程 Bug 的源头

并发编程之美系列目录:https://www.cnblogs.com/binarylei/p/9569428.html

本文是并发编程的第一篇,主要介绍为什么需要并发编程,同时分析并发编程问题的根本原因 - 可见性、原子性和有序性三个方面。下一章我们会继续分析 JVM 是如何解决这三个问题。

1. 并发程序幕后的故事

为什么要学习并发编程,我认为有以下原因:

  1. CPU、内存、硬盘等 I/O 设备,速度相差天上一天地上一年。
  2. 多核时代的到来,更要充分利用 CPU。
  3. 为了充分利用 CPU,引入了缓存、CPU 时间片、编译优化等机制。但这些技术在解决问题的同时,也带来了线程安全问题。所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

1.1 I/O 设备性能大比拼

这些年,我们的 CPU、内存、硬盘等I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。

  • CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年。
  • 内存和 I/O 设备的速度差异就更大:内存是天上一天,I/O 设备是地上十年。

说明: 从上图,我们可以清晰的看到,越靠近 CPU 访问速度越快,同时容量也越小。我们以 CPU 为 1 个单元来,那么常见的 I/O 设备速度如下(我们只需要关注不同 I/O 设备数量级的差异,不用关注绝对数值):

CPU、内存、硬盘等 I/O 设备性能对比图
IO 设备 延迟 带宽 容量
CPU 1 -- --
Cache(L1-L2-L3) 0.5~15ns 2~60GB/s 1M
内存 30~100ns 2~12GB/s 32GB~256GB
SSD 硬盘 10us~1ns 50MB~12GB/s > 1T
机械硬盘 5ms~2ms 50MB~200MB/s > 1T
网卡 100us~1ms 10MB~10GB/s --

1.2 可见性、原子性、有序性问题

CPU 执行往往需要访问内存,有些还要访问硬盘,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • 可见性CPU 增加了缓存,以均衡与内存的速度差异。但缓存也带来可见性问题。
  • 原子性操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异。但线程切换也带来原子性问题。
  • 有序性编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。但编译优化也带来有序性问题。

2. 可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。CPU 缓存导致了可见性问题,在 Java 中,提供了关键字 volatile 解决可见性问题。

首先,缓存虽然高效,但既然使用缓存就一定会存在缓存不一致的问题,如我们常使用的 Redis 缓存。同样的道理,CPU 缓存也会导致不一致问题,也就是可见性。我们先看一下 CPU 的架构,详见 CPU 体系结构之 cache 架构

说明: "CPU" 与 "Cache/内存" 交互过程如下:CPU 接收到指令后,先查找一级缓存(L1 Cache),然而由于缓存容量较小,所以不可能每次都命中。这时 CPU 会继续查找二级缓存,依此类推:L1 Cache -> L2 Cache -> L3 Cache -> RAM -> 硬盘

如果是单核时代,所有的指令都是运行在同一个 CPU 上,不会存在缓存不一致的问题。但到了多核心时代,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存。很明显,这个时候线程 A 对变量的操作对于线程 B 而言就不具备可见性了。

在以下示例中会可见性问题。

说明: 在这个示例中,因为线程 B 停止线程 A 时,直接调用 threadA.stop() 会出现死锁等问题。所以通常要停止线程,往往会用一个标记 running 标识线程是否在运行,当我们要停止线程时,调用 stop 方法。但线程 B 直接修改变量 running 后,可能并未立即将这个变量 running 同步到主存中,线程 A 也可能并未从主存中读取最新的 running 变量,这样就导致了可见性问题。

3. 原子性

一个或者多个操作在 CPU 执行的过程中不被中断的特性,我们称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。线程切换导致了原子性问题,在 Java 中,提供了关键字 synchronized 解决原子性问题。

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为 "任务切换"),这个 50 毫秒称为 "CPU 时间片"。

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候。我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。如 count++,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

还是以上面的 count++ 为例。两个线程 A 和 B 同时执行 count++,即便 count 使用 volatile 修辞,我们预期的结果值是 2,但实际可能是 1。

总结: 操作系统任务切换,可以发生在任何一条 CPU 指令执行完成后,而不是高级语言里的一条语句。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

4. 有序性

编译器为了优化性能,有时候会改变程序中语句的先后顺序。在 Java 中,提供了 happens-before 原则来解决有序性问题。

5. 总结

在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题。其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。


每天用心记录一点点。内容也许不重要,但习惯很重要!

posted on 2020-03-20 18:38  binarylei  阅读(544)  评论(0编辑  收藏  举报

导航