美文网首页
你的并发程序为何会出诡异bug?

你的并发程序为何会出诡异bug?

作者: 兮兮码字的地方 | 来源:发表于2019-07-14 13:12 被阅读0次

    众所周知,编写好并发程序往往不是一件容易的事,常常会出一些十分诡异的bug。事实上,要理解其根本原因,需要从计算机底层的运行原理来探究。

    并发问题三大原因

    一,可见性问题

    public class Test {
      private long count = 0;
      private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
          count += 1;
        }
      }
      public static long calc() {
        final Test test = new Test();
        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(()->{
          test.add10K();
        });
        Thread th2 = new Thread(()->{
          test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        return count;
      }
    }

    上面这段代码的执行结果,我们会下意识的认为是20000。而实际结果却是10000—20000之间的某个随机数。

    这是因为,我们以为的成员变量count对于两个线程是共享的,两个线程是无时不刻都获取的是同一个count

    然而,事实上,由于现在几乎所有的计算机都是多核的,而每个cpu都有自己独立的cpu缓存,如果两个线程被分到两个cpu同时执行,那么它们各自从自己的缓存中获取到的count有可能是还没来得及同步的数据。于是就导致了两个线程的count并非总是我们以为的同一个。

    你的并发程序为何会出诡异bug?

    一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

    由于cpu的缓存机制会导致多核cpu的并发场景时出现可见性问题

    二,原子性问题

    假设我们的计算机都是单核的,那就永远只会有一个cpu缓存,那么还可能会出现并发时的数据问题吗?

    ——是的,仍然可能。

    上面那段代码,当程序执行count+=1这句话时,我们又会下意识的认为它是一个不可再被分割的整体,就像一个原子一样。

    然而,实际上,这段代码在cpu中分为了三个指令来执行:

    指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

    指令 2:之后,在寄存器中执行 +1 操作;

    指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

    并且每个线程拥有自己独立的寄存器。

    你的并发程序为何会出诡异bug?

    从图中可以看出,当线程切换出现这种情况时,就会使得两个线程都执行完各自的一次运算后,count仍然为1而不是我们以为的2。

    我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

    线程的切换可能会打破高级编程语言中一个语句的(我们以为的)原子性,加上每个线程都拥有自己独立的寄存器,所以这又导致了并发时的数据共享的问题。

    三,有序性问题

    除了以上两种原因,还有一类原因也会引发并发的问题。

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

    以上代码就是经典的双检锁创建单例。

    从代码层面分析,我们担心多个线程同时执行到 if (instance == null) ,于是在后面的创建实例的代码块加锁,并增加instance == null判断。

    此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

    单从代码分析似乎很完美。

    但从cpu指令执行层面分析,不然。

    我们以为的 new 操作应该是:

    1.分配一块内存 M;
    2.在内存 M 上初始化 Singleton 对象;
    3.然后 M 的地址赋值给 instance 变量。

    但是实际上cpu对指令优化后的执行路径却是这样的:

    1.分配一块内存 M;
    2.将 M 的地址赋值给 instance 变量;
    3.最后在内存 M 上初始化 Singleton 对象。

    如果线程1执行到第2步时发生了线程切换,轮到第二个线程执行getInstance()方法,线程2检查 instance == null就会发现instance不为空,于是直接返回一个空实例。

    你的并发程序为何会出诡异bug?

    所以这就导致并发下可能会出现访问 instance 的成员变量就可能触发空指针异常。

    编译器为了优化性能,可能会改变程序中语句的先后顺序,在并发场景下,有时就会导致意想不到的bug。

    总结一下,导致并发编程诡异bug有三类原因。

    1.缓存导致的可见性问题
    2.线程切换带来的原子性问题
    3.编译优化带来的有序性问题

    都是性能优化带来的坑

    由于cpu,内存,I/O 设备这三者的执行速度差异十分大,cpu最快,I/O 设备最慢。

    为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

    1.CPU 增加了缓存,以均衡与内存的速度差异;
    2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

    而这三条优化正好对应了并发编程出诡异bug的三大原因。

    ——没有绝对好的解决方案,只有适合的场景。

    我们在运用并发编程时一定要了解它背后可能藏着的坑。

    相关文章

      网友评论

          本文标题:你的并发程序为何会出诡异bug?

          本文链接:https://www.haomeiwen.com/subject/ynebkctx.html