为什么我们开发的系统会有并发Bug,并发Bug根源到底是什么?

芝兰生于幽谷,不以无人而不芳。

前言

为什么我们开发的系统会有并发Bug,并发Bug根源到底是什么?

在追问这个问题之前,先说一下一颗剽悍的种子对并发的看法,并发真是一个即熟悉又陌生的课题。熟悉是因为Java是天然支持多线程的,而多线程正是并发的土壤(单线程是不会有并发问题的,JavaScript就是单线程的,你看坐我对面写JavaScript的前端程序员就不会因为并发Bug加班,虽然后端朋友可能不服,但是事实确实如此)。

所以在学习基础时就经过了多线程的洗礼,知道了在多线程下操作没有加锁的集合,数据是多么变化莫测,例如我们在操作Java常用的非线程安全的集合类:HashMap,ArrayList 在多线程下进行put(),add()都会导致不正确的数据结果。

而当脱离了纯粹用模拟多线程示例的学习,到了我们在公司开发的系统真刀真枪发布到线上;所面临的是每一个个的真实用户抵达我们的系统,每个一个用户在计算机中就是一个个的线程。此时我们就得考虑系统的每一个功能在多线程环境下是否是线程安全的。例如我们开发的系统中常遇到有并发Bug场景,例如电商场景下的下单进行购物,出行场景下的火车高铁等抢票等高并发场景。如果不能防范并发问题,当大量用户同时去抢购,库存就面临超卖现象,也就是并发时有Bug。但是造成系统出现并发Bug只不过是结果,我们要探寻的是造成这些并发Bug的原因。

(但这些并发Bug真是我们软件程序员的锅吗?其实并不一定,下面一颗剽悍种子带你找出真正的“锅”)

所以对于这些场景下的多线程不安全问题,我们都有同样的共识,那就是加锁就可以。在单体系统时加本地锁,如常见的:Synchronized,Lock等 ,到了微服务、集群、分布式下的系统时加分布式锁,如:Redis、ZooKeeper。但这并不是让我陌生的地方,真正让我陌生的是为什么要加锁,加锁仅仅是解决问题的手段,而问题的根源是什么?

为什么在多线程下会出现这些问题,为什么我们开发的系统会有并发Bug?

加锁只是答案,为什么加锁才是问题根源!

看过之前一颗剽悍的种子文章的掘友都知道,比起问这个答案是什么,显然我更感兴趣的是这个答案背后是为什么?

那什么是并发,什么又是并行?

在开始前我们得对并发基本概念先有所了解,因为除了并发还有很容易与之混淆的并行。从字面和概念上不好区分之外,并发看上去也好像并行同时进行一样。

先说并发与并行概念,并发其实就是在同一时间点多个线程轮流切换执行。而并行可以同一时间点执行多个线程

(从开发的角度形象的说并发与并行,当我们开发一个系统时,每一个开发系统的职责就好比一个线程,而一个工程师好比一个处理器;并发就像一个全干(全栈)工程师负责完成;是既写前端同时也负责写后端,还要负责搭建服务器等运维工作,在前后端等职责之间来回切换的进行。而并行是多个工程师一起负责完成,可以有前端工程师、后端工程师、数据库工程师、运维工程师等多个工程师同时独立分工的进行)

如下图所示: image.png

根源一:上下文切换的原子性问题

我们先来看并发Bug根源一,上下文切换导致的原子性问题,这得从单核CPU时候说起,操作系统和程序就能实现多线程的并发任务。(也就说你既可以在浏览器上的掘金写文章,同时还可以打开音乐听着撩人的DISCO)而看似两个任务同时在运行,其实在计算机底层是CPU给每一个线程分配了执行的时间,也就是时间片(Time Slice),所以线程在执行时是按CPU所分配的时间片内执行任务。

意味着时间片决定了一个线程能占用CPU运行的时长,所以当线程获得的CPU时间片用完,或者被迫暂停运行,就轮到另一个被选中的线程来占用CPU。而一个线程脱离处理器使用权暂停运行就是 “切出”;线程被选中占用处理器开始运行时就是 “切入”。而切出切入的过程中还需要保存和恢复来回切出切入的信息,这个保存和恢复的信息就是上下文

而所谓的上下文就得用到存储指令的寄存器和程序计数器。CPU寄存器负责存储已经和正在或要执行的任务,程序计数器负责存储CPU正在执行的指令位置以及即将执行的下一条指令的位置。

如果你再跳出细节来看,这整个过程其实就是上下文切换

关于程序计数器,在我的上一章中有畅谈过的JVM内存结构就有所涉及,而不管是寄存器,还是程序计数器,都相当于一个缓冲,其道理都是一样,一通百通的,感兴趣的朋友可以再回顾。

《我从冯·诺依曼计算机体系,追溯到了JVM,一切原来如此!》

所以从宏观层面,多线程看似同时运行,但在计算机微观层面,单CPU下多线程实则是在CPU时间片的上下文切换。而回到开头所提到的问题,而并发看似跟并发同时进行一样?,也只不过是计算机CPU与内存那纳秒级别的进行线程切换的速度超过了我们肉眼所能感知到的速度而已。

多线程的生命周期

当我们知道了并发和上下文切换,那么再细想,多线程生命周期中的线程上下文切换不也是并发的产物吗?

image.png

如上图所示,Java线程的生命周期包括了新建、就绪、运行、阻塞、死亡这五种状态。

当线程从运行状态到阻塞状态时就是线程的暂停。而线程从阻塞状态到就绪状态时就是线程的唤醒。线程从就绪状态到运行状态的过程或者从运行状态转为阻塞状态,再到就绪状态;然后拿到到CPU执行权限其实就是一个上下文切换过程

可以看到,线程的状态之间的上下文切换实际就是由两个主要的线程生命周期状态所引发而被选中后执行的。

暖男一颗剽悍的种子给你圈出来了,可以看到就绪状态运行状态是获取CPU执行权限和释放CPU执行权限的主要状态。

b90a4b2e2ebb45d6efd6afcfbb28fae.png

那么线程运行的状态转化又是怎么触发的呢?

有很多我们熟悉的关键字和方法,如:synchronized、lock、sleep()、yield()、wait()、join()、park()引发自发的上下文切换。

所以自发上下文切换就是通过程序自发的触发切换,而非自发上下文切换,可以是我们上面所说,当一个线程时间片用完被迫切换或者受系统执行优先权影响被迫切换,以及JVM垃圾回收也会触发被迫切换。

从上面所知,上下文切换带来的不是原子性的并发Bug,那么我们可以用亿小段代码来看验证并发时因线程上下文切换而导致原子性并发Bug。如下代码所示,一个线程执行addCount()方法后会循环1000次的count++操作(也就是从0加1到1000),而我们在calculation()中创建两个线程来执行,按照预期会打印2000的结果,但实际会如何请往下看。

public class AtomicTest {

    private static Integer count = 0;

    private static void addCount() {

        for (int i = 0; i < 1000; i++) {

            count++;
        }
    }

    public static Integer calculation() throws InterruptedException {

        Thread thread_1 = new Thread(()->{
            addCount();
        });

        Thread thread_2 = new Thread(()->{
            addCount();
        });

        thread_1.start();
        thread_2.start();

        thread_1.join();
        thread_2.join();

        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calculation());
    }
复制代码

我们开启了两个线程,并且每个线程累加循环1000次,也就是正确的结果应该是2000。但实际上运行结果,不是正确结果2000,而是会在2000内的随机数。

AtomicTest类代码运行结果如下:

image.png

但是这样结束了吗?

当然没有,你还得知道这亿段代码真正导致Bug地方是哪里!

我们来看代码中的结果是通过count变量进行累加,所以关键的并发Bug想必也是出在这里。

count++;
复制代码

count++看似朴实无害,但逃不过机智的一颗剽悍的种子,通过javap -c反编译可以窥探到Java虽然通过一条语句就可以来完成的事情,但作为高级的编程语言,在编译器编译成字节码的背后实则是多条指令在CPU中完成的,从而多线程同时操作count++时因线程的上下文切换,导致在线程中这些指令都不是整体执行的。

//变量加载到操作数栈
iconst_0 

//从操作数栈存储到局部变量表
istore_1 

//将局部变量表中的变量加一
iinc 1 by 1 
复制代码

所以多线程操作一个不可分割的整体时,不被线程上下文切换而导致执行过程中断的特性就是原子性。(也就是我们常说的原子性就是所有操作要么不间断地全部被执行,要么一个都不执行)

根源二:CPU缓存的可见性问题

我们知道在计算机中,内存的速度要远比磁盘的速度快得多,而CPU的速度又远超内存的速度;那么如果说内存是兰博基尼,CPU就是超音速飞机(CPU与内存的速度是不在同一个速度单位的)。

但追求速度是计算机世界中的信条。所以为了内存也能搭上CPU的速度,CPU增加了缓存,来均衡与内存的速度差异;让内存也感受了一了把超音速飞机的推背感。

914ff28df879dc046b30c2b92bfa792.png

但遗憾的是在计算机中,解决一个问题的同时,就会出现另外的问题。那就是CPU一旦加入缓存之后,CPU缓存与内存就存在数据一致问题。(使用过Redis或者其他中间件来做缓存,想必都会遇到数据一致性问题)

在单个CPU时期,CPU缓存与内存的数据一致性是容易解决的。因为所有的线程都只能操作同一个CPU缓存;所以当一个线程对缓存进行了写操作,那么对于另一个线程一定是可见的。例如下图所示,线程A线程B操作同一个CPU缓存时,线程A更新了共享变量X的值后,线程B去访问共享变量X值的数据就是线程A所写的值(也就是说访问到的一定会是最新的值)。所以当一个线程对共享变量的更新时,另一个线程能够立刻访问到,就是可见性。

image.png

但到了多核CPU时,每个线程不再是在单一个CPU上操作,而是在多个CPU上操作着不同都的缓存;此时的CPU缓存与内存的数据一致性就变得复杂得多。例如下图所示,当线程A操作的是CPU_A的缓存,而线程B访问的是CPU_B的缓存,那么线程A共享变量X的操作对于线程B是不可见的。如下图所示,一图胜千言。

image.png

我们再次回顾上面原子性中展示过的AtomicTest类的代码,其实除了并发时的原子性Bug,还存在可见性Bug。

再看如下代码所示,当多个线程同一时间开始执行,在多CPU下线程读取到count = 值可能来自不同CPU中的缓存,而同样count++也可能是在不同CPU缓存中+1。所以有可能当同时写入内存后,count正确的值应该是2的,然而发现还是1。所以代码中的两个线程都基于各自的CPU缓存中count值进行计算,导致了count值没法正确累加到2000;而这也正是CPU缓存所导致的可见性Bug。

(代码中的原子性Bug在上述中有详解,这里不再赘述,而是要关注代码中还存在的可见性Bug)

public class AtomicTest {

    private static Integer count = 0;

    private static void addCount() {

        for (int i = 0; i < 1000; i++) {

            count++;
        }
    }

    public static Integer calculation() throws InterruptedException {

        Thread thread_1 = new Thread(()->{
            addCount();
        });

        Thread thread_2 = new Thread(()->{
            addCount();
        });

        thread_1.start();
        thread_2.start();

        thread_1.join();
        thread_2.join();

        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calculation());
    }
复制代码

打开任务管理器可以看到CPU中其实已经有着L1、L2、L3好几级别的缓存。 image.png

看到这你会发现这哪里是后端的锅,并发Bug根源的原子性问题和可见性问题很明显是硬件工程师的“锅”。

image.png

根源三:指令优化的有序性问题

我们知道程序的运行是按我们所编写的代码逻辑来实现的,而每一个功能的逻辑则是按代码的先后顺序执行的。但编译器并不会按部就班执行我们的代码,而是想方设法的让不同指令操作重叠,从而能并行处理,提高指令的执行速度。例如:

原来的执行顺序:

//第一处
int x = 1;
int y = 2;

//第二处
x++;
y++;

//第三处
int count = y + x;
复制代码

可能重排后代码:

//第一处
int y = 2;
y++;

//第二处
int x = 1;
x++;

//第三处
int count = y + x;
复制代码

上面的示例中,虽然编译器调整了我们编写的代码顺序,但不会造成影响。但是Bug总会出现在不经意中。

懒汉式的双重校验锁问题

有序性的最经典的案例是在单例模式中懒汉式实现的双重校验锁(DCL)来解决线程不安全问题。代码如下所示:

public class LazySingleton {
   private static LazySingleton instance;
   private static LazySingleton getInstance(){
        if (instance == null) {
            synchronized(LazySingleton.class) {
              if (instance == null){
                  instance = new LazySingleton();
              }
            }
        }
        return instance;
    }
}
复制代码

上面代码看似并发下无隙可乘,但实际JVM的编译的指令优化下就会出现对象不能完成初始化。暖男一颗剽悍的种子带你一起来找茬;在代码中我们知道最为关键的是对象的实例化。(单例模式其作用就是保证一个类仅有一个实例)

instance = new LazySingleton();
复制代码

一颗剽悍的种子为了让你能更好的Get到,再通过javap -c LazySingleton反编译来观察上面这行代码。

//new 创建 LazySingleton 对象实例,分配内存
new #3 <demo/LazySingleton>

//复制栈顶地址,并压入栈顶
dup

//调用构造器方法,初始化 LazySingleton 对象
invokespecial #4 <demo/LazySingleton.<init> : ()V>

//存入局部方法变量表
aload_0
复制代码

那么上面这行代码在原来的执行顺序是这样的:

  1. 对象分配内存空间。
  2. 在内存空间中初始化LazySingletonDemo对象。
  3. 对象将内存地址赋值给instance变量。

指令重排后:

  1. 对象分配内存空间。
  2. 对象将内存地址赋值给instance变量。
  3. 在内存空间中初始化LazySingletonDemo对象。

也就说指令2和指令3的两个阶段,对Java编译器来说,所执行顺序是未定义的。那么当一个线程new LazySingletonDemo()时,编译器仅为该对象分配内存空间。而另一个线程调用getInstance()方法,由于instance != null,所以直接返回instance对象,但instance对象还没有被有效赋值,所以无法正常获取到单例对象。

所以有序性,就是编译器为了提高执行性能对指令进行优化,从而打破了我们代码的执行顺序,导致了并发时的有序性Bug。

总结

我们从追问为什么我们开发的系统会有并发Bug,从而知道了并发Bug背后其实是上下文带来的原子性问题CPU缓存带来的可见性问题,以及指令优化带来的有序问题。而并发Bug解决的手段是加锁,但不管是加的是什么样的锁;加锁要解决核心都是围绕并发Bug的根源。

所以理解了并发Bug根源到底是什么,更多是让你在使用锁时能意识到为什么要锁;就像开头所说从单体系统的使用Synchronized,Lock等锁,到分布式系统Redis、ZooKeeper的锁一直在变,可问题本质并没有变,我想这应该才是我们应该多花时间和精力的地方。

好了,这一个多月就到这了。

我是一颗剽悍的种子,怕什么真理无穷,进一寸,有进一寸的欢喜。感谢各位朋友的:关注点赞收藏评论 ,我们下回见!

创作不易,勿白嫖。

一颗剽悍的种子 | 文 【原创】

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7118919540959870989