操作系统中存在多个进程并发访问和操作同一个数据,并且执行结果和进程执行的特定顺序有关,称为:竞争条件。为了防止竞争条件发生,我们需要确保一段时间内只有一个进程能操作这个数据。为了实现这个保证,进程之间必须要同步。
假设一个OS有n个进程,每个进程有一个“临界区”。在该区域进程能改变同一个数据。为了保证竞争条件不发生,我们需要保证在“临界区”只能有一个进程在执行,其他进程不能在“临界区”执行。因此,进程在“临界区”执行的时候是互斥的。临界区问题是设计一个进程能用来协作的协议。每个进程在进入“临界区”执行的时候必须去申请。实现请求的代码称为“进入区”。临界区后面可以有一个“退出区”,其余代码是“剩余区”。
临界区问题
临界区问题的解答必须满足三个条件:
- 互斥:如果进程A在“临界区”正在执行,那么其他进程不能进入“临界区”。
- 有空让进:如果没有进程在“临界区”,此时刚好有进程申请进入“临界区”,那么只有不在“剩余区”的进程能参与申请进入,并且由这些进程决定出进入“临界区”的进程。并且这种抉择不能无限时的推后。
- 在一个进程发出申请到申请被允许期间这段时间,其他进程被允许进入“临界区”的次数存在一个上限。
一个进程的典型结构就是这样的,它有三个区域。
有一种解决n进程临界区问题的算法,该算法称为“面包店算法”。它本来是为解决面包店,熟食店,摩托车存放处等必须在混乱中找到顺序的场合使用的一种调度算法。
在进程创建的时候,进程会收到一个号码,具有最小号码的进程优先,然而,这个号码不保证不重复,因此,当号码重复的时候,pid小的进程先执行。由于进程ID是唯一的,所以该算法是明确的。
共同的数据结构:
boolean choosing[n];
int number[n];
一开始将上面这个数据结构初始化为false和0.那么“面包店算法”被描述如下:
do
{
choosing[i] = true;
number[i] = max(number[0],number[1],...,number[n-1]) + 1;
choosing[i] = false;
for(j = 0; j < n; j++)
{
while(choosing[j]);
//若a<c,则(a,b) < (c,d);若a == c,则(a,b)与(c,d)大小取决于b,d大小
while((number[j] != 0) && (number[j],j) < (number[i],i));
}
//从这儿开始是临界区
number[i] = 0; //从临界区退出的时候把number[i]恢复
//从这儿开始是剩余区
}while(1);
- 当前临界区无进程访问,进程Pi想要访问它,经过设置choosing,那么第一句while循环将不会执行。第二句while循环,只有当 i == j的时候,number[j] != 0;但是此时后面的条件(number[j],j) == (number[i],i);因此while循环也不会执行。当 j != i 的时候,number[j] == 0。直接不满足第一个条件,while循环不会执行。所以,当临界区当前无进程访问的时候,直接让Pi访问临界区。
- 当前临界区有进程在访问,假设是进程Pi在访问,此时进程Pk也想访问临界区。那么Pi在进入临界区的时候也经过了上面的一系列设置,这将导致数据结构中的choosing[i] == false,以及number[k] == number[i];第一句while循环仍没有什么用,第二句while循环中当 j == i 或者 j == k时, number[j] != 0;此时第二个条件也满足,while循环在这里等待。只有当Pi从临界区退出的时候,恢复number[i] == 0,此时Pk才能进入。
看来看去这个第一句while循环好像没什么用,但是
产生的number[i]会重复是因为cpu调度引起的(number[i]的赋值操作不是原子操作)。
choosing[]数组的意义在于防止i进程的number[i]数值不稳定,出现错误。如果没有choosing[]数组的话,反例如下,比如只考虑进程i和j:
假设进程i<j,当i进程的number[i]赋值时,由于cpu的调度,停在了赋值操作,即此时访问number[i]为0,但给number[i]赋值的寄存器中的数为1,赋值完成后number[i]=1.由于cpu的调度,时间片给了进程j,j完成了number[j]的赋值操作,判断number[i]!=0为false,跳过while循环等待,进入临界区;此时cpu调度给进程i,i完成number[i]的赋值操作,此时number[i]的值为1,但判断while中的后半部分时,由于number[i]==number[j],i<j,导致i进程也跳过while循环的等待,进入临界区,这样算法不满足互斥条件。
---------------------
参考资料来源:
原文:https://blog.csdn.net/qq_27736025/article/details/79848434
---------------------
同步硬件
对于单处理器环境,临界区问题可以被简单的解决:在修改共享变量时只要禁止中断(包括软中断)出现。这样,就能保证当前指令的执行不被打断。在多核下这个方案是不行的,因为禁止中断很浪费时间。因此许多系统提供了特殊硬件指令,以允许人们能“原子(不可被中断的过程)”操作。在有特殊指令的计算机上解决临界区问题是很简单的。例如:假设有swap这个操作指令是原子的,那么,互斥可以按照这样来设计。声明一个全局Boolean变量lock,并初始化为false。那么:
do
{
key = true;
while(true == key)
{
swap(lock,key);
}
//......
//临界区
lock = false;
//......
//剩余区
}while(1);
互斥的解决是简单的,但是对于硬件设计人员而言,设计这样一个原子操作指令并不简单。
信号量
同步硬件的解决方案对于程序设计人员而言是简单的,但是它无疑会增加硬件设计人员的工作,以及可能的硬件价格的上涨等问题。而且不适用于所有情形。因此提出了称为“信号量”的同步工具。信号量是个整数,它只能通过两个标准原子操作wait和signal来访问。这些操作原来被称为P(用于wait,表测试)和V(用于signal,表增加)。有时也称为:PV原语。
//wait的经典定义
wait(S)
{
while(S <= 0);
S--;
}
//signal的经典定义
signal(S)
{
S++;
}
可以使用信号量来解决n进程临界区的问题。让这n个进程共享一个信号量mutex,并初始化为1。每个进程的结构如下:
do
{
wait(mutex);
//临界区
//......
signal(mutex);
//剩余区
//......
}
这样,互斥问题就被解决了,当前若有一个进程在临界区执行,那么mutex变量的值就是-1.那么其余进程在进入临界区的时候,wait操作将会阻塞在这里(S == -1满足了条件S <= 0),这样互斥就被简单的解决了。在忙等待这种情形下,信号量的值不可能是负值。
信号量也可以用于解决进程同步问题。这样的方式类似于模拟硬件同步。
当前若有进程在临界区,那么其余试图进入临界区的进程都在代码中一直循环等待,这个忙等待浪费了CPU时间。这种类型的信号量也称为“自旋锁”。如果当锁的时间是短暂的时候,自旋锁有效的避免了进程调度进行的上下文切换。(自旋锁不阻塞进程,不引起进程调度)为了克服忙等待,可以通过修改wait和signal操作的定义来实现当一个进程执行wait操作时,若发现信号量为负值,将等待(while循环)改为阻塞。这时候将它放入与信号量相关的等待队列中。一个进程阻塞并且等待信号量S,可以在其他进程执行signal操作以后被重新执行。
信号量的关键之处在于原子的执行,在单CPU上,可以通过简单的禁止在信号量执行期间发送中断请求来解决这个问题。对于多CPU就不能简单的使用这种办法来保证信号量是原子执行的。这时只能采用前面提到的面包店算法来解决。
现在如果不让进程做忙等待,而是直接引起进程阻塞,从而实现进程的调度。那么重新定义信号量如下:
typedef struct
{
int value;
struct process * L;
}semaphore;
这样每个信号都包含两部分,分别是一个整数值和一个进程链表。当一个进程必须等待信号量时,就加入到进程链表中。signal操作会从进程链表之中唤醒某一个进程。
wait操作重定义如下:
void wait(semaphore S)
{
S.value --;
if(S.value < 0)
{
//add this process to S.L;
block(); //挂起调用进程
}
}
signal操作重定义如下:
void signal(semaphore S)
{
S.value++;
if(S.value <= 0)
{
//remove a process P from S.L;
wakeup(P); //唤醒进程P
}
}
其中block和wakeup是由OS作为基本系统调用来提供的。解决临界区的调用方式仍旧和忙等待一致,先给S.value初始化为1,然后需要进入临界区的进程会调用wait操作,当前进程进入临界区以后S.value == 0;那么第二个进程如果申请进入临界区,S.value == -1,那么这个进程讲会被挂起。当前进程从临界区退出的时候,会调用signal操作来使得S.value++,如果S.value仍然≤0,那么说明有进程被挂起(S.value==1的话,说明没有挂起进程)。这时会从S.L这个链表中唤醒一个进程。
死锁
上述等待信号量的实现可能会导致这样的情况出现:两个或者多个进程在无限等待一个事件的发生,而这个事件本身是在当前等待队列之中的。出现这种情形时这个事件无法执行signal操作,这些进程就称为死锁。下面这种情形就会发生死锁。
P0 | P1 |
wait(S); | wait(Q); |
wait(Q); | wait(S); |
... | ... |
signal(S); | signal(Q); |
signal(Q); | signal(S); |
假设P0进程执行wait(Q);需要的事件是P1进程执行signal(Q);而P1进程执行wait(S)必须等待P0进程执行signal(S);这种情形执行,两个进程在互相等待,造成了P0和P1的死锁。