16_01_并发问题来源

并发问题来源

一、缓存带来的可见性问题

  • 计算机中可以保存数据的地方有几个:寄存器、缓存、主内存
  • 原始数据都保存在主存上,最初的读取和最终的写入,都是在主存上操作
  • CPU 与主存之间存在缓存,每个 CPU 都有自己独立的的缓存
  • 操作数据时,首先要将原始数据从主存中读取到 CPU 的缓存中,然后再对缓存的数据进行操作
  • 单 CPU 下,一个线程对缓存的修改,对于另一个线程来说是可见的,因为只有一个缓存
  • 多 CPU 下,线程可能运行在不同 CPU 上,操作的是各自不同的缓存,相互之间是不可见的

比如,主存中一个变量 count = 1;

线程 A 在 CPU1 上运行,读取 count 到缓存 Cache1,然后执行 count++;

线程 B 在 CPU2 上运行,也读取 count 缓存 Cache2,然后执行 count++;

假如 A 和 B 是同时读取的 count,它们拿到后都是 1,接着执行了 count++ 后,结果都变成了 2。

最后线程 A 和线程 B 同步变量到主存后,count 的值最终只会是 2,但是理论上应该是 3 才对。

这都是因为线程 A 和线程 B 各自只在自己的缓存上操作数据,相互不可见,导致没有意识到 count 被改了。

二、任务切换带来的原子性问题

  • 一个线程任务不可能长期占用 CPU,每隔一段时间,就需要切换线程任务
  • 线程任务切换一般采用时间片作为单位,比如 50 毫秒一个时间片
  • 线程在执行1个或多个时间片后,就可能会被切换到其他线程任务
  • 如果后面的线程修改了之前线程的数据,那等下次轮到之前线程执行时,它的状态就可能会和它之前被切换前的状态不一致,继续执行下去就有可能出现并发问题

比如 long 变量是 64 位。

线程 A 刚加载了它的前 32 位数据,就被切换走了,开始执行线程 B。

然后线程 B 把变量值改了,写了新值进去,然后又切换回线程 A 执行。

此时线程 A 去加载后 32 位时,数据已经和之前的不一样了。

最终,旧值的前 32 位 + 新值的后 32 位,这 2 个 32 位拼起来的 64 位就会是一个意料之外的值。

三、指令重排带来的有序性问题

  • 编译器生成的指令顺序,可能会和源代码的顺序不同,因为它会对代码进行优化,以便提高执行效率
  • 处理器可以采用乱序或并行的方式来执行指令,比如指令流水线,所以也不一定是按照编译器生成的指令顺序来执行
  • 缓存变量提交到主内存的次序可能会改变,即缓存中先写的变量,不一定会被先提交到主内存中

比如下面可能会出现重排序的例子:

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
public class PossibleReordering {

static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(() -> {
a = 1; // 1
x = b; // 2
});

Thread other = new Thread(() -> {
b = 1; // 3
y = a; // 4
});

one.start();
other.start();

one.join();
other.join();

System.out.println("(" + x + "," + y + ")");
}

}

由于每个线程中的各个操作没有数据流依赖性,所以这些操作可以乱序执行。

也就是说,1和2之间没有依赖关系,所以重排序后2可能比1先执行;同理,4也可能比3先执行。

因此,输出的结果就可能有4种:

  • (0, 1)
  • (1, 0)
  • (1, 1)
  • (0, 0)

其中,(0, 0) 就是由于指令重排引起的,重排后的执行顺序可能是这样的:

1
2
3
4
5
时间线>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

线程A x=b; a=1;

线程B b=1; y=a;
作者

jiaduo

发布于

2022-05-15

更新于

2023-04-03

许可协议