0%

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

并发程序幕后的故事

CPU、内存、I/O三者的速度不匹配,是计算机发展的核心矛盾。
程序里大部分语句都要访问内存,有些还要访问I/O,根据木桶理论,程序整体的性能取决于最慢的操作-读写I/O设备,单方面提高CPU性能是无效的。

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

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

并发程序很多诡异问题的根源也在这里。

源头之一:缓存导致的可见性问题

在单核时代,所有的线程都在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。(单核没有可见性问题,但又原子性、有序性问题)

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

多核时代,每颗CPU都有自己的缓存,这时CPU缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,这个就属于硬件程序员给软件程序员挖的坑。缓存的可见性问题。

源头之二:线程切换带来的原子性问题

由于IO太慢,早起的操作系统就发明了多进程,即便在单核的CPU上也可以一边听着歌,一边写Bug,这就是多进程的功劳。

操作系统允许某个进程执行一小段时间,如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(任务切换),这个50称为称为“时间片”。

在一个时间片内,如果一个进程进行一个IO操作,如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权(时间片内主动释放CPU使用权,无须等到时间片结束),待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了。

这里的进程在等待IO时之所以后释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样CPU的使用率就上来了(IO操作不占用CPU,读文件是设备驱动的工作);如果这时有另外一个进程也读文件,对文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了。

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

Java并发程序都是基于多线程的,自然也会涉及到任务切换,这是并发编程里诡异Bug的源头之一。任务切换的时机大多数是在时间片结束的时候,而现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成。操作系统做任务切换,可以发生在任何一条CPU指令执行完,而不是高级语言里的一条语句。

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

CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,这是违背直觉的地方。因此,很多时候需要再高级语言层面保证操作的原子性。

同样,在32位(4字节)的机器上对long型变量(8字节,被拆成两个32位操作-高32位+低32位)进行加减操作存在并发隐患。

源头之三:编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序。

编译器调整了语句的顺序,有时候不影响程序的最终结果。有时候编译器及解释器的优化可能导致意想不到的Bug。

在Java领域一个经典的案例就是利用双重检查创建单例对象。(volatile,禁止指令重排序,保证可见性和有序性)

  • 分配一块内存M;
  • 在内存M上初始化Singleton对象;
  • 将M的地址赋值给instance变量。

线程在synchronized块中,发生线程切换,锁是不会释放的。
因为编译后,会在synchronized语句结束处或者异常出口处插入指令monitorexit(入口处是monitorenter指令),而只有当JVM执行到这个指令,才会释放锁,显然时间片的切换不会导致锁的释放。

总结

只要能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以理解、可以诊断的。

缓存带来的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题。缓存、线程切换、编译优化都是为了提高程序性能。计数在解决一个问题的同时,必然会带来另外一个问题,在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。