Three Root Causes of Concurrency Problems - tenji/ks GitHub Wiki

并发问题产生的三大根源

并发问题变幻莫测,一谈到并发就显得非常高深,一般的程序员对于并发问题也是头疼不已,但是随着网络互联越来越普遍,大规模用户访问网站程序也越来越频繁,并发问题又无法避免。

在我们解决并发问题前首先要理解产生并发问题的根源是什么,所有并发处理的工具只是针对这些根源问题的其中一种解决方案,如果只去了解解决方案而不理解问题的根源是什么,那么我们就很难正确的定位问题并对症下药。所以要写好并发程序我们首先就要深入理解并发问题产生根源是什么?

起因:如何最大化的利用 CPU

CPU 运算速度和 IO 速度的不平衡一直是计算机优化的一个课题,我们都知道 CPU 运算速度要以百倍千倍程度快于 IO 的速度,而在进行任务的执行的时候往往都会需要进行数据的 IO,正因为这种速度上的差异,所以当 CPU 和 IO 一起协作的时候就产生问题了,CPU 执行速度非常快,一个任务执行时候大部分时间都是在等待 IO 工作完成,在等待 IO 的过程中 CPU 是无法进行其它工作的,所以这样就使得 CPU 的资源根本无法合理的运用起来。

CPU 就相当于我们计算机的大脑,如何把 CPU 资源合理的利用起来就直接关系到我们计算机的效率和性能,所以为了这个课题计算机分别从缓存、任务切换、指令排序优化这几个方向进行了优化 。

一、进程和线程的产生

在最原始的系统里计算机内存中只能允许运行一个程序,这个时候的 CPU 的能力完全是过剩的,因为 CPU 在接收到一个任务之后绝大部分时间都是处在 IO 等待中,CPU 根本就利用不起来,所以这个时候就需要一种同时运行多个程序的方法,这样的话当 CPU 执行一个任务 IO 等待的时候可以切换到另外一个任务上去执行指令,不必在 IO 上浪费时间,那么 CPU 就能很大程度的利用起来,所以基于这种思路就产生了进程和线程。

有了进程后,一个内存可以划分出不同的内存区域分别由多个进程管理,当一个进程 IO 阻塞的时候可以切换到另外一个进程执行指令,为了合理公平的把 CPU 分配到各个进程,CPU 把自己的时间分为若干个单位的片段,每在一个进程上执行完一个单位的时间就切换到另外一个进程上去执行指令,这就是 CPU 的时间片概念。有了进程后我们的电脑就可以同时运行多个程序了,我们可以一边看着电影一边聊天,又进一步提升了 CPU 的利用率。

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

并发问题根源之一:CPU 切换线程执导致的原子性问题

首先我们先理解什么叫原子性,原子性就指是把一个操作或者多个操作视为一个整体,在执行的过程不能被中断的特性叫原子性。

因为 IO、内存、CPU 缓存他们的操作速度有着巨大的差距,假如 CPU 需要把 CPU 缓存里的一个变量写入到磁盘里面,CPU 可以马上发出一条对应的指令,但是指令发出后的很长时间 CPU 都在等待 IO 的结束,而在这个等待的过程中 CPU 是空闲的。

所以为了提升 CPU 的利用率,操作系统就有了进程和时间片的概念,同一个进程里的所有线程都共享一个内存空间,CPU 每执行一个时间段就会切换到另外一个进程处理指令,而这执行的时间长度是是以时间片(比如每个时间片为1毫秒)为单位的,通过这种方式让 CPU 切换着不同的进程执行,让 CPU 更好的利用起来,同时也让我们不同的进程可以同时运行,我们可以一边操作 word 文档,一边用 QQ 聊天。

后来操作系统又在 CPU 切换进程执行的基础上做了进一步的优化,以更细的维度“线程”来切换任务执行,更加提高了 CPU 的利用率。但正是这种 CPU 可以在不同线程中切换执行的方式会使得我们程序执行的过程中产生原子性问题。

比如说我们以一个变量赋值为例:

语句1:Int number = 0;

语句2:number = number + 1;

在执行语句2的时候,我们的直觉 number = number + 1 是一个不可分割的整体,但是实际 CPU 操作过程中并非如此,我们的编译器会把 number = number + 1 拆分成多个指令交给 CPU 执行。

number = number + 1 的指令可能如下:

指令1:CPU 把 number 从内存拷贝到 CPU 缓存。

指令2:把 number 进行 +1 的操作。

指令3:把 number 回写到内存。

在这个时候如果有多线程同时去操作 number 变量,就很有可能出现问题,因为 CPU 会在执行上面任何一个指令的时候切换线程执行指令,这个时候就可能出现执行结果与我们预期结果不符合的情况。

比如如果现在有两个线程都在执行 number = number + 1,结果 CPU 执行流程可能会如下:

执行细节:

  1. CPU 先执行线程 A 的执行,把 number = 0 拷贝到 CPU 寄存器。
  2. 然后 CPU 切换到线程 B 执行指令。
  3. 线程 B 把 number = 0 拷贝到 CPU 寄存器。
  4. 线程 B 执行 number = number + 1 操作得到 number = 1。
  5. 线程 B 把 number 执行结果回写到缓存里面。
  6. 然后 CPU 切换到线程 A 执行指令。
  7. 线程 A 执行 number = number + 1 操作得到 numbe = 1。
  8. 线程 A 把 number 执行结果回写到缓存里面。
  9. 最后内存里面 number 的值为 1。

二、高速缓存的产生

为了减少 CPU 等待 IO 的时间,让 CPU 有更多的时间是花在运算上,最简单的思路就是减少 IO 等待的时间,基于这个思路所以就有了高速缓存增加了高速缓存(L1, L2, L3, 主存)。

在计算机系统中,CPU 高速缓存是用于减少处理器访问内存所需的时间,其容量远小于内存,但其访问速度却是内存 IO 的几十上百倍。当处理器发出内存访问请求时,会先查看高速缓存内是否有请求数据。如果存在(命中),则不需要访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

并发问题根源之二:缓存导致的可见性问题

在有了高速缓存之后,CPU 的执行操作数据的过程会是这样的,CPU 首先会从内存把数据拷贝到 CPU 缓存区。

然后 CPU 再对缓存里面的数据进行更新等操作,最后 CPU 把缓存区里面的数据更新到内存。

磁盘、内存、CPU缓存会按如下形式协作。

缓存导致的可见性问题就是指我们在操作 CPU 缓存过程中,由于多个 CPU 缓存之间独立不可见的特性,导致共享变量的操作结果无法预期。

在单核 CPU 时代,因为只有一个核心控制器,所以只会有一个 CPU 缓存区,这时各个线程访问的 CPU 缓存也都是同一个,在这种情况一个线程把共享变量更新到 CPU 缓存后另外一个线程是可以马上看见的,因为他们操作的是同一个缓存,所以他们操作后的结果不存在可见性问题。

而随着 CPU 的发展,CPU 逐渐发展成了多核,CPU 可以同时使用多个核心控制器执行线程任务,当然 CPU 处理同时处理线程任务的速度也越来越快了,但随之也产生了一个问题,多核 CPU 每个核心控制器工作的时候都会有自己独立的 CPU 缓存,每个核心控制器都执行任务的时候都是操作的自己的 CPU 缓存,CPU1 与 CPU2 它们之间的缓存是相互不可见的。

这种情况下多个线程操作共享变量就因为缓存不可见而带来问题,多线程的情况下线程并不一定是在同一个 CPU 上执行,它们如果同时操作一个共享变量,但因为在不同的 CPU 执行所以他们只能查看和更新自己 CPU 缓存里的变量值,线程各自的执行结果对于别的线程来说是不可见的,所以在并发的情况下会因为这种缓存不可见的情况会导致问题出现。

比如下面的程序:

两个线程同时调用 addNumber() 方法对 number 属性进行 +1,循环 10W 次,等两个线程执行结束后,我们的预期结果 number 的值应该是 20000,可是我们在多核 CPU 的环境下执行结果并非我们预期的值。

public class TestCase {
    private int number = 0;

    public void addNumber() {
        for (int i = 0; i < 100000; i++) {
            number = number + 1;
        }
 ​
    }
 ​
    public static void main(String[] args) throws Exception {
        TestCase testCase = new TestCase();
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                testCase.addNumber();
            }
        });
 ​
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                testCase.addNumber();
            }
        });
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
        System.out.println("number=" + testCase.number);
    }
}

打印结果:

三、指令优化

进程和线程本质上是增加并行的任务数量来提升 CPU 的利用率,缓存是通过把 IO 时间减少来提升CPU的利用率,而指令顺序优化的初衷的初衷就是想通过调整 CPU 指令的执行顺序和异步化的操作来提升 CPU 执行指令任务的效率。

指令顺序优化可能发生在编译、CPU 指令执行、缓存优化几个阶,其优化原则就是只要能保证重排序后不影响单线程的运行结果,那么就允许指令重排序的发生。其重排序的大体逻辑就是优先把 CPU 比较耗时的指令放到最先执行,然后在这些指令执行的空余时间来执行其他指令,就像我们做菜的时候会把熟的最慢的菜最先开始煮,然后在这个菜熟的时间段去做其它的菜,通过这种方式减少 CPU 的等待,更好的利用 CPU 的资源。

并发问题根源之三:指令优化导致的重排序问题

下面的程序代码如果 init() 方法的代码经过了指令重排序后,两个方法在两个不同的线程里面调用就可能出现问题。

private static int value;
private static boolean flag;
 ​
public static void init() {
    value = 8;      // 语句1
    flag = true;    // 语句2
}​

public static void getValue() {
    if (flag) {
        System.out.println(value);
    }
}

根据上面代码,如果程序代码运行都是按顺序的,那么 getValue() 中打印的 value 值必定是等于 8 的,不过如果 init() 方法经过了指令重排序,那么结果就不一定了。根据重排序原则,init() 方法进行指令重排序重排序后并不会影响其运行结果,因为语句1和语句2之间没有依赖关系。 所以进行重排序后代码执行顺序可能如下。

flag = true;    // 语句2  
value = 8;      // 语句1

如果 init() 方法经过了指令重排序后,这个时候两个线程分别调用 init() 和 getValue() 方法,那么就有可能出现下图的情况,导致最终打印出来的 value 数据等于 0。

参考链接

⚠️ **GitHub.com Fallback** ⚠️