并发bug源头
并发涉及到很多的底层只是,操作系统、编译原理都会有涉及。并发中的bug尝尝比较诡异的出现或消失,很难重现和追踪,只有对追本溯源,了解并发的源头所在,才能解决根本问题。
计算机硬件发展
CPU、内存、I/O设备都在不断迭代,朝着更好更快的方向发展,伴随着三者的发展,核心矛盾一直都存在,就是三者的速度差异。CPU和内存,就像神话世界中的天上一天,地上一年;内存和I/O设备之间,差异就更大了。木桶理论中,盛水的多少,取决于最短的那块;也就是说,CPU单方面的高性能是不行的。为了合理利用CUP高性能,平衡这三者的速度差异,计算机硬件、操作系统、编译程序都有努力。
- CPU增加缓存,均衡与 内存的速度差异。
- 操作系统增加进程、线程,分时复用CPU,均衡CPU与I/O之间的差异。
- 编译程序优化指令执行次序,使得缓存更加合理利用。
这些成果,大大提高了操作系统的性能和速度,但是并发程序的诡异bug,也是源自这里。
源头之一:缓存导致的可见性问题
单核时代,所有线程都在一颗CPU上执行,cup缓存和内存的数据一致性问题,是很容易解决的。所有线程都是操作同一个CPU缓存,一个线程对缓存的读写,对另一个线程来说一定可见。一个线程对共享变量的修改,另外一个线程能立刻看见,我们称之为可见性。
多核时代,每个CPU都有自己的缓存,线程在不同的CPU上对共享变量的修改,对另一个线程来讲是不可见的,这就是硬件带来的并发中的可见性问题。
/**
* 下面代码中,按照我们的意愿,是想让count为20000,可是结果却是10000到20000之间的一个随机数。
* 因为线程t和线程t2在不同CPU上执行,使用的都是各自CPU缓存,当他们同时执行时,count++之后的
* 值是1,他们往内存中写入时间也没有协调,一个线程会覆盖掉另一个线程的写入,这就是问题所在
*/
@RunWith(JUnit4.class)
public class ConcurrencyTest {
private long count = 0;
public void add(){
int idx = 0;
while (idx++ < 10000){
count ++;
}
}
@Test
public void testMutip(){
Thread t = new Thread(()->{add();});
Thread t2 = new Thread(() -> add());
t.start();
t2.start();
try {
t.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
源头之二:线程切换导致的原子性问题
由于IO太慢,早起操作系统发明了进程,即使在单核时代,我们可以一边听歌,一边写bug,就是多进程的功劳,操作系统允许某个进程在执行一小段时间后,切换到另一个进程。这样不停的在各个进程之间切换,达到并发效果。操作系统会将执行时间切分成时间段,这个时间段就是时间片,当一个进程时间片执行完,操作系统就会切换到下一个进程。
早期操作系统基于进程来调度CPU,不同进程间之间,内存空间也不一样,进程切换时候,内存空间也会切换到对应地址,一个进程的所有线程,是共享内存的,线程切换,成本是很低的。现代操作系统都基于更轻量的线程来调度。线程切换,也叫任务切换。
任务切换都是在时间片结束后,任务切换就是并发编程bug源头之一,高级语言一条语句往往需要多条CPU指令去完成,一个时间片执行的指令是有限的。任务切换,可以发生在任何一条CPU指令执行完成后。而不是高级语言里的一段或一条语句。
count += 1这个操作,在高级语言里是一个整体,而在CPU中最低需要三条CPU指令。
- 指令1:把count从内存加载到CPU寄存器
- 指令2:在寄存器中执行加1操作
- 指令3:将结果写入内存(缓存机制导致写入有可能是缓存而不是内存)
一个或者多个操作在CPU执行过程中不被中断的特性称为原子性,CPU能保证的原子性是CPU指令级别的,不是高级语言的操作符,因此,需要我们在高级语言层面保证操作的原子性。
源头之三:编译优化带来的有序性问题
有序性,顾名思义,就是程序代码执行的先后顺序。但是编译器为了优化性能,会改变程序中语句的先后顺序,这就可能导致意想不到的bug。