如何避免多进程(线程)因竞争条件引发的错误?

注:本文主要参考自<<现代操作系统>>第2章

如果避免多进程(线程)因竞争条件引发执行错误?

多进程程序竞争条件

对于多进程或多线程协作程序,如果多个执行程序间需要访问共享内存区域,则程序编写人员一定要仔细判断程序执行逻辑,确保多个程序对共享内存区域的访问不会出现逻辑错误.对于多个进程协作可能因竞争条件引发的执行错误,在多线程编程中同样存在,其解决方法也同样适用于多线程问题,因此后文均以多进程来说明问题.

多进程协作间因竞争条件引发错误的根本问题在于程序并未按照开发者设想的逻辑顺序正常执行,追根溯源,在于开发者编写程序时,通常没有考虑到由于CPU的进程调度,当前进程的执行可能在任意位置发生进程切换.开发者通常认为程序在某一特定区域的执行不会被引发中断,或者没有意识到程序在当前区域中断后,运行其他协作进程可能引发的逻辑顺序错误.

考虑如下打印机程序.生产者进程接受用户打印文件的请求,将待打印的文件名写入到一个文件目录中.消费者进程(执行打印工作)从当前文件目录中取出当前文件名(删除文件名)并打印当前文件名.使用in变量记录生产者进程向文件目录中写入文件名的下一位置,使用out变量记录下一打印文件名的位置.
在这里插入图片描述
生产者进程的伪代码如下所示:

while(true){
	//获取待打印的文件名
	printFileName = getFileName();
	//将文件名写入打印文件夹中
	writeFileNameToPrintFolder(printFileName, printFolder, in);
	in++;
}

假设当前有两个生产者进程A,B同时收到打印文件名,准备执行writeFileNameToPrintFolder写入文件名到打印文件夹中.进程A读取变量in的值为7,进程A写入文件名到文件夹中的位置7处,若此时CPU中断,切换到执行进程B,此时进程B读取变量in 值为7,则进程B将文件名写入到文件夹的位置7处.在此情形下,进程A写入的文件名被进程B覆盖.

发生以上错误的原因在于程序发生了超出开发者设想的不合时宜的切换.如果程序没有在调用writeFileNameToPrintFolderin++间中断,则程序可以正常工作.为什么在这两句代码间不能中断呢?因为这两句代码正在执行修改共享内存区的操作.在本例中.共享内存区内容包括in变量及打印文件夹printFolder. 为了确保程序执行无误,任意时刻,最多只有一个进程正在执行这两句代码.

通常将涉及对共享内存区域中变量进行操作的代码片段称为临界区,因此为了避免多进程中因竞争条件引发的错误,程序开发人员必须保证其撰写的代码,在同一时刻,做多只有一个进程运行在临界区当中.

避免多进程竞争条件

为了避免多进程竞争条件,所提出的方案应当符合如下条件:

  • 任何两个进程不能同时处于临界区
  • 不能对CPU速度和数量作出任何假设
  • 不得使进程无限期等待进入临界区
  • 临界区外运行的进程不得阻塞其他进程

除了最后一个条件外,前三个条件都是可行方案所必须满足的.最后一个方案若不满足,则意味着产生了不必要的等待时间,程序执行效率被降低.

以下提出的几种解决思路均属于忙等待方案.即进程在等待进入临界区时,持续检查是否符合进入条件,CPU持续运转.显然,忙等待造成了不必要的CPU资源浪费.

(1)屏蔽中断

在程序准备进入临界区前,屏蔽所有终端,在结束临界区操作后,打开中断.这样,在执行临界区代码时,进程不会因为任何中断条件发生切换,保证了在每一时刻最多只能有一个进程在执行临界区代码.

尽管从理论上可行,在屏蔽中断方法几乎不会被实际采用.首先.将屏蔽终端的权限交给用户具有很大的风险.例如,如果一个进程在将所有终端屏蔽后并未再次打开,则其余所有进程将用于得不到执行的机会,这可能造成系统的崩溃.另一方面,屏蔽终端方法仅使用於单处理器硬件环境.当前进程执行屏幕终端指令时,其仅能将正在运行该进程的CPU的所有中断屏蔽掉,而其他CPU仍可正常中断,因此其他进程仍然有可能进入临界区.目前,多处理器的硬件环境多已成基本配置,因此屏蔽中断方法基本不具有可行性.

将屏蔽中断的权限限制在内核态是一项非常有用的技术.例如内核在更新变量或列表的几条指令期间可以将中断屏蔽,保证操作的原子性.

(2) 锁变量

可以考虑使用锁变量来实现多个进程的互斥访问.锁变量为0表示当前没有进程位于临界区当中.对于想要进入临界区的进程,其首先检查当前锁变量的值,如果为0,则更新当前锁变量值为非0值并进入临界区,在退出临界区前,将锁变量重新置为0.其伪代码如下:

//非邻接区代码
....
//进入临界区
while(lock != 0){
	lock = 1;
	//临界区代码
	....
	lock = 0;
}
//非临界区代码
...

该方法看似可行,然而仔细分析就会发现,该代码仍然无法实现进程在临界区的互斥.假设进程0准备进入临界区,其检查锁变量,发现其值为0.此时发生时钟中端,切换到进程1,进程1检查锁变量,发现其值为0,然后进入临界区,修改锁变量的值为1,进而开始执行临界区代码.若在执行临界区的过程中,CPU发生时钟中断,再次切换会进程0.此时进程0顺序执行下一条指令,其进入临界区,修改lock变量为1.这时,麻烦来了,进程0和进程1同时进入了临界区.

出现以上问题的原因在于进程对与锁变量的访问与修改不是原子操作,在对锁变量的访问与修改的间隙可能发生时钟中断,因此无法保证仅有一个进程位于临界区.

(3) 严格轮换法

如果我们可以严格制定多个进程在临界区的运行次序,则可以避免多个进程同时进入临界区.
严格轮换法使用变量turn记录当前拥有在临界区运行权限的进程编号,在当前进程执行完临界区代码,准备退出前,其将turn变量设置为下一具有临界区运行权限的进程编号,实现权限的交接,即进程轮换在临界区执行.对于两个守护进程0,和1,该方法伪代码如下:
对于进程0

while(true)//执行非临界区代码
	.......
	//准备进入临界区
	while(turn != 0);
	//进入临界区
	....
	turn = 1; //将临界区执行权限转移给进程1
	//执行非临界区代码
	......

对于进程1

while(true){
	//执行非临界区代码
	......
	//准备进入临界区
	while(turn !=1 );
	//进入临界区
	......
	turn = 0; //将临界区执行权限转移给进程0
	//非临界区代码
	......
}

上述方法是第一个可行的解决方案.分析一下进程执行逻辑,如果当前进程0位于临界区,则turn取值等于0,因此其他的进程无法进入临界区.如果当前进程1位于临界区,则turn等于1,则进程0无法进入临界区.

上述算法尽管可行,但其无法满足前述提到良好解决方案的最后一个条件,即位于非临界区的进程不应当阻塞其他进程.考虑如下情形,假设进程0执行为临界区代码后,turn更新为1,此时进程0开始执行非临界区代码,进程1执行其临界区代码.假设进程1很快执行完了其临界区代码和非临界区代码,将临界区执行权限转移给进程0,turn设置为0.此时进程1准备执行新一轮的临界区代码,它正在等待turn变量被进程0修改为1.而此时若进程0的非临界区代码执行耗时较大,则进程0仍然位于非临界区代码,由于进程0尚未进入临界区,因此无法将临界区执行权限转移给进程1.

因此,我们发现,在严格轮换法中,如果两个进程执行时间差异较大,则可能造成位于非临界区的进程阻塞其他进程.造成较大的CPU资源浪费.

(4)peterson算法

peterson算法简洁易懂,其主要包含两个方法,在进程准备进入临界区前,调用enter_region方法,则进程执行完临界区代码后,准备退出前,调用leave_region方法.
对于包含两个进程的互斥算法,其实现如下:

int[] interested = new int[N]
int turn; //纪录当前有临界区执行权限的进程编号
void enter_region(int process){
	int other = 1 - process;
	interested[process] = true;
	turn = process;
	while(turn == process && interested[other] == true);
}

void leave_region(int process){
	interested[process] = false;
}

上述代码能否实现进程在临界区的互斥呢?
考虑进程0何时能够进程临界区?如果turn == 1或者turn == 0 && interested[1] == false, 则进程0能够进入临界区.
分析第一种情形:如果此时turn==1,是否可能证明进程1不在临界区呢?

证明:如果turn == 1, 则表明在进程0设置完turn之后,发生了进程切换,进程1执行并将turn设置为了1.对于进程1,此时turn == 1 && interested[0] == true, 因此进程1无法进入临界区.因此我们可以证明,对于进程0,如果此时turn==1,则进程1不在临界区,因此进程0可以安全进程临界区.

分析第二种情形,如果turn == 0 && interested[1] == false, 能够证明进程1不再临界区呢?

证明:如果turn == 0, 则此时进程1可能尚未进入临界区,此时进程0可以安全进入临界区.如果进程1当前进入了临界区,则interested[1]等于true, 因此与当前条件interested[1] == false相矛盾,因此在turn == 0 && interested[1] == false时,进程1不在临界区内,因此进程0可以安全进程临界区.

(5) TSL指令

现在来看需要硬件支持的一种方案。某些计算机中,特别是那些设计为多处理器的计算机,都有下面一条指令

TSL RX,LOCK

称为测试并加锁(Test and Set Lock),它将一个内存字lock读到寄存器RX中,然后在该内存地址上存一个非零值。读字和写字操作保证是不可分割的,即该指令结束之前其他处理器均不允许访问该内存字。执行TSL指令的CPU将锁住内存总线,以禁止其他CPU在本指令结束之前访问内存。

在方法3中我们提到,由于读取变量与设置变量并非原子操作,因此在读取变量与设置变量间可能因CPU时钟中断造成进程切换.而TSL指令实现了读取变量与设置变量的原子性.我们可以在方法3的基础上基于TSL指令实现进程在临界区的互斥访问.

着重说明一下,锁住存储总线不同于屏蔽中断。屏蔽中断,然后在读内存字之后跟着写操作并不能阻止总线上的第二个处理器在读操作和写操作之间访问该内存字。事实上,在处理器1上屏蔽中断对处理器2根本没有任何影响。让处理器2远离内存直到处理器1完成的惟一方法就是锁住总线,这需要一个特殊的硬件设施(基本上,一根总线就可以确保总线由锁住它的处理器使用,而其他的处理器不能用)。

为了使用TSL指令,要使用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程都可以使用TSL指令将其设置为1,并读写共享内存。当操作结束时,进程用一条普通的move指令将lock的值重新设置为0。

这条指令如何防止两个进程同时进入临界区呢?解决方案如下图所示。假定(但很典型)存在如下共4条指令的汇编语言子程序。第一条指令将lock原来的值复制到寄存器中并将lock设置为1,随后这个原来的值与0相比较。如果它非零,则说明以前已被加锁,则程序将回到开始并再次测试。经过或长或短的一段时间后,该值将变为0(当前处于临界区中的进程退出临界区时),于是过程返回,此时已加锁。要清除这个锁非常简单,程序只需将0存入lock即可,不需要特殊的同步指令。
在这里插入图片描述
现在有一种很明确的解法了。进程在进入临界区之前先调用enter_region,这将导致忙等待,直到锁空闲为止,随后它获得该锁并返回。在进程从临界区返回时它调用leave_region,这将把lock设置为0。与基于临界区问题的所有解法一样,进程必须在正确的时间调用enter_region和leave_region,解法才能奏效。如果一个进程有欺诈行为,则互斥将会失败.

一个可替代TSL的指令是XCHG,它原子性地交换了两个位置的内容,例如,一个寄存器与一个存储器字。代码下图所示,而且就像可以看到的那样,它本质上与TSL的解决办法一样。所有的Intel x86 CPU在低层同步中使用XCHG指令。

在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章