并发编程 可见性、原子性、有序性

本文将介绍并发编程中影响线程安全的三个原因:可见性问题、原子性问题、有序性问题。

一、可见性问题

1. 什么是可见性问题?

一个线程对共享变量的修改,另外一个线程不能立即看到。

CPU 的运算速度比内存的读写速度快得多,为了平衡 CPU 与内存之间的速度差距,硬件工程师们在 CPU 与内存之间增加了一个缓存层,它的工作方式如下:

  • 读:在 CPU 需要读取数据时,首先从缓存中查找,
    • 如果找到,直接返回
    • 如果没有找到,以相对慢的速度从内存中读取,存入缓存中,返回
  • 写:直接写缓存中的数据,缓存中的数据会待合适的时机刷回内存

在单核时代,所有的线程都是在一颗 CPU 上执行,因此操作的也是同一个缓存中的数据,因此不存在可见性问题;

在多核时间,每颗 CPU 都有各自独立的缓存,各个缓存之间无法相互访问,当不同的线程在不同的 CPU 中访问 “不同” 的同一份数据时,便会出现可见性问题。

2. 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Test {

private int count = 0;

private void add() {
int idx = 0;
while (idx++ < 100000) {
count += 1;
}
}

public static int calc() throws Exception {
final Test test = new Test();
Thread th1 = new Thread(test::add);
Thread th2 = new Thread(test::add);
th1.start();
th2.start();
th1.join();
th2.join();
return test.count;
}

public static void main(String[] args) throws Exception {
long c = calc();
System.out.println(c);
}

}

虽然两个线程都对同一个变量进行了 100000 次累加,但是结果并不会是想象中的 200000,而是个 100000 ~ 200000 的随机数。

在这段程序执行过程中,可能的情况有千万种,这里试着还原其中一种:

  • 线程 1 开始执行,读取内存中的值 0 至缓存,开始累加

  • 线程 1 累加至 13900,刷回内存

  • 线程 2 开始执行,读取内存中的值 13900 至缓存,开始累加

  • ···

  • 线程 1 累加至 24300,刷回内存

  • 线程 2 累加至 26090,刷回内存

  • ···

  • 线程 1 累加到 100000,刷回内存

  • 线程 2 累加到 113900,刷回内存

二、原子性问题

1. 什么是原子性问题?

命令并没有被原子性执行。

高级语言中的一条语句在执行时往往会拆分成多条 CPU 指令。每当一个进程的时间片结束以后,操作系统会做任务切换,将 CPU 的使用权交给其它的进程。操作系统会保证原子性操作,将任务切换放在 CPU 指令的间隔执行,但一条 CPU 指令可能一条高级语言命令的一部分,这将导致一条高级命令只执行一半便暂停执行。

2. 高级指令的拆分

1
2
3
4
5
6
7
高级语言:
count += 1

CPU 指令:
1. 将变量 count 加载到寄存器中
2. 对寄存器中的变量 count 执行 +1 操作
3. 将结果写回缓存

寄存器是 CPU 内部用来暂时存放数据的小型存储区域。CPU 需要数据时,会把数据从内存中读到缓存,再从缓存中读到寄存器,它们的关系是:CPU - 寄存器 - 缓存 - 内存

3. 操作系统的任务切换

时间片是指 CPU 分配给进程使用的一小段时间,CPU 会以时间片为单位轮番交叉地运行不同的进程,从而表现出 “同时” 运行多个进程的 “假象”。

早期的操作系统基于进程分配时间片、调度 CPU,现代的操作系统往往都基于更轻量的线程。

三、有序性问题

1. 什么是有序性问题?

程序并没有按照代码的先后顺序执行。

为了优化性能,处理器、编译器可能会改变程序中语句的先后执行顺序,这可能导致代码执行结果出现问题。

2. 示例

一段双重检查创建单例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {

static Singleton instance;

static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

这段代码做了这样的事:

  • 首先判断 instance 是否为空
  • 如果为空
    • 加锁,检查 instance 是否为空
      • 若还为空,创建 instance
  • 返回 instance

它的逻辑是:假如有多个线程同时调用 getInstance(),它们会发现 instance 为空,都会尝试加锁,这种情况下只会有一个线程能够获得锁,其它线程将进入等待。获取到锁的线程将创建实例,并在创建完成后释放锁,释放后,等待中的其它线程才能依次获取锁,当它们再看到 instance 时,便会发现它已经被创建成功。

看起来似乎无懈可击,可实际运行时便可能会出现问题。

代码 instance = new Singleton() 会被拆成三步执行:

  • 分配内存空间
  • 在内存空间中新建 Singleton 实例
  • 将 instance 的指针指向这片内存空间

由于处理器、编译器的优化,这三步可能会乱序执行,试想这样一种情况:

  • 线程 A 调用 getInstance()
  • 线程 A 第一次检查,判断 instance 为空
  • 线程 A 加锁;第二次检查,发现 instance 为空
  • 线程 A 创建 instance
    • 分配内存空间
    • 将 instance 的指针指向这片内存空间
  • 线程 B 调用 getInstance()
  • 线程 B 第一次检查,判断 instance 不为空,取走 instance
  • 线程 B 使用 instance,报错
  • 线程 A 创建 instance
    • 在内存空间中新建 Singleton 实例

参考