• 并发编程Bug起源:可见性、有序性和原子性问题


    以前古老的DOS操作系统,是单进行的系统。系统每次只能做一件事情,完成了一个任务才能继续下一个任务。每次只能做一件事情,比如在听歌的时候不能打开网页。所有的任务操作都按照串行的方式依次执行。

    这类服务器缺点也很明显,等待操作的过长,无法同时操作多个任务,执行效率很差。

    现在的操作系统都是多任务的操作系统,比如听歌的时候可以做打开网页,还能打开微信和朋友聊天。这几个任务可以同时进行,大大增加执行效率。

    并发提高效率

    一个完整服务器,都有CPU内存IO,三者之间的运行速度存在明显的差异:

    • CPU相关的操作,执行指令以及读取CPU缓存等操作,基本都是纳秒级别的。
    • CPU读取内存,耗时是CPU相关操作的千倍,基本都是微秒级别。CPU和内存之间的速度差异。
    • IO操作基本是毫秒的级别,是内存操作的千倍,内存IO之间存在速度的差异。

    CPU -> 内存 -> SSD -> 磁盘 -> 网络
    纳秒 -> 微秒 -> 毫秒 -> 毫秒 -> 秒

    程序中大部分的语句都要访问内存,有些还要访问的IO读写。为了合理的利用CPU的高性能,高效的平衡三者的速度差异,操作系统、编译器主要做了以下改进:

    • CPU增加了CPU缓存,用来均衡CPU内存的速度差异。
    • 操作系统增加了多进程、多线程,用来分时复用CPU,从而均衡CPUIO设备之间的差异。
    • 编译优化程序执行顺序,充分利用缓存。

    做了以上操作之后,CPU读取或者修改数据之后,将数据缓存在CPU缓存中,CPU不需要每次都从内存中获取数据,极大的提高了CPU的运行速度。多线程是将时间段切成一个个小段,多个线程在上下文切换中,执行完任务,而不用等前面的线程都执行完毕之后再执行。比如做一个计算,CPU耗时1纳秒,而从内存读取数据要1微秒,没有多线程的话,N个线程要耗时N微秒,此时CPU高效性就无法体现出来。有了多线程之后,操作系统将CPU时间段切成一个一个小段,多线程上下文切换,线程执行计算操作,无需等待内存读取操作

    虽然并发可以提高程序的运行效率,但是凡事有利也有弊,并发程序也有很多诡异的bug,根源有以下几个原因。

    缓存导致可见性问题

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

    在单核时代,所有的线程都是在同一个CPU上运行,所有的线程都是操作同一个线程的CPU缓存,一个线程修改缓存,对另外一个线程来说一定是可见的。比如在下图中,线程A线程B都是操作同一个CPU缓存,所以线程A更新了变量V的值,线程B再访问变量V的值,获取的一定是V的最新值。所以变量V对线程都是可见的

    在多核CPU下,每个CPU都有自己的缓存。当多个线程执行在不同的CPU时,这些线程的操作也是在对应的CPU缓存上。这时候就会出现问题了,在下图中,线程A运行在CPU_1上,首先从CPU_1缓存获取变量V,获取不到就获取内存的值,然后操作变量V线程B也是同样的方式在CPU_2缓存中获取变量V

    线程A操作的是CPU_1的缓存,线程B操作的是CPU_2的缓存,此时线程A变量V的操作对于线程B不可见的。多核CPU一方面提高了运行速度,但是另一方面也可能会造成线程不安全的问题。

    下面使用一段代码来测试多核场景下的可见性。首先创建一个累加的方法add10k方法,循环10000count+=1的操作。然后在test方法里面创建两个线程,每个线程都调用add10k方法,结果是多少呢?

    public class VisibilityTest {
    
    	private  static int count = 0;
    
    	private void add10k() {
    		int index = 0;
    		while (index++ < 10000) {
    			count += 1;
    		}
    	}
    
    	@Test
    	public void test() throws InterruptedException {
    		VisibilityTest test = new VisibilityTest();
    		Thread thread1 = new Thread(() -> test.add10k());
    		Thread thread2 = new Thread(() -> test.add10k());
    		// 启动两个线程
    		thread1.start();
    		thread2.start();
    		// 等待两个线程执行结束
    		thread1.join();
    		thread2.join();
    		System.out.println(count);
    	}
    }
    

    按照直觉来说结果是20000,因为在每个线程累加10000,两个线程就是20000。但是实际结果是介于10000~20000的之间,每次执行结果都是这个范围内的随机数。

    因为线程A和线程B同时开始执行,第一次都会将count=0缓存到自己的CPU缓存中,执行完count += 1之后,写入自己对应的CPU缓存中,同时写入内存中,此时内存中的数是1,而不是期望的2。之后CPU再取到自己的CPU缓存再进行计算,最后计算出来的count值都是小于20000,这就是缓存的可见性问题。

    线程切换带来的原子性问题

    上面提到,由于CPU内存IO之间的速度存在很大的差异,在单进程系统中,需要等速度最慢的IO操作完成之后,才能接着完成下一个任务,CPU的高性能也无法体现出来。但操作系统有了多进程之后,操作系统将CPU切成一个一个小片段,在不同的时间片段内执行不同的进程的,而不需要等待速度慢的IO操作,在单核或者多核的CPU上可以一边的听歌,一边的聊天。

    操作系统将时间切成很小片,比例20毫秒,开始的20毫秒执行一个进程,下一个20毫秒切换执行另外一个线程,20毫秒成为时间片,如下图所示:

    线程A线程B来回的切换任务。

    如果一个进行IO操作,例如读取文件,这个时候该进程就把自己标记为休眠状态并让出CPU的使用权,等完成IO操作之后,又需要使用CPU时又会把休眠的进程唤醒,唤醒的进程就可以等待CPU的调用了。让出CPU的使用权之后,CPU就可以对其他进程进行操作,这样CPU的使用率就提高上了,系统整体的运行速度也快了很多。

    并发程序大多数都是基于多线程的,也会涉及到线程上下文的切换,线程的切换都是在很短的时间片段内完成的。比如上面代码中count += 1虽然有一行语句,但这里面就有三条CPU指令。

    • 指令 1:把变量V从内存加载到CPU寄存器中。
    • 指令 2:在寄存器中执行+1操作。
    • 指令 3:将结果写入内存(也可能是写入CPU缓存中)。

    任何一条CPU指令都可能发生线程切换。如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图顺序执行,那么我们会发现两个线程都执行count += 1的操作,但是最后结果却是1,而不是2

    编译优化带来的有序性问题

    有序性是指程序按照代码的先后顺序执行,编译器为了优化性能,在不影响程序的最终结果的情况下,编译器调整了语句的先后顺序,比如程序中:

    a = 2;
    b = 5;
    

    编译器优化后可能变成:

    b = 5;
    a = 2;
    

    虽然不影响程序的最后结果,但是也会引起一些意想不到的BUG。

    Java中一个常见的例子就是利用双重检验创建单例对象,例如下面的代码:

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

    在获取实例getInstance方法中,首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance是否为空,如果还为空就创建一个Singleton实例。

    假设两个线程,线程A线程B同时调用getInstance方法。此时instance == null,同时对Singleton.class加锁,JVM保证只有一个线程能加锁成功,假设是线程A加锁成功,另一个线程就会处于等待状态,线程A会创建一个实例,然后释放锁,线程B被唤醒,再次尝试加锁,此时成功加锁,而此时instance != null,已经创建过实例,所以线程B就不会创建实例了。

    看起来没有什么问题,但实际上也有可能问题出现在new操作上,本来new操作应该是:

    • 1、分配一块内存。
    • 2、在内存上初始化对象。
    • 3、内存的地址赋值给instance变量。

    但实际优化后的执行顺序却是如下:

    • 1、分配一块内存。
    • 2、将内存地址赋值给instance变量。
    • 3、在内存上初始化对象。

    优化之后会发生什么问题呢?首先假设线程A先执行getInstance方法,也就是先执行new操作,当执行完指令2时发生了线程切换,切换到线程B上,此时线程B执行getInstance方法,执行判断时会发现instance != null,所以就返回instance,而此时的instance是没有初始化的,如果这时访问instance就可能会触发空指针异常。

    总结

    操作系统进入多核、多进程、多线程时代,这些升级会很大的提高程序的执行效率,但同时也会引发可见性原子性有序性问题。

    • 多核CPU,每个CPU都有各自的CPU缓存,每个线程更新变量会先同步在CPU缓存中,而此时其他线程,无法获取最新的CPU缓存值,这就是不可见性。
    • count += 1含有多个CPU指令。当发生线程切换,会导致原子问题。
    • 编译优化器会调整程序的执行顺序,导致在多线程环境,线程切换带来有序的问题。

    开始学习并发,经常会看到volatilesynchronized等并发关键字,而了解并发编程的有序性、原子性、可见性等问题,就能更好的理解并发场景下的原理。

    参考

    可见性、原子性和有序性问题:并发编程Bug的源头

  • 相关阅读:
    c# sqlite 修改字段类型
    GUI编程--PyQt5--QMessageBox
    Integer包装类常用方法和属性
    使用通义灵码插件提高开发效率
    Intel汇编-内联汇编
    iNFTnews | 元宇宙技术将带来全新的购物体验
    九九乘法表
    Linux虚拟机的克隆
    机器学习策略篇:详解单一数字评估指标(Single number evaluation metric)
    搞懂SpringBean生命周期与依赖注入:你还在为这些热门技术感到困惑吗?Comate插件来帮你解答!
  • 原文地址:https://www.cnblogs.com/jeremylai7/p/16644934.html