在之前的文章中我们讲到过引起多线程bug的三大源头问题(可见性,原子性,有序性问题),java的内存模型(Java Memory Model)可以解决可见性和有序性问题,但是原子性问题如何解决呢?
public class Counter {
volatile int count;
public void add10K() {
for(int i = 0; i < 10000; i++) {
count++;
}
}
public int get() {
return count;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> counter.add10K());
Thread t2 = new Thread(() -> counter.add10K());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
上面的代码我们的期望结果是20000,可以运行结果确实小于20000的。是因为count这个共享变量同时被两个线程在修改,而count++这条语句在cpu中实际对应三条指令
- 读取count的值
- count + 1
- 将count+1的值重新复制给count
而线程切换就可能发生在执行完任意一个指令的时候,比如 现在count的值为100, 线程1读取到了之后加1完成,但是还没有将新的值赋值给count,此时让出cpu,然后线程2执行,线程2读取到的值也为100,执行后count++之后,才让出cpu,此时线程1获取到执行资格,将count设置为101。
类似上面的情况,两个或多个线程读写某些共享数据,而最后的结果取决于线程运行的精准时序,称为竞争条件。
那么如何避免竞争条件呢?实际上凡是涉及共享内存、共享文件以及共享任何资源的情况都会引发与前面类似的错误,要避免这种错误,关键是要找出某种途径来阻止多个线程同时读写共享的数据。
换言之,我们需要的是互斥,即以某种手段确保当一个进程在使用另一个共享变量或文件时,其他进程不能做同样的操作。
上面例子的症结在于,线程1对共享变量的使用未结束之前线程2就使用它了。
线程的存在的问题,进程也同样存在。所以我们接下来看看操作系统是如何处理的。
一个进程的一部分时间做内部计算或另外一些不会引发竞争条件的操作。在某些时候进程可能需要访问共享内存或文件,或执行另外一些会导致竞争的操作。
我们把对共享内存进行访问的程序片段称作临界区域或临界区。如果我们能够使得两个进程不同时处于临界区中,就能避免竞争。
为了保证使用共享数据的并发进程能够正确和高效地进行写作,一个好的解决方案需要满足以下4个条件。
- 任何两个进程不能同时处于其临界区
- 不应对CPU的速度和数量做任何假设
- 临界区以外运行的进程不得阻塞其他进程
- 不得使进程无限期等待进入临界区
比较期望的进程行为如上图所示。进程A在T1时刻进入临界区。稍后,在T2时刻进程B试图进入临界区,但是失败了,因为另一个进程已经在临界区内,而一个时刻只允许一个进程在临界区内。所以B被暂时挂起知道T3时刻A离开临界区为止,从而允许B立即进入。最后,在B离开(在时刻T4),回到了在临界区中没有进程的原始状态。
互斥
为了保证同一时刻只能有一个进程进入临界区,我们需要让进程在临界区保持互斥,这样当一个进程在临界区更新共享内存时,其他进程将不会进入临界区,以免带来不可预见的问题。
接下来会讨论下关于互斥的几种方案。
屏蔽中断
单处理器系统中比较简单的做法就是在进入临界区之后立即屏蔽所有中断,并在要离开之前在打开中断。屏蔽中断后,时钟中断也被屏蔽,由于cpu只有发生时钟中断或其他中断时才会进行进程切换,这样在屏蔽中断后CPU不会被切换到其他进程,检查和修改共享内存的时候就不用担心其他进程介入。
中断信号导致CPU停止当前正在做的工作并且开始做其他的事情
可是如果把屏蔽中断的能力交给进程,如果某个进程屏蔽之后不再打开中断怎么办?
整个系统可能会因此终止。多核CPU可能要好点,可能只有其中一个cpu收到影响,其他cpu仍然能正常工作。
锁变量
锁变量实际是一种软件解决方案,设想存在一个共享锁变量,初始值为0,当进程想要进入临界区的时候,会执行下面代码类似的判断
if( lock == 0) {
lock = 1;
// 临界区
critical_region();
} else {
wait lock == 0;
}
看上去很好,可是会出现和文章开头演示代码同样的问题。进程A读取锁变量lock的值发现为0,而在恰好设置为1之前,另一个进程被调度运行,将锁变量设置为1。
当第一个进程再次运行时,同样也将该锁设置为1,则此时同时有两个进程进入临界区中。
同样的即使在改变值之前再检查一次也是无济于事,如果第二个进程恰好在第一个进程完成第二次检查之后修改了锁变量的值,则同样会发生竞争条件。
严格轮换法
这种方法设计到两个线程执行不同的方法
线程1:
while(true) {
while(turn != 0) {
// 空等
}
// 临界区
critical_region();
turn = 1;
// 非临界区
noncritical_region();
}
线程2:
while