临界资源

互斥访问

进程共享各种资源,然而有很多资源一次只能供一个进程使用。一次仅允许一个进程使用的资源称为临界资源,如输入机、打印机、磁带机等许多物理设备都属于临界资源。访问这样的临界资源就需要通过互斥共享方式,在一段时间内只允许一个进程访问该资源。

临界区

每个进程中访问临界资源的代码称为临界区(critical section),实现进程对临界资源的互斥访问可以转变为进程互斥地进入临界区。每个进程在进入临界区之前,需要先对临界资源进行检查。如果此刻临界资源未被访问就允许临界区的访问,并设置它正被访问的标志;如果此刻该临界资源正被某进程访问,则本进程不能进入临界区;进程访问完临界资源后需要归还,并且要去掉被访问的标志。
因此需要在临界区前面增加一段用于检查的进入区(entry section)代码,在后面也要加上一段退出区(exit section)的代码,其它部分的代码称为剩余区。访问临界资源的循环进程可以用伪代码描述:

  1. while(TURE)
  2. {
  3. 进入区
  4. 临界区
  5. 退出区
  6. 剩余区
  7. }

进程同步

需要同步的动机

OS 引入进程后可以让程序并发执行,提高运行效率,但是这也增加了系统的复杂性。如果不能对进程进行管理,在多个进程对临界资源进行争夺时就会带来很多问题。因此为了保证多个进程能有条不紊地运行,需要提供一些进程同步的方式。
进程同步机制是对多个相关进程在执行次序上进行协调,使并发执行的诸进程之间能按照一定的规则(或时序)共享系统资源。

进程的制约关系

进程之间可能存在着以下两种形式的制约关系,由于进程的异步性,如果缺乏进程同步会产生对共享变量或数据结构等资源不正确的访问次序,从而造成进程每次执行结果的不一致。

制约关系 说明
间接制约关系 对于临界资源,必须保证多个进程对其互斥地访问,这些进程形成了间接相互制约关系
直接相互制约关系 某些应用程序建立了多个进程完成同一项任务,这些进程就构成了直接制约关系

同步的原则

实现进程互斥地进入自己的临界区而采用的同步机制应遵循以下原则:

原则 说明
空闲让进 当无进程处于临界区时,应允许一个请求进入临界区的进程立即进入
忙则等待 当已有进程进入临界区时,其它试图进入临界区的进程必须等待
有限等待 对要求访问临界资源的进程,应保证在有限时间内能进入自己的临界区
让权等待 当进程不能进入自己的临界区时,应立即释放处理机

实现同步的原理

在对临界区进行管理时,可以将标志看做一个锁。初始时锁是打开的,每个要进入临界区的进程必须先对锁进行测试,当锁未开时必须等待,直至锁被打开。当锁是打开的时候,应立即把其锁上来阻止其它进程进入临界区。为防止多个进程同时测试到锁为打开的情况,测试和关锁操作必须是连续的。

硬件同步机制

基于软件的方法存在很大的局限性,同步也可以通过计算机提供的一些的硬件指令实现。但是当临界资源忙碌时,其它访问进程得不断地调用硬件进行测试,处于一种“忙等”状态不符合“让权等待”的原则,造成处理机时间的浪费。

关中断

在进入锁测试之前关闭中断,直到完成锁测试并上锁之后才能打开中断。关中断后进程在临界区执行期间,计算机系统不响应中断,从而不会引发进程调度,也就不会发生进程或线程切换。
关中断的方法是一种非常简单的方法,但是关中断时间过长会影响系统效率。关中断意味着所有线程都可以执行特权操作,并相信这些操作不会被滥用。且关中断方法也不适用于多 CPU 系统,因为一个 CPU 关中断后其他 CPU 还是可以访问临界资源。

Test-and-Set 指令

可以使用 Test-and-Set(测试并建立)指令实现互斥,TS 指令的伪代码如下:

  1. boolean TS(boolean *lock){
  2. {
  3. Boolean old;
  4. old = *lock
  5. *lock = TRUE;
  6. return old;
  7. }

可以为每个临界资源设置一个布尔变量 lock 代表该资源的状态,初值为 FALSE 表示该临界资源空闲。进程在进入临界区之前先用 TS 指令测试 lock,如果其值为 FALSE 则可以进入,并将 TRUE 值赋予 1ock。此时 lock = TRUE 表示资源被占用,任何进程检查 lock 后不能进入临界区。利用 TS 指令实现进程同步的伪代码如下:

  1. do{
  2. 进入区
  3. while TS(&lock);
  4. 临界区
  5. lock = FALSE;
  6. 剩余区
  7. }while(TRUE);

Swap 指令

Swap (对换)指令用于交换两个字的内容,对换指令的伪代码如下:

  1. void swap(boolean *a, boolean *b)
  2. boolean temp;
  3. temp = *a;
  4. *a = *b
  5. *b = temp
  6. }

可以每个临界资源设置一个初值为 false 的全局布尔变量 lock,在每个进程中使用一个局部布尔变量 key。Swap 指令实现进程互斥的伪代码如下:

  1. do{
  2. key = TRUE;
  3. do{
  4. swap(&lock,&key);
  5. }while(key!=FALSE);
  6. 临界区
  7. lock = FALSE;
  8. 剩余区
  9. } while(TRUE);

信号量机制

信号量就是用一个变量来表示系统中某种资源的数量,可以利用这种机制来实现同步。

整型信号量

整型信号量定义为一个用于表示资源数目的整型量 S,S 除了初始化外仅能通过两个标准的原子操作 wait(S)signal(S)来访问,这两个操作也可以被称为 P、V 操作。wait 操作的伪代码如下:

  1. wait(S){
  2. while(S<=0);
  3. S--;
  4. }

signal 操作的伪代码如下:

  1. signal(S){
  2. S++;
  3. }

当一个进程在修改某信号量时,由于 P、v 操作是原子操作,因此没有其它进程可同时对该信号量进行修改。

记录型信号量

当整型信号量 s≤0 时就会不断地测试直到它大于 0,因此该机制并未遵循“让权等待”的准则。但是采取了“让权等待”的策略后,又会出现多个进程等待访问同一临界资源的情况。因此除了需要一个用于代表资源数目的整型变量 value,还应增加一个进程链表指针 list 用于链接的所有等待进程,这类信号量称之为记录型信号量

  1. typedef struct{
  2. int value;
  3. struct process_control_block *list;
  4. }semaphore;

将原本的整型变量修改为上述结构体定义的变量后,wait(S) 操作使系统中可供分配的该类资源数减少一个,表示进程请求一个单位的资源,伪代码如下:

  1. wait(semaphore *S){
  2. S->value--;
  3. if(S->value < 0)
  4. block(S->list);
  5. }

signal(S) 操作使可分配的资源增加一个,表示进程释放一个单位的资源,伪代码如下:

  1. signal(semaphore *S) {
  2. S->value++;
  3. if(S->value <= 0)
  4. wakeup(S->list);
  5. }

AND 型信号量

如果一个进程需要多个临界资源才能进行工作,在只获得部分资源时会导致工作无法展开且占用着资源无法释放的情况。AND 型信号量的基本思想是一次性分配进程在整个运行过程中需要的所有资源,待进程使用完后再一起释放。只要尚有一个资源未能分配给进程,其它所有可能为之分配的资源也不分配给它。
wait 操作就需要加入 AND 的条件对多个资源进行判断,伪代码如下:

  1. wait(S1,S2,...,Sn){
  2. while(TRUE)
  3. if(Si >= 1 &&...&& Sn >= 1){
  4. for(int i = 1; i <= n; i++)
  5. Si--;
  6. break;
  7. }
  8. else {
  9. 进程加入等待队列
  10. }
  11. }
  12. }

相应的 signal 操作需要释放进程占用的所有资源,伪代码如下:

  1. Ssignal(S1,S2,..., Sn) {
  2. while(TRUE){
  3. for(int i = 1; i <= n; i++){
  4. Si++;
  5. 将等待该资源的进程移入就绪队列
  6. }
  7. }
  8. }

信号量集

前面的记录型信号量机制中,wait(S) 或 signal(S) 操作仅能对某类临界资源进行一个单位的申请或释放。当一次需要 N 个单位时要进行 N 次操作,不仅效率低,还可能出现获得部分资源导致工作无法展开且占用着资源无法释放的情况。在有些情况下,当所申请的资源数量低于某一下限值时要进行管制,不分配该类资源来保证安全性。因此在分配临界之前,都必须测试资源的数量,判断是否大于可分配的下限值。
因此可以提出信号量集,也就是进程对信号量 S 的测试值可以从 1 改成资源的分配下限值 t,当 S ≥ t 时才能进行资源分配。进程对该资源的需求值为 d,分配资源时进行 S = S - d 一次分配多个资源。对应的 Swait 和 Ssignal 格式为:

  1. Swait(S1, t1, d1,..., Sn, tn, dn);
  2. Ssignal(S1, d1,..., Sn, dn);

信号量的应用

实现进程互斥

为该临界资源设置一个初始值为 1 的互斥信号量 mutex,然后将各进程访问该资源的临界区置于 wait(mutex) 和 signal(mutex)。这样每个欲访问该临界资源的进程在进入临界区之前,都要先执行 wait 操作判断资源是否可用,临界区运行完毕后需要用 signal 操作释放资源。

  1. semaphore mutex=1;
  2. PA(){
  3. 其他代码
  4. wait(mutex);
  5. 临界区
  6. signal(mutex);
  7. 剩余区
  8. }
  9. PB(){
  10. 其他代码
  11. wait(mutex);
  12. 临界区
  13. signal(mutex);
  14. 剩余区
  15. }

实现前趋关系

设有两个并发执行的进程 P1 和 P2 分别有语句 S1 和 S2,希望在 S1 执行后再执行 S2。如果进程之间必须保证执行的顺序是一前一后的,可以设置一个初始值为 0 的同步信号量 S,然后在前操作之后执行 signal(s),在后操作之前执行 wait(s)。前操作执行完之后 s 的会加一,此时后操作调用 wait 满足了条件即可开始执行。

  1. semaphore s = 0;
  2. PA(){
  3. S1
  4. signal(s);
  5. }
  6. PB(){
  7. wait(s);
  8. S2
  9. }

管程

提出管程的动机

信号量机制需要每个要访问临界资源的进程都必须编写 wait(S) 和 signal(S) 的代码,会导致同步操作产生冗余,加大系统管理的复杂性。而系统中的各种资源和软件资源可用数据结构来抽象地描述,因此可以用共享数据结构表示系统中的共享资源,并且将对该共享数据结构实施的特定操作定义为一组过程。这样进程可以通过这些过程,对共享资源执行申请、释放和其它操作。

管程的定义

代表共享资源的数据结构,以及由对该共享数据结构实施操作的一组过程,组成的资源管理程序共同构成了一个操作系统的资源管理模块,称之为管程。对于请求访问共享资源的诸多并发进程,可以根据资源情况接受或阻塞,确保每次仅有一个进程进入管程。通过管程达成该进程对共享资源所有访问的统一管理,有效地实现进程互斥。管程和面向对象编程的对象很相似,由上述的定义可知,它由四部分组成:

  1. 管程的名称;
  2. 共享数据结构说明;
  3. 对该数据结构进行操作的一组过程;
  4. 对共享数据设置初始值的语句。

管程的语法描述如下:

  1. Monitor monitor_name //管程名
  2. {
  3. share variable declarations; //共享变量说明
  4. cond declarations; //条件变量说明
  5. //对数据结构操作的过程
  6. public:
  7. void P1(……){
  8. }
  9. void(){
  10. initialization code; //初始化代码
  11. }
  12. }

管程的特点

  1. 模块化:管程是一个基本程序单位,可以单独编译;
  2. 抽象数据类型:管程中不仅有数据,也有对数据的操作;
  3. 信息掩蔽:管程中的数据结构只能被管程中的过程访问。

    非死锁问题

    非死锁问题占了并发问题的大多数,主要有两种:违反原子性(atomicity violation)缺陷和错误顺序(order violation)缺陷。

    违反原子性

    违反原子性的定义是,违反了多次内存访问中预期的可串行性,即代码段本意是原子的,但在执行中并没有强制实现原子性)。例如如下代码,两个线程都要访问 thd 结构中的成员 proc_info,第一个线程检查 proc_info 非空然后打印,第二个线程设置其为空。如果第一个线程检查之后在 fputs()时中断,第二个线程把指针置为空,则当第一个线程恢复执行时将导致程序奔溃。 ```c Thread 1:: if (thd->proc_info){ fputs(thd->proc_info, …); }

Thread 2:: thd->proc_info = NULL;

  1. proc_info 的非空检查和 fputs() 调用打印 proc_info 需要具有原子性,因此访问 proc_info 字段时需要给变量加锁。
  2. ```c
  3. pthread_mutex_t proc_info_lock =PTHREAD_MUTEX_INITIALIZER;
  4. Thread 1::
  5. pthread_mutex_lock(&proc_info_lock);
  6. if (thd->proc_info) {
  7. fputs(thd->proc_info,…);
  8. }
  9. pthread_mutex_unlock(&proc_info_lock);
  10. Thread 2::
  11. pthread_mutex_lock(&proc_info_lock);
  12. thd->proc_info = NULL;
  13. pthread_mutex_unlock(&proc_info_lock);

违反顺序

违反顺序的定义是,两个内存访问的预期顺序被打破了,即 A 应该在 B 之前执行,但是实际运行中却不是这个顺序。例如如下代码,线程 2 的代码中假定变量 mThread 已经被初始化了,然而如果线程 1 并没有首先执行,线程 2 就可能因为引用空指针奔溃。

  1. Thread 1::
  2. void init(){
  3. mThread = PR CreateThread(mMain,...);
  4. }
  5. Thread 2::
  6. void mMain(...) {
  7. mState = mThread->State;
  8. }

通过强制顺序来修复这种缺陷,可以设置条件变量和加锁来实现。态的变量(mtInit)。初始化代码运行时,会将 mtInit 设置为 1,并发出信号表明它已做了这件事。如果线程 2 先运行就会一直等待信号和对应的状态变化,如果后运行线程 2 会检查是否
mtInit 被设置为 1,然后正常运行。

  1. pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER;
  2. pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER;
  3. int mtInit = 0;
  4. Thread 1::
  5. void init() {
  6. mThread = PR CreateThread(mMain, ...);
  7. pthread_mutex_lock(&mtLock);
  8. mtInit = 1;
  9. pthread_cond_signal(&mtCond);
  10. pthread_mutex_unlock(&mtLock);
  11. }
  12. Thread 2::
  13. void mMain(...) {
  14. pthread_mutex_lock(&mtLock);
  15. while (mtInit == 0) {
  16. pthread_cond_wait (&mtCond, &mtLock);
  17. }
  18. pthread_mutex_unlock(&mtLock);
  19. mState = mThread->State;
  20. }