如何确保共享同一逻辑地址空间(包括代码和数据)的协作进程能有序执行并维护数据的一致性?
一、背景
多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称为竞争条件(或竞态条件)。
为什么出现这种情况呢?
因为再CPU调度发生上下文切换后,一个进程A的上下文被保存,而当这个进程A再次被执行时,此时CPU载入的上下文并没有被其它进程改变,所以即使在这个进程A停止执行后有其它进程对共享的变量进行了改变,但是这个改变没有影响到之前保存的上下文。即进程的调度导致了不确定的结果。
为了防止竞争条件,需要确保一段时间内只有一个进程能操作变量counter。而为了实现这种保证,要求一定形式的进程间同步。
二、临界区域问题
临界区(Critical Section)是指进程中的一段需要访问共享资源并且另一个进程处于相应代码区域时便不会被执行的代码区域。
临界区是互斥的,当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并访问任何相同的共享资源。
死锁(dead lock),两个或以上的进程,相互等待彼此完成自己需要的任务,最终导致双方一直等待。
饥饿:一个可以执行的线程,被调度器持续忽略,以至于处于可执行状态却一直没有被执行。
解决临界区问题要满足的要求:
互斥:同一时间,临界区最多存在一个线程。
Progress:如果一个进程想要进入临界区,那么他一定会进入临界区。
有限等待:如果线程i处于入口区,那么在i的请求被接受之前,其他线程进入临界区的时间是有限制的。
无忙等待:如果一个进程在等待进入临界区,那么在他进入之前可以被挂起。(可选)
三、解决临界区问题的方法
1、禁用硬件中断
当没有硬件中断,就不会有上下文切换,因此没有并发。
硬件将中断处理延迟到中断被启用之后。
大多数现代计算机体系结构提供指令来完成。
进入临界区:禁用中断;离开临界区,开启中断。
这种操作的也有问题,因为一旦中断被禁用,线程就无法被停止。也就是说,整个系统都会为你停下来,这样也可能导致其它线程处于饥饿状态。
要是临界区可以任意长,就无法限制响应中断所需的时间。
所以要小心使用,且不适合多CPU的场景。
2、基于软件的解决方法(略复杂)
Dekker算法,第一个针对双线程例子的正确解决方案。
/*满足两个进程Pi和Pj在临界区互斥的经典的基于软件的解决方法*/
/*Peterson算法*/
/*需要原子的LOAD和STORE指令*/
/*使用两个共享的数据项*/ int turn; //表示该轮到谁进入临界区了。 boolean flag[]; //表示进程是否做好准备进入临界区。 /*Pi的算法*/ do{ flag[i]=true; turn=j; while(flag[j] && turn==j); /*critical section*/ flag[i]=false; /*remainder section*/ }while(true)
n个进程的临界区怎么解决呢?
Eisenberg and McGuire's Algorithm;
Bakery Algorithm:
- 进入临界区之前,进程接收一个数字;
- 得到的数字最小的进程进入临界区;
- 如果Pi和Pj收到相同的数字,那么如果i<j,Pi先进入临界区,否则Pj先进入临界区(进程本身的ID);
- 编号方案总是按照枚举的增加顺序生成数字
如果没有硬件保证,那么也就没有真正的软件解决方案。如Peterson算法要求的原子指令。
3、更高级的抽象——利用原子指令操作。
在硬件提供了一定的原语,如中断禁用、原子操作指令等。多数的现代体系结构都这样。
操作系统提供更高级的编程抽象来简化并行编程,如锁、信号量,他们都是从硬件原语中构建。
这种方法对于多CPU场景也可行。
(1) 锁是一个抽象的数据结构,可以实现互斥
它是一个二进制状态(锁定/解锁),并有两种方法:
Lock::Acquire(),锁被释放前一直等待,然后得到锁;
Lock::Release(),释放锁,唤醒任何等待的程序。
(2) 使用锁来编写临界区,使实现更清晰。
4、基于临界区的特征,决定选择忙等或者非忙等。
四、信号量
为了在更复杂的问题中解决临界区问题,提出了信号量(semaphore)的同步工具。
信号量s是个整数变量,除了初始化外,它只能通过两个标准原子操作wait和signal来访问。这些操作原来被称为P(用于wait,表测试)和V(用于signal,表增加)。
/*wait的经典定义*/ wait(S){ while(S<=0) ; //no-op S--; } /*signal的经典定义*/ signal(S){ S++; }
在wait和signal的操作中,对信号量整数值的修改必须不可分地执行。即当一个进程修改信号量值时,不能有其他进程修改同一信号量的值。此外,对于wait(S),对S数值的测试(S<=0)和对其可能的修改(S--),也必须没有中断地执行。
1、用法
可以使用信号量来解决n个进程的临界区问题。这n个进程共享一个信号量mutex,并初始化为1。每个进程P(i)的组织结构如下:
/*使用信号量的互斥实现*/ do{ wait(mutex); 临界区 signal(mutex); 剩余区 }while(1)
也可以使用信号量来解决各种同步问题。
例如,两个正在执行的并发进程:P(1)有语句S1而P(2)有语句S2,假设要求只有在S1执行完后才执行S2,可以很容易实现这一要求:让P(1)和P(2)共享一个信号量synch,且初始化为0,在进程P(1)中插入语句:
S1;
signal(synch);
在进程P(2)中插入语句:
wait(synch);
S2;
因为synch初始化为0,P(2)只有在P(1)已调用signal(synch),即S1之后,才会执行S2。
2、实现
信号量的主要缺点是要求忙等待,这显然浪费了CPU的时钟,因为这本来可以为其他进程所利用。这种类型的信号量也称为自旋锁(spinlock),因为进程在等待锁时自旋。
自旋锁在多处理器系统中是有用的,其有点是进程必须等待一个锁时无需上下文切换,而上下文切换需要花费相当长的时间。因此,当锁只保留较短时间时,自旋锁利大于弊,值得使用。
如果想要克服忙等,可以修改信号量的wait和signal操作的定义。当一个进程执行wait操作时,发现信号量值不为正的话,则它必须等待,我们把它设计为这时阻塞自己。阻塞操作将一个进程放入到与信号量相关的等待队列中,且该进程的状态被切换成等待状态。接着,控制被转到CPU调度程序,选择一个进程来执行。
一个进程阻塞且等待信号量S,可以在其他进程执行signal操作后被重新执行。该进程的先被wakeup操作唤醒,从等待状态切换到就绪状态,接着进入就绪队列,等待CPU的调度。
信号量的关键之处是他们原子地执行。所以必须确保没有两个进程能同时对同一信号量执行操作wait和signal。这种情况属于临界区问题,参考之前的策略。
3、死锁与饥饿
具有等待队列的信号量的实现可能导致死锁。
一组进程处于死锁是指:组内的每一个进程都等待一个事件,而该时间只可能由组内的另一个进程产生。
也可能导致饥饿~
4、二进制信号量
之前的整数信号量构架称为计数信号量,因为其整数值课跨越一个不受限制的域内。
二进制信号量的值只能为整数值0或者1,根据支持硬件的体系结构,二进制信号量可能比计数信号量更容易实现。(P154)