协助进程能与系统内的其他执行进程相互影响。共享数据的并发访问可能导致数据的不一致。
多个进程并发访问和操作同一数据并且执行结果与特定访问顺序有关,称为竞争条件。为了防止竞争条件,就需要保证一次只有一个进程可以操作数据。为了做出这些保证,就要求这些进程按一定方式来同步。
6.1 临界区问题
临界区
假设某个系统有n个进程{P0、P1、...、Pn-1},每个进程都有一段代码,称为临界区(critical section)。进程在执行该区时可能修改公共变量等。
该系统的重要特征是,当一个进程在临界区执行时,其他进程不允许在它们的临界区执行。
注意:临界区不是一个公共的程序区,而是每个进程各自独立拥有自己的临界区。
在进入临界区之前,每个进程应请求许可,实现这一请求的代码段称为进入区。临界区之后可以有退出区,其他代码为剩余区。
临界区问题的解决方案应满足以下三条要求:
- 互斥(mutual exclusion):如果进程Pi在其临界区内执行,那么其他进程都不能在其临界区内执行。
- 进步(progress):如果没有进程在其临界区执行,并且有进程需要进入临界区,那么只有那些不在剩余区内执行的进程可以参加选择,以便确定谁能下次进入临界区,而且这种选择不能无限推迟。
- 有限等待(bounded waiting):从一个进程做出进入临界区的请求直到这个请求被允许为止,其他进程允许进入其临界区的次数具有上限。
两种常见的处理操作系统的临界区问题:
- 抢占式内核:允许处于内核模式的进程被抢占。响应快,更适用于实时编程。
- 非抢占式内核:不允许被抢占,一直运行直到退出内核模式、阻塞或自愿放弃CPU控制。基本不会造成竞争条件。
6.2 Peterson 解决方案
经典的基于软件的临界区解决方案。适用于两个进程交错执行临界区和剩余区。
//两个进程Pi 和 Pj,共享两个数据项 int turn; //表示哪个进程可以进入临界区 boolean falg[2]; //true表示哪个进程准备进入临界区 //Peterson解答的进程pi的结构 do { flag[i] = true; trun = j; while ( flag[j] && turn == j ); //最早让步的最先进临界区 临界区 flag[i] = false; 剩余区 } while(true);
注意:Pj也会执行类似的结构
6.3 硬件同步
通过加锁来保护临界区。
对于单处理器环境可以简单解决:
在修改共享变量时禁止中断出现,这样就能确保当前指令流可以有序执行,且不会被抢占。往往被非抢占式内核采用。
对于多处理器环境:
中断禁止不可行,因为中断禁止很耗时,这是因为消息要传递到所有处理器。
许多现代系统提供特殊硬件指令,原子地交换两个字(作为不可中断的指令)。
6.4 互斥锁
临界区问题的基于硬件的解决方案不但复杂,而且不能为程序员直接使用。因此OS设计人员构建软件工具来解决。最简单的是互斥锁(mutex lock)。
一个进程在进入临界区时得到锁,退出临界区时释放锁。函数acquire()获取锁,而release()释放锁。
每个互斥锁有一个布尔变量available,它的值表示是否可用。如果锁是可用的,那么调用acquire()会成功,并且锁不再可用。当一个进程试图获取不可用的锁时,它会阻塞,直到锁被释放。
acquire() { while ( !available ) ; //busy wait available = false; } release() { available = true; }
对acquire()和release()的调用必须是原子地执行(原子操作是不可分割的,在执行完毕之前不会被其他任何事务中断)。
这里实现的主要缺点是需要忙等待(busy waiting)。当由一个进程在临界区中,任何其他进程在进入临界区时必须连续地调用acquire()。其实,这种类型的互斥锁也被称为自旋锁(spinlock),因为进程不停地旋转,以等待锁变得可用(在硬件同步里也会出现)。在实际多道程序系统中,即当多个进程共享一个CPU时,忙等待浪费CPU周期,而这原本可以有效地用于其他进程。
不过自旋锁有一个优点:当进程在等待锁时,没有上下文切换(这可能需要很长时间)。自旋锁通常用于多处理系统。
6.5 信号量
功能类似于自旋锁,但是更高级和鲁棒(Robust,音译,即为健壮的意思)。
一个信号量(semaphore)S 是个整型变量,它除了初始化外只能通过两个标准的原子操作:wait()(最初称为P,荷兰语proberen,测试)和 signal()(最初称为V,荷兰语verhogen,增加)来访问。
wait (S) { while ( S <= 0 ) ; //busy wait S--; } signal (S) { S++; }
6.5.1 通信量的使用
OS通常区分:
- 计数信号量:值不受限制。
- 二进制信号量:值只能为1 或 0。
二进制信号量类似互斥锁,没有提供互斥锁的系统上,可以使用二进制信号来提供互斥。
计数信号量可以用于控制访问具有多个实例的某个资源。信号量的初值为可用资源量,当进程需要资源时,对信号量做wait()操作,进程释放资源时,需要对该信号量执行signal()操作。信号量为0表示所有资源都在使用中。之后使用资源会发生阻塞,直至计数大于0。
6.5.2 信号量的实现
为了克服忙等待,当一个进程执行wait()发现信号量<=0时,该进程不是忙等待,而是阻塞自己。阻塞操作将一个进程放到与信号量相关的等待队列中,并将该进程的状态切换成等待状态。然后控制转到CPU调度程序,以便选择一个进程。
等待信号量 S 而阻塞的进程,在其他进程执行操作 signal()后,应该重新被执行。进程的重新执行是通过操作 wakeup()来进行的,它将进程从等待状态改为就绪状态,然后进程被添加到就绪队列。
如下定义信号量、wait()和 signal()操作:
typedef struct{ int value; struct process * list; }semaphore; wait(semaphore *S){ S->value--; if (S->value < 0){ add this process to S->list; block(); //挂起调用它的进程 } } signal(semaphore *S){ S->value++; if (S->value <= 0){ remove a process P from S->list; wakeup(P); //重新启动阻塞程序P的执行 } }
关键的是信号量操作应该是原子地执行。应保证对于同一信号量,没有两个进程可以同时执行操作wait()和signal()。对于单处理器环境,可以简单禁止中断。对于多处理器环境,每个处理器的中断都应该被禁止。
这里并没有完全取消忙等待,只是将忙等待从进入区移到临界区。这些区比较短,临界区几乎不会被占用,忙等待很少发生,而且所需时间很短。
6.5.3 死锁和饥饿
具有等待队列的信号量实现可能导致这样的情况发生:两个或多个进程无限等待一个事件,而该时间只能由这些等待进程之一来产生。这里的事件是执行操作signal()。当出现这种状态时,这些进程就为死锁。
无限阻塞或饥饿,即进程无限等待信号量。
6.5.4 优先级的反转
假设三个进程L、M、H,优先级顺序是L < M < H。假定进程H需要资源R,而R目前正在被进程L访问。通常H将等待L用完资源R。但是,现在假设进程M进入可运行状态,从而抢占了进程L。间接的,具有较低优先级的进程M影响了进程H应等待多久,才会使得进程L释放资源R。这个问题称为优先级反转。
一个解决方案是只有两个优先级。
优先级继承协议:所有正在访问资源的进程获得需要访问它的更高优先级进程的优先级,直到它们用完了有关资源,后恢复原来的优先级。
6.6 经典同步问题
6.6.1 有界缓存问题
生产者-消费者问题:生产者进程产生信息,以供消费者进程消费。解决方法共享内存,二者并发执行,有一个固定大小的缓冲区,如果缓冲区为空,那么消费者必须等待;如果缓冲区已满,则生产者必须等待。
生产者和消费者共享以下数据结构:
int n; //n个缓冲区 semaphore mutex = 1; //提供访问缓冲池的互斥要求 semaphore empty = n; //空的缓冲区的数量 semaphore full = 0; //满的缓冲区的数量
生产者进程结构
do { ... /*produce an item in next_produced*/ ... wait(empty); wait(mutex); ... /*add next_produced to the buffer*/ ... signal(mutex); signal(full); } while(true);
消费者进程结构
do { wait(full); wait(mutex); ... /*remove an item from buffer to next_consumed*/ ... signal(mutex); signal(empty); ... /*consume the item in next_consumed*/ ... } while(true);
6.6.2 读者 - 作者问题
一个数据库为多个并发进程所共享。读者:只需读取数据库数据;作者:更新数据库。
最简单的问题,通常称为第一读者-作者问题,要求读者不应保持等待,除非作者已经获得权限使用共享对象。第二读者-作者要求一旦作者准备就绪,那么作者会尽快的执行。
这两个问题的解答都可能导致饥饿。第一种情况,作者可能会饥饿;第二种情况,读者可能会饥饿。
这里只讨论第一读者-作者问题。
读者进程共享以下数据结构:
semaphore rw_mutex = 1; //读者和作者进程共用 /*信号量rw_mutex供作者作为互斥信号量,也作为第一个进入临界区和最后一个离开临界区的读者所使用,而不为其他读者所使用*/ semaphore mutex = 1; //用于确保更新变量read_count时的互斥 int read_count = 0; //用于跟踪多少进程正在读对象
作者进程结构:
do { wait(rw_mutex); ... /*writing is performed*/ ... signal(rw_mutex); } while(true);
读者进程结构:
do { wait(metux); read_count++; if (read_count == 1) //第一个读者 wait(rw_mutex); signal(mutex); ... /*reading is performed*/ ... wait(mutex); read_count--; if (read_count == 0) //最后一个读者 signal(rw_mutex); signal(mutex); } while(true);
6.6.3 哲学家就餐问题
...
6.6.4 理发师问题
...
int charis = n; //空椅子数 int customers = 0; //顾客数 int barbers = 0; //理发师数 int mutex = 1; //barber: do{ wait(customers); wait(mutex); chairs++; signal(barbers); signal(mutex); //理发 } while(true); //customer do{ wait(mutex); if (chairs > 0){ chairs--; signal(customers); signal(mutex); wait(barbers); } else signal(mutex); } while(true);