什么是互斥(Mutual Exclusion)?
顾名思义,互斥就是相互排斥。换句话说,我在做一件事情时,别人不能做。一个生活中的例子可以是银行中客户排队办手续,一个窗口的营业员在某一时间段只能服务一个客户,其他客户在当前窗口客户未办完业务之前,不能插在当前客户办理业务过程中同时与营业员交流。映射到计算机程序中,一个窗口的营业员相当于多个线程需要访问的共同代码段或者共享数据,为了便于描述,我们把它称之为临界区。而多个线程相当于多个尚未办理银行业务的客户。为了保证营业员工作不会出错(出错指临界区的数据被不符合逻辑地篡改),一个窗口的营业员只能处理完一个客户后,再处理下一个。因此,客户需要排队,一个接一个办理业务。即,临界区只能被一个线程完全访问完后,才能被下一个线程访问,这就是互斥。
互斥的重要意义在于保证了多个线程访问临界区时只能 一个接一个 进入。
还有一个生活例子,想象一下在下课铃响后,一大群同学同时冲出教室,走下楼梯,到达教学楼门口时,却发现保安叔叔严守门口,声称门口(临界区)大小只能容纳一个人通过。此时,疯狂并行奔跑的人群停下来,只能乖乖地一个接一个出去。
计算机专业术语解释如下图:
两个线程如何实现互斥?
多个线程同时需要互斥访问临界区,情况看起来很复杂,既然这样,我们先考虑两个线程的情况。上文提到,实现互斥就是遇到临界区时,两个并发执行的线程需要突然停下,一个接一个通过就可以了。至于谁先谁后的顺序问题,并不重要。
如何实现?先看看现实生活中,银行是怎么做的。当一个窗口的营业员正和一位客户办理业务时,该窗口会亮起红灯,表示本窗口已有客户正在服务过程中,请其他客户勿前来。如果窗口空闲,则亮起绿灯以及语音播报,表示其他客户可以到该窗口办理业务。提示灯就承担了说明窗口是否空闲的作用,进而实现互斥。
而在多线程并发中,临界区是也是一段代码或一块共享数据,操作系统在程序未执行之前也不知道这段代码或共享数据内存区有多大,因此,操作系统如何给未知大小的临界区加一个”提示灯“也是个严重问题。最后,我们把”提示灯的模拟“交给各个线程去做。每个线程i各自拥有一面旗帜,flag[i].当线程i的赋值flag[i] == true 表示它将要进入临界区,亮起红灯,其他线程看到它的flag[i] 为true时,则等待在临界区外。
上图为两个线程的互斥实现思路。
并发执行意味着你并不知道A,B程序代码的执行顺序,那么如何验证其正确性?
由于仅有两个线程,不妨枚举一下所有可能的执行顺序。
如上图序号所标的代码行为例,共有六种线程A,B执行顺序。
第一种:
* 1 flag[0] = true;
* 2 while(flag[1]){} //此时flag[1] 为false, while循环结束,A能进入临界区
* 3 flag[1] = true;
* 4 while(flag[0]){} // 此时flag[0] 为true, while循环不结束,B不能进入临界区
第二种:
* 3 flag[1] = true;
* 4 while(flag[0]){} //此时flag[0] 为false, while循环结束,B能进入临界区
* 1 flag[0] = true;
* 2 while(flag[1]){} // 此时flag[1] 为true, while循环不结束,A不能进入临界区
第三种:
* 1 flag[0] = true;
* 3 flag[1] = true;
* 2 while(flag[1]){} //此时flag[0] 为true, while循环不结束,A不能进入临界区
* 4 while(flag[0]){} // 此时flag[1] 为true, while循环不结束,B不能进入临界区
第四、五、六种:1,3,4,2 和 3,1,2,4 和 3,1,4,2 跟第三种情况类似,A,B都不能进入临界区
观察上面结果发现,如果有一个线程A进入了临界区,则它的flag为true,别的线程不能进入临界区,待该线程A退出临界区,置它的flag为false,别的程序B的while循环结束,进入临界区。因此,最终实现了互斥。
然而,另一个问题出现了。如第三、四、五、六种情况所示,所有线程都进入不了临界区!!!这是因为,有可能,所有线程都置各自的flag为true之后,突然发现其他线程的flag都为true,误以为临界区已被占用,进而互相等待”flag[i]=false”出现,陷入死锁状态。
这就是死锁状态!!!!
死锁是怎样产生的?
上述第三、四、五、六种线程执行顺序都有一个共同特点: 每个线程独享一个属于自己的flag变量,当各自线程都置各自的flag[i] 为true时,没有一个线程的while循环能执行完,就导致了互相等待,进入死锁状态。
如何解决两个线程的死锁问题?
问题的关键在于 每个线程独享一个属于自己的flag变量。如果,有一个变量vitctim是两个线程共享的,只要给这个变量赋值为victim == 1,则第1个线程不会进入临界区。而由于victim是两个线程共享的,所以第二个线程能看到victim=1,不是2,则第二个线程能进入临界区。我猜,利用“共享变量” 的特性,就是科学家Peterson解决两个线程死锁问题的思路。
为了便于说明,模拟一个情景:有A,B两人要过海关出镜,A是君子,B是小人,此时海关出口只允许一个人通过,A,B都有护照,那么怎么决定谁先通过呢?答案是:A作为君子,谦让一些,让B小人先通过,然后自己再通过。
上下两图表示情景和两个线程并发代码段的相互映射。在该场景中,代码执行顺序为 3,1,4,2 或者3,1,2,4或者3,4,1,2。总而言之,代码行号执行顺序1在3之后。由于A,B代码段是对称的,因此如果B是君子,A是小人,同理,两个线程也能确保总会促使其中一个线程先通过临界区。综上,两个线程不会出现互相等待观察的死锁状态。
当然,具体严格的数理逻辑证明还需看书,用反证法论证。
如何解决多个线程的死锁问题?
沿着两个线程利用共享变量的思路,我们不难得出 n个线程的死锁的解决方式。
Generalizing to n Threads – The Filter Algorithm
一个n个线程并发执行并且没有死锁出现的假设情景如下图:
在实现了多线程互斥、解决了死锁问题,接下来,我们考虑多线程之间公平执行的问题。
在银行服务中,如果客户都安安分分地排队等候,显然,按照排队的先后顺序办理业务,是公平的;但是,如果不断有人插队,显然,则不能保证按照客户排队顺序服务,不公平。甚至于,如果客户甲前面不断有人插队,则直到下班,客户甲也不能办理业务。
考虑以下三个并发线程,A,B,C 共享一段临界区代码。假设程序A功能是向屏幕输出“Hello World!”,程序B是播放一部2小时的电影,程序C也是播放一部2小时的电影。
假如程序A最新置它的flag[0] = true,宣告A要进入临界区,然后分别是B置flag[1] = true, C置flag[2] = true。
并发代码行执行顺序为 3,5,4,1,6,2 。 则执行完3,5,4 后B成功进入临界区,执行完3,5,4,1,6后,C也成功进入 临界区,最后进入临界区的是A。则程序执行顺序为B,C,A。 结果则是,A本来想首先进入临界区及输出“Hello World!” ,但直到B,C共播放了4个小时电影后,A才真正打印出Hello World. A本来首先宣称要第一个进入临界区的,并且是一个1秒钟就能解决的任务,结果因为在临界区外”饥饿” 等待,足足等了4小时。
就A而言,B,C的插队对它太不公平了。
如何保证多个线程的公平执行?
接下来,我们来看看大名鼎鼎的Bakery Algorithm 是如何保证多个线程的执行公平的。它的基本思想是保证线程“先到先执行” 。更口语化的表达是,“至多做一回谦谦君子,如果这次我让给别人先执行,下一次一定是我执行完,才轮到别人”。
算法的基本代码如下:
该算法使用到一个trick(使用线程ID比较)来解决如果两个线程的label相等,谁先执行的问题。关键在于,线程ID操作系统赋予给线程的,且是唯一的序号。
考虑上图两个并行执行的程序A,B,如果代码行的执行顺序为 1,3,2,4 则label[i] 和label[k] 相等。 那么在while 循环中则利用线程i和线程k的线程ID做比较,线程ID小的先执行。