第六章 进程管理
第二章已经介绍进程的概念,进程是一个具有独立功能的程序关于某一个数据集合在处理机上的一次执行活动。进程和程序是两个既有联系又有区别的概念,它们的区别的关系可简述如下:
进程是一个动态的概念,而程序是一个静态的概念。程序是指令的有序集合,没有任何的执行含义。而进程则是程序的执行过程,它动态地被创建、调度、执行,直至消亡。当然,进程的执行活动是在程序中事先规定的。形象的比喻就是:若把一个程序看作一个菜谱,那么进程则是按照该菜谱炒菜的过程。
进程具有并行特征,而程序没有。由进程的定义可知,进程具有并行特征的两个方面,即独立性和异步性。也就是说,在不考虑资源共享的情况下,各进程的执行是独立的,执行的速度是异步的。显然,由于程序不反映执行过程,所以不具有并行特征。
进程是竞争计算机系统资源的基本单位,从而其并行性受到系统本身的制约。这种制约就是对进程独立性和异步性的限制。
不同的进程可以包含同一程序,即不同进程可共享同一程序,只要该程序所对应的数据集不同。
本章首先介绍并发进程由于竞争资源而产生的制约—互斥和并发进程由于相互协作而产生的制约—同步,以及这种互斥和同步的实现技术,接着介绍进程之间交换信息的处理方式—进程通信,然后介绍进程内的基本调度单位—线程,最后介绍多个进程由于竞争资源而产生的死锁及其防止、避免和解除方法。
6.1进程管理的背景
并发进程执行可能是无关的,也可能是交往的。无关的并发进程是指它们分别在不同的变量集合上操作,所以一个进程的执行与其它并发进程的进展无关,即一个并发进程不会改变另一个并发的变量值。然而,交往的并发进程,它们共享某些变量,所以一个进程的执行可能影响其它进程的执行结果,因此,这种交往的并发进程执行必须进行合理的控制,否则就会出现不正确的结果。
两个交往的并发进程,其中一个进程对另一个进程的影响常常是不可预期的,甚至是无法再现的。这是因为两个并发进程执行的相对速度无法相互控制,交往的并发进程的速率不仅处理器调度的影响,而且还受到与这两个交往的并发进程无关的其它进程的影响,所以一个进程的速率通常无法为另一个进程所知。因此交往的并发进程的执行就可能产生各种与时间有关的错误。
现以两个例子来说明交往的并发进程产生与时间有关的错误。
例1 现有生产者(producer)和消费者(consumer)两个进程,这两个进程通过一个缓冲区进行生产和消费的协作过程。生产者将得到的数据放入缓冲区中,而消费者则从缓冲区中取数据消费。缓冲区buffer为一有界数组,缓冲区中的数据个数用count变量表示,它们均是两个进程的共享变量。
生产者进程的程序片段代码如下:
               while (count==BUFFER_SIZE) ; //no-op
               // add an item to the buffer
               count++;
               buffer[in] = item;
               in = (in+1) % BUFFER_SIZE;
消费者进程的程序片段代码如下:
               while (count == 0 ) ; //no-op
               // remove an item from the buffer
               count--;
               item = buffer[out];
               out = (out+1) % BUFFER_SIZE;
初看起来这两个进程分别执行时是正确的,但仔细分析考察运行实质,我们可发现,当他们并发执行时,可能产生运行结果不唯一的错误。其主要原因是他们共享了记录缓冲区数据项数目的变量count,而对这共享变量的操作没有加以正确的控制所引起的。
下面我们来分析一下为什么会产生结果不唯一的情形。生产者进程的程序片段中count++语句翻译成机器语言的指令序列如下:
register1=count;
register1= register1+1;
count= register1;
这里的register1是CPU中的一个寄存器。同样,消费者进程的程序片段中count--语句翻译成机器语言的指令序列如下:
register2=count;
register2= register2-1;
count= register2;
这里的register2也是CPU中的一个寄存器。尽管register1和register2可能是同一个物理寄存器,但这个寄存器的内容可由中断处理进行保护和恢复。
count++语句和count--语句的并发执行等价于上述机器语言的指令序列任意顺序的交替执行。假设count的原先的值为6,若CPU把count++语句所对应机器语言的指令序列执行完,再去执行count--语句所对应机器语言的指令序列,则count值为6,这是正确的。若CPU以如下的一个交替顺序执行:
T0: producer  执行 register1=count          { register1 = 6 }
T1: producer  执行 register1= register1+1     { register1 = 7 }
T2: consumer 执行 register2=count          { register1 = 6 }
T3: consumer 执行 register2= register2-1     { register1 = 5 }
T4: producer  执行 count= register1         { count = 7 }
T5: consumer 执行 count= register2         { count = 5}
这样就得到了不正确的状态“count=5”,它表示buffer中有5个数据,而实际上buffer中应有6个数据。如果T4和T5时刻颠倒一下它的执行顺序,则同样也得到不正确的状态“count=7”。
由此可看出:并发进程执行时,由于执行的相对速度无法控制和进程调度不可预测性,它们对共享变量的访问,如不加特定的限制,则将可能产生运行结果不唯一的错误。
例2 假设有两个并发进程borrow和return分别负责申请与归还主存资源,两个并发进程的程序片段如下所示。X表示现有的空闲主存量,为共享变量,B表示申请或归还的主存量。
process  borrow(…,B, …)
     int B;
     {
       if ( B>X ) { 等待主存资源;}
       X=X-B;
       修改主存分配表;
      }
process  return ( …, B,, ..)
     int B,;
     {
       X=X+B;
       释放等待主存资源者;
       修改主存分配表;
     }
若进程borrow在执行了比较B和X的指令后,发现B>X,但在执行“等待主存资源”前,进程调度正好调度进程return执行,它归还了全部主存资源。这时,由于进程borrow还未被置成等待状态,因此,进程return中“释放等待主存资源者”的动作相当于空操作。以后进程调度再调度进程borrow执行时,进程borrow被置成等待主存资源状态,假设这时再也没有return进程来归还主存资源了,从而进程borrow将可能永远等待下去。系统中就出现有永远等待的进程。
从上面两个例子可以看出:由于并发进程执行序列的随机性,会引起与时间有关的错误。这种错误表现为结果不唯一和永远等待两种情况。因此,必须对交往的并发进程执行的制约关系进行详细的分析,并制定控制交往的并发进程能正确执行的方案。
6.2 进程互斥
6.2.1 互斥与临界区
从上面两个例子看出:之所以交往的进程会产生错误,其原因在于两个进程交叉访问的共享变量count或X。我们把并发进程中与共享变量有关的程序段称为“临界区”(Critical section)。如,生产者与消费者两进程中,生产者进程的临界区为:
               count++;
               buffer[in] = item;
               in = (in+1) % BUFFER_SIZE;
消费者进程的临界区为:
               count--;
               item = buffer[out];
               out = (out+1) % BUFFER_SIZE;
与同一变量有关的临界区是分散在各进程的程序中,而进程的执行速度不可预知。如果能保证一个进程在临界区中执行时,不让另一个进程进入相关的临界区执行,那么就不会造成与时间有关的错误。这种不允许两个以上共享共有资源或变量的进程同时进入临界区执行的性质称为互斥(mutual exclusion),即相关临界区的执行必须具有排它性。另外,从进程的程序代码可以看出:互斥通常是由于并发进程共享共有资源或变量而造成的执行速度上的间接制约,这里,“间接”二字指的是各进程的执行速度是受共有资源或变量制约,而不是进程间预定的直接制约。
要保证若干个进程共享共有资源或变量的相关临界区能被互斥地执行,则对这些临界区的管理应有三个要求:
互斥性:如果一个进程在它临界区中执行,其它任何进程均不能进入相关的临界区执行;
进展性:如果一个进程不在它临界区中执行,不应阻止其它任何进程进入相关的临界区执行;
有限等待性:某个进程从申请进入临界区时开始,应在有限的时间内得以进入临界区执行。
上述的要求(1),(2)是保证各并发进程享有平等、独立的竞争和使用公有资源的权利,且保证任何时刻最多只有一个进程在临界区中执行。而要求(3)则是并发进程不发生死锁(死锁的概念将在后面讲述)的重要保证。否则,若有某个并发进程长期占有临界区,其他进程则因为不能进入临界区而处于相互等待状态。
在交往的并发进程执行中,除了因为竞争公有资源而引起的间接制约带来进程之间互斥外,还存在着因为并发进程相互共享对方的私有信息所引起的直接制约。直接制约将迫使各并发进程同步执行。有关直接制约与进程间同步的概念、方法将在后续章节中介绍。下面将介绍互斥的实现方法。

6.2.2 临界区管理的讨论
从60年代开始,不少人对临界区互斥管理的实现技术进行尝试。从实现的途径上看,这些技术可分为两类:即软件实现方法和硬件实现方法。这些技术有的是正确的,可以从一定程度上解决一些问题,有的是不正确的。下面我们来讨论几种实现方案。
6.2.2.1互斥的软件实现方法
标志法
如P1和P2两个进程,它们的程序代码均包含有相关的临界区。我们对P1和P2分别用两个变量inside1和inside2来标志它们是否在临界区中,当进程在它的临界区内时其值为1,不在临界区时其值为0。两并发进程的程序如下:
int inside1, inside2; /*两并发进程共享变量*/
inside1 = 0; /* 表示P1不在临界区内 */
inside2 = 0; /* 表示P2不在临界区内 */
process  P1
{……
 while (inside2) ; /*等待inside2变成0*/
 inside1 = 1;
 临界区;
 inside1 = 0;
 ……
}
process  P2
{……
 while (inside1) ; /*等待inside1变成0*/
 inside2 = 2;
 临界区;
 inside2 = 0;
 ……
}
这个方法存在的问题是;当inside1和inside2均为0时,在P1(P2)测试到inside2(inside1)为0与随后置inside1(inside2)之间,P2(P1)也测试到inside1(inside2)为0,于是将inside2(inside1)置成1,这样两个并发进程同时进入了各自的临界区。这就违反了临界区管理要求(1),即每次至多只允许一个进程进入临界区。
严格轮换法
用一个指针turn来指示应该哪个进程进入临界区。若turn = 0则表示P0可进入临界区;若turn = 1则表示P1可进入临界区。进程程序描述如下:
int turn;
turn = 0;
process  P0
{……
 while (turn==1) ; /*等待turn变成0*/
 临界区;
 turn = 1;
 ……
}
process  P1
{……
 while (turn==0) ; /*等待turn变成1*/
 临界区;
 turn = 0;
……
}
由上述描述可知:turn==i (i为0,1)时进程Pi(i为0,1)才能进入其临界区。因此,一次只有一个进程能进入临界区,且在一个进程退出临界区之前,turn的值是不会改变的,保证不会有另一个进入相关临界区。同时由于turn的值不是0就是1,也不可能同时有两个进程均在while语句上等待而无法进入临界区。
但是,这种方法严格强制了两个进程轮换地进入临界区。当进程P0进入其临界区后,一定要让进程P1进入其临界区。反之,当进程P1进入其临界区后,一定要让进程P0进入其临界区。无法做到进程P0(进程P1)进入其临界区之后,紧接着又再一次进入其临界区,尽管无进程P1(进程P0)在其临界区中。因此,违反了临界区管理的要求(2)。另一个问题是一个进程不能进入临界区时,必须执行while语句而等待,这种等待需要CPU的开销,我们把它称为“忙等待”。
Peterson算法
Peterson算法能正确解决互斥问题。该方法每一个进程设置一个标志,当标志为1时表示该进程请求进入临界区。另外再设置一个指针turn以指示可以由哪个进程进入临界区,当turn等于i时则可由进程Pi进入临界区。可以提供两个函数来管理临界区,这两个函数为enter_region, leave_region,程序描述如下:
int turn;
int flag[2]={0,0};
void enter_region(int process)
{
  int other;
  other = 1-process;
  flag[process ]= 1;
  turn = other;
  while (turn == other && flag[other] == 1) ;
}
void leave_region(int process)
{
  flag[process] = 0;
}
假设两个进程分别以0、1来标识,当进程process在进入临界区时,应调用enter_region(process),而退出临界区时,则调用leave_region(process)。这样一定能保证两个进程互斥地进入临界区。下面我们来分析它的正确性。
当一个进程process执行enter_region(process)函数期间,另一进程process1尚未执行enter_region(process1)函数,这样进程process就可顺利进入临界区。当一个进程process执行enter_region(process)函数期间,另一进程process1已进入临界区且尚未退出,这样进程process就会在while语句上循环执行,等待进程process1退出临界区,然后再进入临界区。当一个进程process执行enter_region(process)函数期间,另一进程process1也在执行enter_region(process1)函数时,则那个先执行turn = other语句的进程将进入临界区,而另一进程将等待进入临界区直至那个进程退出临界区。故Peterson算法能保证临界区管理的正确性。
但是,一个进程不能进入临界区时,也必须通过执行while语句而忙等待,影响了系统的执行效率。
6.2.2.2互斥的硬件实现方法
中断屏蔽方法
从宏观上看,多个进程同时在临界区内执行的原因是:一个进程在临界区内执行时发生了中断事件,而进程调度程序调度了另一进程执行,使之又进入了临界区。一种简单的实现临界区互斥的方法是采用中断屏蔽方法,即当一个进程要进入临界区执行时,采用屏蔽中断的方法使之不响应中断事件,不进行进程切换,保证当前进程把临界区代码执行完,实现互斥执行,然后在开中断。采用中断屏蔽方法在单处理器上实现临界区执行的典型模式如下:
                ……
                屏蔽中断( disable interrupts);
                临界区;
                开中断( enable interrupts);
                ……
采用中断屏蔽方法进行临界区管理的好处是简单直接。但它存在两个缺点:其一系统付出的代价较高。这种关开中断的做法限制了处理器交叉执行程序的能力,若临界区的执行花费较多时间时,系统在这一段时间内,实际上已退化为单进程的执行,影响了系统整体效率,因此系统代价较高。其二这种方法在多处理器系统中是无法实现临界区的互斥执行的,因为在一个处理器上关中断,并不能防止进程在其它处理器上执行其临界区。
硬件指令方法
在多进程环境之所以存在临界区问题,是因为由于多个进程共同访问、修改同一个公共变量。在单机系统中,由于中断的原因,使得一个进程对一个公共变量先取来并检测其值,然后再修改。这样的两个动作通常可能要由2~3条指令来完成,而一条指令执行完,就可能出现中断。因此,在这两个动作之间,就有可能插入其它进程对此公共变量的访问和修改,从而破坏了此公共变量数据的完整性和正确性。许多机器都提供了专门的硬件指令,这些指令允许对一个字的内容进行检测和修正,或交换两个字的内容。这些操作都是在一个存储周期内完成,或者说是有一条指令来完成的。用这些指令就可以解决临界区的问题。测试并设置指令和交换指令就是这样的指令,因而,可以用它们来是实现临界区的互斥执行。
测试并设置指令TS可看作是一个函数过程,它有一个变量flag和一个返回条件值。当TS(&flag)测到flag为0时则置flag为1,且根据测试到的flag值形成返回条件值。该指令功能用C语言描述如下:
int TS( int *flag)
{ int old_flag ;
    old_flag = *flag ;
    *flag = 1;
    return( old_flag);
}
这条指令在微型计算机Z-8000中称为TEST指令,在IBM 370中称为TS指令。
交换指令Swap是实现两个字的内容交换。该指令功能用C语言描述如下:
viod Swap(int *x, int *y)
{ int temp;
  temp = *x ;
  *x = *y ;
  *y = temp;
}
在微型计算机8086或8088中,这条指令称为XCHG指令。
用这些硬件指令可以简单而有效地管理现临界区。其方法是为每一个临界区设置一个整型变量,例如用lock来表示,当其值为0时,则表示临界区未被使用,反之则说明有进程正在临界区中执行。于是某进程用TS指令实现临界区互斥的进程程序结构为:
……
while TS(&lock) do ;
临界区代码 ;
lock = 0 ;
……
用Swap指令来管理临界区时,则进程程序结构为:
……
key = 1;
do
   Swap( &lock, &key);
while (key) ;
临界区代码 ;
lock = 0 ;
……
用上述硬件指令虽然可以有效地保证临界区的互斥执行。但它们有一个明显的缺点,就是与软件方法实现互斥一样,也存在“忙等待”现象,即当有进程正在临界区中执行时,其它想进入临界区的进程必须不断地测试整型变量lock的值,这将造成处理器机时的浪费。
6.2.3信号量及P、V原语
前面我们讨论了用软件和硬件方法解决临界区问题,虽然它们都可以解决互斥问题,特别是,硬件实现方法是十分简单而有效的,但都存在一定的缺陷。而软件实现算法太复杂,效率不高,不但不直观而且是人神秘漠测,难以掌握和应用。于是计算机科学家们又在努力寻找其它更有效的方法。
荷兰著名的计算机科学家Dijkstra,于1965年提出了一个信号量(semaphore)和P、V操作的同步机构。其基本原则是在多个相互合作的进程之间使用简单的信号来协调控制。一个进程检测到某个信号后,就被强迫停止在一个特定的地方,直到它收到一个专门的信号为止才能继续执行。这个信号就称为“信号量”。其工作方式有点类似于十字路口的交通控制信号灯。
信号量被定义为含有整型数据项的结构变量,其整型值大于等于零代表可供并发进程使用的资源实体数,但小于零时则表示正在等待使用临界区的进程数。其数据结构表示如下:
typedef struct
  {
   int value;
   PCB *pointer;
   } semaphore;
对信号量的操作由两个P、V操作原语来实现。所谓原语即是执行时不可中断的过程。P操作原语和V操作原语可分别定义如下:
P操作P(s):将信号量s的整型值减去1,若结果小于0,则将调用P(s)的进程置成等待信号量s的状态。
V操作V(s):将信号量s的整型值加上1,若结果不大于0,则释放一个等待信号量s的进程。
P操作和V操作两个过程可用C语言描述如下:
Viod P(semaphore *s)
{
  s->value = s->value – 1;
  if ( s->value<0 ) {
          insert (CALLER, s->PCB); /*将调用进程插入到等待信号量s的进程队列中*/
          block (CALLER); /*阻塞调用进程*/
            }
}
Viod V(semaphore *s)
{ PCB *proc_id;
  s->value = s->value + 1;
  if ( s->value<=0 ) {
           remove (s->PCB, proc_id ); /*从等待信号量s的进程队列中摘除一个进程*/
           wakeup(proc_id); /*唤醒该进程*/
            }
}
其中insert, block, remove, wakeup均为系统提供的过程。insert (CALLER, s->PCB)是把调用者进程CALLER的进程控制块PCB插入信号量s的等待队列s->PCB中。block (CALLER)是把调用者进程CALLER的状态置成阻塞状态,并调用进程调度程序,以便选择一个新的进程占有处理器运行。remove (s->PCB, proc_id )是从等待信号量s的进程队列中,选一个进程移出队列,并把该进程标识号(或其PCB地址)送入proc_id中。wakeup(proc_id)是把进程标识号为proc_id的进程状态转换成就绪状态。信号量s的整型值的初值可定义为0,1或其它正整数,在系统初始化是确定。
P、V操作原语是一种阻塞等待的同步原语,若进程通过该原语的调用而不允许继续执行时,它将被阻塞或挂起,在此期间就没有机会获得处理器执行,直到它被唤醒为止。故可使得进程在等待进入临界区时,将处理器让给了其它就绪进程执行。而忙等待的临界区管理法,使得进程在等待进入临界区时,也和其它就绪进程一起分享处理器的服务。所以,用P、V操作来解决互斥和同步问题时,将提高系统效率。同步的概念及实现在下一节介绍。
为进一步理解P、V操作的物理含义,我们可以这样来分析与看待:
当信号量s的整型值大于0时,它表示某类公用资源的可用数。因此,每执行一次P操作就意味着请求分配一个单位的该类资源给执行P操作的进程使用,信号量s的整型值应减去1。
当信号量s的整型值小于等于0时,表示已经没有此类资源可供分配了,因此,请求资源的进程将被阻塞在相应的信号量s的等待队列中。此时,s的整型值的绝对值等于在该信号量上等待的进程数。
而执行一次V操作就意味着进程释放出一个单位的该类可用资源,故信号量s的整型值应增加1。若s的整型值还小于等于0,表示在信号量s的等待队列中有因请求该类资源而被阻塞的进程,因此,就把等待队列中的一个进程唤醒,使之转移到就绪队列中去。注意:唤醒的次序依系统而定。
6.2.3用P、V操作实现进程间的互斥
使用上述定义的信号量和P、V操作可方便有效地解决临界区问题。
例如 有两个并发进程insert_item和delet_item分别负责对一个队列进行插入数据项和删除数据项的操作,插入数据项和删除数据项均需要对队列中的指针进行修改。因此,它们对队列中指针的操作是一种互斥关系。
我们可定义一个公共的互斥信号量mutex,其初值设为1,用P、V操作描述insert_item和delet_item进程的程序结构如下:
process  insert_item
  {  ……
     向系统申请一个缓冲区;
     将数据data送入该缓冲区中;
     P(mutex);
     把该缓冲区挂入数据队列中;
     V(mutex);
     ……
   }
process  delet_item
  {  ……
     P(mutex);
     从数据队列中摘除数据项data;
     V(mutex);
     释放数据项data的缓冲区;
     ……
   }
下面我们来总结一下n个进程实现互斥的一般形式。假定mutex是一个互斥信号量,由于每次只允许一个进程进入临界区执行,若把临界区抽象成资源,显然它的可用单位数为1,由信号量的物理含义可知,mutex初值应为1。这样各并发进程的程序描述大致如下:
semaphore mutex;
mutex = 1;
……
process Pi
{
 ……
 P(mutex);
 进程Pi的临界区代码;
 V(mutex);
 ……
}
下面我们进一步分析各并发进程的执行过程和正确性。开始时,信号量mutex的值为1。当有一个进程Pj执行P(mutex)时,mutex的值变为0。这时若有其它进程再执行P(mutex)时,mutex的值将变为小于0,它们均会阻塞在该信号量的等待队列中。当进程Pj执行完其临界区代码,并执行V(mutex)时,若发现mutex的值小于等于0,它会唤醒在该信号量的等待队列中的一个进程,使它能进入其临界区代码执行,之后执行V(mutex)。同理,又唤醒在该信号量的等待队列中的另一个进程,使它能进入其临界区代码执行。因此,一定能保证各并发进程对其临界区的互斥执行。所以,用此框架实现多个并发进程对其临界区的互斥执行是正确的。
需要注意的是:对于正确使用P、V操作实现进程间互斥而言,则当有多个进程在等待进入临界区的队列中排队,而允许一个进程进入临界区时,应先唤醒哪一个进程进入临界区?是不应有刻意要求的。故,在证明使用P、V操作的程序的正确性时,必须证明进程按任意次序进入临界区都不影响程序的正确性。
6.3 进程同步
6.3.1进程同步概念
为了引入进程同步的概念,我们在回过头来分析一下生产者和消费者问题。现有生产者(producer)和消费者(consumer)两个进程,这两个进程通过一个缓冲区进行生产和消费的协作过程。生产者将得到的数据放入缓冲区中,而消费者则从缓冲区中取数据消费。缓冲区buffer为一有界数组。在这个例子中,有两种情况会导致不正确的结果。一种情况是消费者从一个空的缓冲区buffer中取数据,即此时缓冲区buffer中一个数据也没有。如果我们认为消费者已经取走由生产者放入的所有的数据后的缓冲区是空的缓冲区,或者开始时生产者并未存任何数据到缓冲区也是空的缓冲区,那么,从空的缓冲区中取数据就意味着重复取已经取走的数据或取缓冲区中并不是生产者放入的数据,这显然是错误的。正确的做法应该是当缓冲区已空时,消费者就不能再去取数据。另一种情况是生产者把数据存入已满的缓冲区,即如果生产者产生的数据存满了缓冲区而消费者尚未取走过这些数据,则认为缓冲区是满的,那么生产者把数据存入已满的缓冲区就意味着将覆盖尚未消费(取走)的数据,这同样也是错误的。正确的做法应该是当缓冲区已满时,生产者就不能再将数据存入。
上述生产者和消费者问题中出现的两种不正确的结果并不是因为两个进程同时访问共享缓冲区,而是因为它们访问缓冲区的速率不匹配。正确地控制生产者和消费者的执行,必须使它们在执行速率上做到相匹配,即在执行中它们是应相互制约的。这与上一节中介绍的进程互斥是不同的,进程互斥时它们的执行顺序可以是任意的。一组在异步环境下的并发进程,其各自的执行结果互为对方的执行条件,从而限制各进程的执行速率的过程我们把它称为并发进程间的直接制约。实现进程间的直接制约的一种简单而有效的方法是直接制约的进程互相给对方进程发送执行条件已经具备的消息。这样,被制约进程就可省去对执行条件的测试,它只要收到了制约进程发来的消息便可开始执行,而在未收到制约进程发来的消息时便进入等待状态。我们把异步环境下的一组并发进程,因直接制约互相发送消息而进行相互协作、相互等待,使得各进程按一定的速度执行的过程称为进程间的同步。
操作系统中实现进程同步的机制称同步机制。不同的同步机制实现进程同步的方法也不同,迄今,已提出了多种同步机制,本节将介绍两种经典的同步机制:P、V操作和管程。
6.3.2用P、V操作实现进程间的同步
一般来说,可以把各进程发送的消息作为信号量看待。进程同步的信号量与进程互斥的信号量在含义上是有着明显的不同,进程同步的信号量只与制约进程及被制约进程有关,而不是与整组并发进程有关。因此,用于控制进程同步的信号量可称为私有信号量(private semaphore)。一个进程的私有信号量是指从制约进程发来的进程Pi的执行条件所需要的消息。与私有信号量相对应,进程互斥的信号量称为公用信号量(public semaphore)。
有了私有信号量的概念,可以方便地使用P、V操作实现进程间的同步。利用P、V操作实现进程间的同步可按三个步骤来考虑,首先为各并发进程设置私有信号量,然后为私有信号量赋初值,最后利用P、V操作和私有信号量为各进程设计执行顺序。
例1 生产者每次生产一件物品(数据)存入缓冲区,消费者每次从缓冲区取一件物品消费。假定缓冲区只能存放一件物品。我们可为生产者进程和消费者进程设置相应的私有信号量s1, s2,s1表示生产者能否将物品存入缓冲区,s2表示生产者告诉消费者能否从缓冲区中取物品。开始时,缓冲区是空的。显然,s1初值为1,s2初值为0。于是生产者进程和消费者进程的程序描述如下:
semaphore s1, s2;
int B ;
s1.value = 1 ; s2.value = 0 ;
process producer
 {
   int data;
   生产一件物品并暂存在data中;
   P(&s1);
   B = data ;
   V(&s2);
}
process consumer
 {
   int data;
   P(&s2);
   data = B ;
   V(&s1);
   消费data;
}
例2 现有m个生产者和n个消费者,它们共享可存放k件物品的缓冲区。这是一个同步与互斥共存的问题。为了使它们能协调地工作,必须使用公用信号量s,以限制它们对缓冲区的互斥存取,另用两个私有信号量s1和s2,以控制生产者不往满的缓冲区中存物品,消费者不从空的缓冲区中取物品。各进程的程序描述如下:
int buffer[k];
semaphore s1, s2, s ;
int in, out ;
s.value =1 ; s1.value = k ; s2.value = 0 ;
in = 0 ; out = 0 ;
……
process produceri
 {
   int item;
   生产一件物品并暂存在item中;
   P(&s1);
   P(&s);
   buffer[in] = item ;
   in = (in+1) % k ;
   V(&s2);
   V(&s);
}
process consumerj
 {
   int item;
   P(&s2);
   P(&s);
   item = Buff[out] ;
   out = (out+1) % k ;
   V(&s1);
V(&s);
   消费item;
}
……
在这个同步与互斥共存的问题中,对私有信号量和公用信号量的P操作使用次序是有一定要求的。若把生产者进程的两个P操作使用次序交换一下,即程序如下:
process produceri
 {
   int item;
   生产一件物品并暂存在item中;
   P(&s);
   P(&s1);
   Buff[in] = item ;
   in = (in+1) % k ;;
   V(&s2);
   V(&s);
}
那么,当缓冲区中存满了k件物品时,此时s.value =1 ,s1.value = 0 , s2.value = k,生产者又生产了一件物品,它欲向缓冲区存放时将在P(&s1)上等待,但它已经占有了使用缓冲区的权利(现在s.value =0)。这时,消费者欲取物品时将由执行P(&s)而被挂起,它得不到存取缓冲区的权利。从而导致生产者等待消费者取走物品,而消费者却在等待生产者释放缓冲区,这种相互等待永远也无法结束,故产生了死锁现象,关于死锁问题,将在6.5中介绍。
所以在用P、V操作实现同步与互斥共存的问题时,应特别小心P操作的次序,而V操作的次序无关紧要。一般来说,私有信号量的P操作应在前执行,而用于互斥的公用信号量P操作应在后执行。
例3 读者与写者问题。一个数据集(如一个文件或记录)为多个并发进程所共享,其中一些进程只要求读该数据集的内容,这些进程称为“读者”,而另一些进程则要求修改该数据集的内容,这些进程称为“写者”。具体要求是:允许多个读者同时读该数据集的内容,但是,若有一个写者在写,则其他读者不能读,若有一个写者在写或有其他读者在读,则其他写者均被拒绝。
对于读者与写者进程的程序设计来说,必须设置一个公共变量readcount记录当前正在访问该数据集的读者个数,另设置一个互斥信号量mutex用来实现对readcount的互斥修改,再设一个信号量wrt用来实现写者之间的互斥和作为第一个读者读的执行条件,该信号量既是互斥信号量也是同步信号量。
int readcount;
semaphore mutex , wrt;
readcount = 0; mutex.value = 1 ; wrt.value = 1 ;
……
process readeri
{
  P(&mutex) ;
  readcount ++ ;
  if ( readcount == 1 ) P(&wrt) ;
  V(&mutex) ;
  读数据集 ;
  P(&mutex) ;
  readcount -- ;
  if ( readcount == 0 ) V(&wrt) ;
  V(&mutex) ;
}
process writerj
{
  P(&wrt) ;
  写数据集 ;
  V(&wrt) ;
}
……
当一个写者正在写,而有多个读者与写者在等待时,该算法并未考虑优先唤醒写者工作。而更合理的要求是写者应优先唤醒。对此算法的改进,请读者思考与练习。
6.3.3管程
前面我们介绍了用信号量和P、V操作来解决同步与互斥问题。可以看到信号量和P、V操作的使用相当灵活,确实是一个强有力的工具。但是,由于P、V操作分散在各进程的程序中,难以直观地看到同步原语的影响,这往往并发程序设计者带来困难,甚至稍不小心就会出现错误。如,在生产者和消费者问题中,颠倒两个P操作的顺序就会引起死锁。如果能把有关共享变量的操作集中在一起,就可使并发进程之间的相互作用更为清晰。于是Brinch Hansen 和Hoare提出了一种新的同步机制—管程(monitor)。
Brinch Hansen在并发PASCAL语言中,首先实现了管程这一同步机制,并将它作为该语言的一个数据结构类型来描述操作系统有关程序。在该语言中,管程和进程都是操作系统的一个结构成分。管程是管理进程间同步的机制,它可保证进程互斥地访问共享变量,并且提供了一个使用方便的阻塞和唤醒进程的原语。
我们可以这样认为,把系统中的共享资源用数据抽象的形式表示出来,对共享资源的管理就可用数据及在其上实施操作的若干过程来表示。而代表共享资源的数据及在其上实施操作的一组过程就构成了管程。管程是被请求和释放资源的进程所调用。它具有以下基本特性:
局部于管程的数据只能由局部于该管程内的过程存取,不允许进程和其它管程来直接存取;
一个进程只有通过调用管程内的过程才能进入管程存取共享数据;
在任何时刻最多只有一个进程能真正进入管程执行某个内部过程。即进程必须互斥地进入管程调用其内部过程,其它想调用管程内部过程的进程必须等待。
若不考虑第三个特性,管程的概念就非常类似于面向对象语言中对象的概念。现在管程的概念已被并发PASCAL, MODULA等语言作为一个语言的构件,或程序库中的成分而广泛使用。但目前多数语言都不具有管程这一构件或成分,如:C, Java等。然而,还可以由其它的同步机构来实现,如:Hoare采用了P、V操作来实现管程,Hoare的实现法在此就不介绍了。
由于管程是语言的一个构件,管程的过程互斥调用控制成分完全是由编译程序在编译时自动添加上的。所以,程序员无须考虑管程的过程互斥调用问题,用管程来设计进程间的同步算法相当简单明了。下面介绍如何用管程来实现进程间的同步。
为了使管程能用于处理进程间的同步,在管程内应增加用于同步的设施。例如一个进程调用管程内的过程而进入管程,在该过程执行中,发生了必须把该进程挂起阻塞的情况,直到一些条件满足后,才能继续往下执行。因此,必须要有使该进程阻塞并且使它离开管程,以便其它进程可以进入管程执行的设施,同时在以后的某个时候,当被阻塞的进程等待的条件得到满足后,又必须使被阻塞的进程重新进入管程从被阻塞的断点处恢复执行。
这样,在管程定义中应增加一些支持同步的组成部分:
局限于管程并仅能从管程内访问的若干条件变量(condition);
对条件变量进行操作的两个函数过程。
WaitC(C):将调用此函数的进程挂起并阻塞在与条件变量C相应的队列中,同时使其它进程可以进入管程。
SignalC(C):恢复某个由于在条件变量C上执行WaitC操作而被挂起阻塞的进程执行。若没有被挂起的进程,则执行空操作,即什么也不做。
尽管早先由并发PASCAL提供了管程的同步机构,但为了统一的表示法和易理解性,我们在这里用类C语言的来描述管程和用管程来解决进程间的同步问题。
例如:用管程实现生产者和消费者问题。仍然使用有K个缓冲区的环形缓冲区,每个缓冲区可容纳一个数据记录,in是缓冲区的尾指针,out为缓冲区的头指针。另外,再用fulll表示缓冲区已满的条件变量,用empty表示缓冲区已空的条件变量,用count表示当前缓冲区未取走的数据记录数。这样用管程实现生产者和消费者进程同步的程序描述如下:
Monitor boundedbuffer
{
product buffer[K] ;
static int in = 0, out = 0 ,count = 0; /*初始化*/
Condition full, empty ;
public put( product x)
{
  if (count == K ) WaitC(full); /*缓冲区已满,等待*/
  buffer[in] =  x ;
  in = (in+1) % K;
  count ++;
  SignalC( empty );
}
public get( product *x)
{
  if (count == 0 ) WaitC(empty); /*缓冲区已空,等待*/
  *x =  buffer[out] ;
  out = (out+1) % K;
  count --;
  SignalC( full );
}
}
process produceri
{
  product x ;
  生产一件物品x;
  boundedbuffer.put(x) ;/*调用管程过程,将物品放入缓冲区*/
}
process consumerj
{
  product y ;
  boundedbuffer.get(&y) ; ;/*调用管程过程,从缓冲区中取物品*/
消费一件物品y;
}
6.4进程通信
6.4.1进程通信概念
在一个计算机系统中,为了提高资源的利用率和作业的处理速度,常常把一个作业分成若干个可并发执行的进程,这些进程彼此独立地向前推进。但由于它们都是合力地完成一个共同的作业,所以必须保持一定的联系,以便协调地完成任务。这种联系就是指在进程间交换一定数量的信息。我们把一个进程将一批信息发送给另一进程的过程称为进程通信。如:前面介绍的通过信号量和P、V操作交换一些控制信息来实现交往的并发进程的协同工作情形,也可以看作是一种进程通信,不过这种通信交换的信息量有限,通常仅是一些控制信息,所以把这种通信称为低级的进程通信。有时进程之间还需要交换更的信息,例如,一个输入输出操作请求,要求一个进程把一批数据直接传输给另一个进程,这种大信息量的信息传输过程可称为高级的进程通信。实现这种信息传输的方式称为通信机制。这种信息常以一种信件的格式来描述,进程通信即进程间用信件来交换信息。一个正在执行的进程可以在任何时刻向另一个正在执行的进程发送一封信件;一个正在执行的进程也可以在任何时刻向另一个正在执行的进程请求一封信件。如果一个进程在某一时刻的执行依赖于另一进程的信件或接收进程对信件的回答,那么通信机制将紧密地与进程的阻塞和释放相联系。这样的进程间的通信就进一步扩充了并发进程间对数据的共享。
进程通信不仅用于一个作业的诸进程之间交换信息,而且还用于共享有关资源的进程之间及客户/服务器的进程之间交换信息。随着信息技术的快速发展,以及多机系统、网络系统和分布式系统的普及应用,进程间的通信正变得越来越重要、越来越广泛。各种系统不同实现通信的方法可能不同,一般有直接通信和间接通信两种。本节主要介绍这两种的高级通信方式的实现技术。
6.4.2直接通信
所谓直接通信是指发送进程把信件直接发送给接收进程。在这种通信方式下,发送进程必须指出信件发给哪个进程,接收进程也指出从哪个进程接收信件。可采用两个不可分操作send原语和receive原语实现这种通信方式。这两个原语定义如下:
send(P,信件):表示把一封信件发送给进程P;
receive(Q,信件):表示从进程Q处接收一封信件。
在这种通信方法中,两进程Q和P通过执行这两条原语自动建立了一种通信链,并且这一种通信链仅仅发生在这一对进程之间。这种方案在指名方面具有对称性,即发送者和接收者都必须指出对方的名字进行通信。
直接通信的另一种实现方式是非对称指名通信方式。仅仅发送者指出接收者,而接收者不须指出发送者。如信件缓冲就是这样的一种实现方式。在这种方式中,操作系统统一管理一个由缓冲区组成的缓冲池,其中每个缓冲区存放一封信件。当发送进程要发送信件时,先向系统申请一个缓冲区,将信件存入缓冲区,然后把该缓冲区链接到接收进程PCB的信件缓冲队列上,若接收进程正在等待信件,则将接收进程唤醒使它接收信件。当接收进程欲接收信件时,就从信件缓冲队列中接收一封信件,若信件缓冲队列中无信件,则阻塞在信件缓冲队列的信号量上。发送和接收过程如图6.1所示。由于多个进程可同时给一个进程发信,并且在发信和收信时,均要对信件缓冲队列操作,因此还必须要设置一个互斥信号量以保证信件缓冲队列的互斥访问。因此,所采用的数据结构和通信原语算法如下:
数据结构
信件 每封信件至少包括信息:接收进程Id,发送进程Id,信件长度和正文。
信件缓冲区 每个信件缓冲区包括的数据项有:发送进程Id,信件长度,正文和用于形成信件缓冲队列的链指针。
信件缓冲队列 为信件缓冲区的链表结构,其头指针保存在接收进程的进程控制块PCB中。队列可按先进先出或优先级的原则来组织。
信号量sm为信件缓冲队列的信号量。
信号量mutex 为信件缓冲队列操作互斥信号量。
通信原语算法
send(接收进程Id,信件)
{
向系统申请一个信件缓冲区;
将信件存入该信件缓冲区;
据接收进程Id找到其PCB;
P(&mutex) ;
把信件缓冲区链接到接收进程PCB的信件缓冲队列的尾部;
V(&mutex);
V(&sm) ;
        }
receive(信件)
{
P(&sm) ;
P(&mutex) ;
从信件缓冲队列中摘取第一个缓冲区;
V(&mutex);
将该缓冲区中的信息考到信件的存储区域中;
释放该缓冲区;
V(&mutex);
        }
6.4.3间接通信
间接通信是指发送信件进程不是把信件直接发送给接收进程,而是把信件发送到一个共享的数据结构—信箱(mailbox)中,接收进程也到信箱去取信件,即进程间发送或接收信件均通过信箱来进行。当两个进程有一个共享的信箱时,它们就能通信。一个进程也可以分别与多个进程共享多个不同的信箱,因此,一个进程可以同时和多个进程通信。间接通信方式如图6.2所示。在间接通信方式,发送和接收原语的形式如下:
send(B,信件):把一封信件传送到信箱B中。
receive(B,信件):从信箱B中接收一封信件。
信箱是可以存放多封信件的存储区域,每个信箱结构分为信箱特征和信箱体两部分。信箱特征描述信箱容量、指针和信件格式等;信箱体是存放信件的区域,信箱体分成若干个区,每个区存放一封信。
多个发信进程和收信进程对信箱中信件的存放和收取操作,类似于生产者与消费者问题,也必须考虑同步与互斥问题。对send和receive原语的设计可采用P、V操作或管程的方法。在此就一一介绍了,请读者作为练习。
直接通信常用于进程间关系比较密切的情形,而间接通信则用于联系不十分紧密的进程之间通信。另外,间接通信具有较大的灵活性。其灵活性表现在发送进程和接收进程之间的关系可以有一对一、一对多、多对一和多对多的多种关系,以及进程与信箱的关系可以是静态的,也可以是动态的。
“一对一”关系主要用于两个进程间建立私用的通信连接,可以不受其它进程的干扰和影响。“一对多”关系是指一个发送者和多个接收者的通信关系,这种关系可用于一个发送者进程向一组中多个接收进程以广播的方式发送一封或多封信件的应用场合。而“多对一”关系主要用于现代操作系统中的客户/服务器模式下客户进程和服务器进程之间的通信情形。例如,许多客户进程可以向一个打印服务进程发信件请求打印信息。在这种情况下,我们可以把信箱称为端口(port)。
一个信箱可以由一个创建信箱者所拥有,如创建者用系统提供的mailbox说明并创建一个信箱,而其他知道这个信箱名字的进程都可成为它的用户。当拥有信箱的进程执行结束时,它的信箱也就消失,这时必须把这一情况及时通知该信箱的用户。进程与信箱的关系可以是静态的,即固定不变的,长期安排给特定进程使用,直到使用进程或创建进程撤消。进程与信箱的关系也可以是动态的,如在有多个发送者时,多个发送进程与信箱的关系就可以是动态的。为了实现信箱动态连接的目的,系统提供链接(connect)和解除链接(disconnect)原语。在进程通信之前,发送进程调用链接(connect)原语,建立起进程和信箱的链接关系。通信完毕,可用解除链接原语撤消这种链接关系。
6.4.4进程通信的有关问题
缓冲问题
用于存放信件的区域称为缓冲区,在间接通信方式下,这个缓冲区就是信箱体。缓冲区的容量是指缓冲区中存放信件的数量。我们可针对缓冲区容量的三种情况来讨论进程通信的情形。
1) 缓冲区容量为0
即无信箱或缓冲区的情形,前面介绍的指名式的直接通信就是这种情形。在这种情形下,发送者必须等待接收者接收到它所发送的信件后,或者获得了接收者的回答消息后,才能继续执行。
2) 缓冲区容量有界
前面介绍的间接通信就是这种情形。例如,缓冲区容量为n,那么缓冲区至多能存入n封信件。当缓冲区有空时,发送进程直接发送信件无须等待;当缓冲区满时,发送进程将等待缓冲区有空时再发送信件。对接收进程而言,当缓冲区有信件时,就直接从缓冲区中收取信件,否则将等到缓冲区有信件时再执行接收信件操作。
3) 缓冲区容量无界
在这种情况下,缓冲区可存放无限多封信件,因而,发送进程永远无须等待缓冲区。但是,由于内存的有限性,这种方法是无法真正实现的。
并行性问题
当一个进程发送一封信件后,它的执行可分成两种情况。一种是等待收到接收者的回答消息后才继续往下执行,接收者进程在接收到消息前也须等待,直到接收到消息后再向发送者进程发送一个回答消息,这种也称为“双向通信”。另一种是发送信件后立即继续往下执行,直到某个时刻需要接收者进程送来的回答消息时,才对回答消息进行处理。显然,后一种情况并行性要高一些,但是它需要增加两条原语:
answer(P, result):向进程P发送回答消息result。
Wait(Q, result):等待接收进程Q的回答消息result。
6.5多线程
6.5.1线程的概念
为了使系统各部件能最大限度地并行工作,从而最大限度地提高系统效率,一直是计算机系统设计的追求目标。现代计算机系统,无论在硬件系统方面,还是在以操作系统为代表的软件系统方面,都提供了使其各成分并行工作的能力。如:在硬件体系结构方面,出现了流水线计算机、数据流计算机、并行处理器、流水式存储器以及多交叉、多端口存储器等;在操作系统方面,也提供了多进程执行和开发技术。都在不同角度上提高了计算机系统的并行性。那么为什么还要引入线程(thread)概念呢?
当今个人计算机已相当普及,目前个人计算机的处理能力已相当于过去的中小型计算机,尽管大多是以个人独占使用为主,但进一步提高并行性和系统效率仍然很重要。由于这些计算机多数是用于多媒体处理和网络信息服务,而在这些应用中,一个用户的应用,常常包含有多个相对独立的子任务。例如:使用Web浏览器的用户可能一方面想下载某帧图像或文件,一方面又想浏览其它内容或进行其它方面的处理工作。为了加快这些工作的处理和提高效率,一种有效的方法是使系统并行运行以上各个子任务。但是怎么实现呢?在过去只有进程机制的操作系统中,可以用并发程序设计语言来编写此类应用程序。首先为整个应用设置一个进程,然后,由该进程为各个子任务创建相应子进程,这样是可以达到并行处理效果的。但是,进程是分配资源的基本单位,它需要操作系统为之分配相应的地址空间和其它相关的资源,特别是当调度各个子进程并行执行时,需要频繁地进行进程上下文的切换,进程的切换将涉及到有关资源指针的保存及进程地址空间的转换等问题,这些开销的总和,在一定程度上降低了并发进程所带来的利益。于是,就提出了是否能进一步提高系统的执行效率,减少处理机的空转时间和调度切换时间,以及便于系统管理呢?这就需要引入线程(thread)概念。
什么是线程呢?线程是一个进程内相对独立的、可调度的执行单位。这个执行单位既可由操作系统内核控制,也可以由用户程序控制。有些系统把线程称为轻权进程(light weight process)。之所以称之为轻权进程是因为它运行在进程上下文中,并分享分配给进程的资源和环境。线程是由线程控制块TCB、相关堆栈和寄存器组成,堆栈和寄存器用来存储线程内的局部变量,线程控制块TCB用来说明线程存在的标识和记录线程属性及调度信息。
为了进一步理解线程的概念,我们把线程与进程的异同点作一个比较:
进程是资源分配的基本单位,所有与该进程有关的资源分配情况,如打印机、输入输出缓冲队列等,均记录在进程控制块PCB中,进程也是分配主存的基本单位,它拥有一个完整的虚拟地址空间。而线程与资源分配无关,它属于某一个进程,并与该进程内的其它线程一起共享进程的资源。
不同的进程拥有不同的虚拟地址空间,而同一进程中的多个线程共享同一地址空间。
进程调度的切换将涉及到有关资源指针的保存及进程地址空间的转换等问题。而线程的切换将不涉及资源指针的保存和地址空间的变化。所以,从操作系统的开销上看,线程切换的开销要比进程切换的开销小得多。
进程可以动态创建进程。被进程创建的线程也可以创建其它线程。
进程有创建、执行、消亡的生命周期。线程也有类似的生命周期。
在一个多线程的系统中,一个进程内可包含多个线程,它们的关系图6.3所示。

 

 

 

 


使用线程的最大好处是在有多个任务需要处理机处理时,可减少处理机的切换时间,而且,线程的创建和结束所需要的系统开销也比进程的创建和结束要小得多,此外,也方便和简化了多任务的用户程序结构。因此,在用户程序可以按功能不同划分为不同的小段时,在单处理机系统中,使用线程可简化程序的结构和提高执行效率,在多处理机系统中,使用线程可将同一用户程序根据不同的功能划分为不同的线程,并把这些线程调度到不同的处理机上真正实现并行执行。所以在这些场合使用线程都是非常合适的。而在那些很少做进程切换的实时系统、个人数字助理(PDA)系统中,由于任务的单一性,设置线程反而会占用更多的内存空间,就不是很适合了。
线程的典型应用场合可以归纳为:
前后并行工作场合。例如:在表处理进程中,设置一个线程用来显示表单和读取用户输入,设置另一个线程用来执行用户命令和修改表格。由于用户输入命令和命令执行分别由两个不同的线程在前后并行执行,所以提高了系统的效率。
异步处理工作场合。系统和程序中常有一些异步处理的成分,这些成分在执行上并没有严格的顺序规定,特别便于用线程来执行。例如:为了预防掉电故障带来的问题,往往设置一个备份线程,它每隔一分钟把RAM缓冲区的数据和信息写入磁盘。
需要加快执行速度的场合。多线程能够在一个进程的地址空间内并行执行,若一个进程既有计算过程,又有输入过程时,可分别把它们设置成线程,使得一个线程在计算一批数据时另一个线程可以从设备上输入下一批数据,从而加快进程的执行速度。
组织复杂工作的程序。当一个程序要处理的工作较为复杂,它涉及到多个不同的任务、多个不同的数据源及从多个不同的设备上输入输出,那么使用多线程机制可方便程序的设计和组织,同时也可提高整个系统效率。
同时有多个用户服务请求的场合。例如:在一个局域网上的文件服务器中,当一个新的文件服务请求到来时,就为它创建一个新的线程为它负责文件管理工作。当同时有多个用户的文件服务请求到来时,就有多个线程分别为它们进行文件服务,故多个用户可同时获得文件服务。
6.5.2线程的状态与管理
线程是进程中的一个可调度的基本单位,每一个线程应包含以下方面内容:
线程状态;
当线程不执行时,被保护的现场信息,包括程序计数器、程序状态字、通用寄存器和堆栈指针的内容;
一个执行堆栈;
存放每个线程的局部变量的主存区域;
需要访问的同一进程中所有其它线程共享的主存和其它资源。
每一个线程都有它的生命周期,线程状态反映了一个线程在它的生命周期内的活动情况。一个线程在它的生命周期内可能处于三种基本状态之一:
就绪状态:表示线程已具备执行条件,等待调度程序分配CPU运行。
运行状态:表示线程被调度程序选中,并正占有CPU运行。
等待状态:表示线程正在等待某个事件发生。
以上线程的三种基本状态,这些与进程的三种基本状态类似,但是需要注意以下几个问题:
进程有挂起操作,由于各种原因需要挂起进程时,该进程的映象会从主存撤到磁盘。而线程就没有这种概念的挂起操作,因为线程不是资源的拥有者,资源属于进程,所以线程不应有决定将整个进程或自己从主存撤出去的权利;
进程中可能有多个,当有其中一个线程在执行中要求系统服务时(I/O请求),该线程就成为阻塞状态,但该进程中其它线程的可能处于就绪状态,其它线程仍可能占有CPU执行,因此,该进程的状态将可能是就绪或运行状态。正是由于有这一特点,进程中采用多线程机制加快了进程的执行,同时提高了系统的效率。
对多线程进程的状态的考虑,系统不同可能会有差异。由于进程已经不再是调度的基本单位,所以不少系统如Windows NT对进程的状态只划分为活动(可运行)和非活动(不可运行)状态,而挂起状态就是不可运行状态之一。
针对线程的三种基本状态,通常需要有5种基本操作实现线程的状态转换和管理。这5种基本操作是:
派生(spawn):线程是由进程或其它线程创建的,即它可由进程或线程派生。用户一般用系统调用或相应的库函数派生自己的线程。一个新派生出来的线程具有TCB、相应的数据结构指针和变量,这些指针和变量作为寄存器的上下文的存放在相应的寄存器和堆栈中。新派生的线程进入就绪队列。
阻塞(block):若某个线程在执行中需要等待某个事件的发生,则被阻塞。阻塞时,寄存器的上下文、程序计数器及堆栈指针应得到保护。
激活(unblock):若被阻塞的线程的等待事件发生时,则该线程将被激活并进入就绪队列。
调度(schedule):从线程就绪队列中选择一个线程占有CPU执行。
结束(finish):若一个线程执行结束,则释放它的TCB、寄存器的上下文和堆栈。
线程的状态与操作关系如图6.4所示。
注意由于系统不同,线程的状态与操作也不尽相同。图6.5给出了一个Java运行时系统(Java run-time system)的线程状态及转换。当一个线程被创建(调用newthread())时,该线程就成为新线程,它的TCB及线程对象尚未实例化,没有具体内容。在调用start()原语时,该线程被实例化,即指出该线程的指令地址、其它现场信息、系统和用户堆栈指针、优先级等,此时线程具备了运行条件,从而进入可运行状态。运行中的线程调用sleep()、suspend()或由于请求I/O时,该进程就进入阻塞状态。当一个被阻塞的线程被唤醒、被解除挂起、及等待I/O已完成,则被恢复执行(resume())。当运行中的线程调用stop()、exit()或执行结束时,该线程就死亡结束其使命。
线程管理中的另一个问题是同步问题。由于同一进程中的所有线程共享该进程的所有资源和地址空间,任何一个线程共享资源的操作都会给其它相关线程的执行带来影响。因此,必须为线程的执行提供同步控制机制,以防止线程的执行给其它相关线程带来不利的影响。线程中使用的同步控制机制与进程中所使用的同步控制机制相同。在此就不再讨论有关线程的同步控制问题了。

6.5.3线程的实现
目前许多系统都提供对线程的支持。从系统支持线程的角度上看,线程可分为两类:一类是用户级线程,另一类是核心级线程。有的系统使用纯用户级线程,如:Java。有的系统使用纯核心级线程,如:Windows NT和OS/2。而有的系统则混合使用用户级线程和核心级线程,如:Solaris操作系统。
用户级线程(User Lever Threads)是指由用户应用程序建立的线程,并且由用户应用程序负责所有这些用户线程的调度执行和管理工作。而操作系统完全不知道这些线程的存在,操作系统的内核只对进程进行管理。
为了实现用户级线程的管理工作,操作系统提供了一个基于多线程的用户应用程序开发环境和运行环境,我们把它称为线程库(Threads Library)。它可以支持所有用户的创建、调度和管理线程工作。当应用程序提交给系统后,操作系统内核为它建立一个由内核管理的具有线程库运行环境的进程。当用户程序在线程库环境中开始执行时,只有一个线程库为之建立的线程。随着线程的执行,此时具有线程库运行环境的进程处于运行状态,应用程序和线程都是相对于该进程的,线程可以创建其它新线程,它是通过调用线程库中创建线程的过程(如fork())实现的,该进程为它建立一个新的数据结构TCB和用户堆栈,并将它置为就绪状态。再由线程库按一定的调度算法挑选该进程的就绪线程运行,同时保存原运行线程的现场信息。这些活动都是发生在该进程的用户地址空间,内核完全不知道这些活动,内核只是进行进程级的调度活动。其实现方法如图6.6所示。
用户级线程实现特点是:
线程调度算法和过程完全由用户自行选择确定,与操作系统内核无关。在用户级线程系统中,操作系统的调度单位仍是进程。若进程的调度区间为T,则在T区间内,用户可以根据自己的需要设计不同的线程调度算法。
用户级线程的调度只进行线程上下文切换而不进行进程切换,且线程上下文切换是在内核不参与的情况下进行的。
由于用户级线程的上下文切换与内核无关,因此可能出现如下情形:当一个进程由于I/O中断或时间片用完等原因造成该进程处于等待状态或就绪状态,而在该进程中执行的线程仍处于执行状态。
核心级线程(Kernel Lever Threads)是指所有的线程的创建、调度和管理全部由操作系统内核完成。操作系统内核为应用程序提供了相应的系统调用和应用程序接口API,以便用户程序可以创建、执行和撤消线程。当一个用户程序提交给这一类多线程的操作系统运行时,内核为它创建一个进程和一个线程,线程运行过程中可以调用内核创建线程的系统调用创建其它所需线程,所有这些线程均属于该进程。内核为进程在进程控制块PCB中保存该进程作为整体的现场信息或进程上下文,也为该进程内的每一个线程在其线程控制块TCB中保存线程的现场信息。处理机调度是由线程调度程序基于线程进行的。核心级线程实现方法如图6.7所示。
与用户级线程相比其优点是:核心级线程既可以被调度到一个处理机上并发执行,也可以被调度到不同处理机上并行执行,从而可提高程序的执行速度;操作系统内核既负责进程调度工作,也负责进程内不同线程的调度工作,因此不会出现进程处于就绪或等待状态,而其线程处于执行状态的情况;此外,内核过程本身也可以用线程方法实现。
与用户级线程相比其缺点是:由于同一进程中线程的切换要经过两次模式转换,即用户态→核心态→用户态的转换。因为应用程序的线程运行在用户态,而线程调度和有关中断处理程序是运行在核心态的。所以核心级线程的上下文切换时间要大于用户级线程的上下文切换时间。有人曾做过实验分析,在执行Null Fork操作下,用户级线程、核心级线程及进程的上下文切换所需时间分别为34μS、948μS和11300μS。由此可看出用户级线程切换系统开销最小,核心级线程切换系统开销要大用户级线程的一个数量级,而进程的切换系统开销最大。
为了克服纯用户级线程和纯核心级线程的缺点,发挥它们的各自优点,有的系统就把这两种线程的实现结合了起来。这类系统可称为基于多线程的操作系统。内核支持多线程的建立、调度和管理,同时系统又提供线程库,以便让用户应用程序建立、调度和管理其用户级的线程。用户级的线程可被映射到系统空间并转化为核心级线程。其实现方法如图6.8所示。

 

 

 

 

 

 

 

6.6死锁
6.6.1死锁的概念
在引入进程管理的背景和介绍生产者与消费者问题的时候,我们已经初步接触过“死锁”问题了。即如果多个交往的进程程序设计的不恰当的话,会造成一组进程相互等待对方所占有的资源,最终各个进程谁也无法继续执行,形成一组进程处于永远等待的现象。该现象实际上就是本节所介绍的“死锁”现象。
死锁问题首先是由Dijkstra于1965年在研究银行家问题时提出来的,而后Havender, Lynch等人也分别于1968年、1971年相继取得共识并加以发展。实际上死锁问题是一种具有普遍性的现象。不仅在计算机系统存在,而且在日常生活和其它领域也是广泛存在的。
所谓死锁是指一组并发进程彼此相互等待对方所占有的资源,而且这些进程在得到对方的资源之前不会释放自己所占有的资源,从而造成这组进程都不能继续向前推进的状况。我们称这组进程处于死锁状态。具体地说,是存在一组进程P1,  P2,…, Pn, 其中P1占有资源R1同时又申请R2, P2占有资源R2同时又申请R3,…, Pn-1占有资源Rn-1同时又申请Rn, Pn占有资源Rn同时再申请R1, 如图6.9所示。我们就说系统中出现了死锁现象,P1,  P2,…, Pn这组进程处于死锁状态。
例如,再看6.3.2节例2中的生产者与消费者问题。如果我们把互斥信号量(mutex)的P(&mutex)操作放在同步信号量(s1)P操作前面。生产者、消费者进程并发运行时,若生产者超前了,以至于某一时刻,生产者已将缓冲区存满物品,此时又有一个生产者生产了物品欲往缓冲区存放,无人与它竞争缓冲区,它将获得缓冲区的使用权,即执行P(&mutex)操作能顺利通过,但缓冲区已满且s1=0,再执行P(&s1)时将被挂起阻塞在信号量s1上午等待队列中。而以后再有消费者进程欲到缓冲区中取物品时,应该有物品可取,但由于缓冲区的使用权已被刚才的生产者占有,它们将在缓冲区的互斥信号量上等待。同样其它的生产者也因得不到缓冲区的使用权,而在缓冲区的互斥信号量上等待。这样,这组生产者与消费者进程就进入了死锁状态。
又如,系统中有m个进程均需要使用若干的某类资源,该类资源共有n个,而每一个进程最多可使用该类资源的数目为k个,这里的k≤n且k.m>n。若对该类资源的分配不加限制的话,也会出现一组进程处于死锁状态。设m=4, n=4, k=2,4个进程同时先申请一个时,系统均给予分配,之后它们又各自再提出申请1个该类资源,此时已无该类资源可分配,因此,各进程均被置成等待该类资源的状态,而各进程已占有的资源均不释放,故这4个进程就相互等待,从而进入的死锁状态。
再如,对临时性资源(如信件)的使用不加限制也会出现死锁现象。比如:系统中现有三个进程P1, P2, P3, 进程P1在收到进程P3发来的信件m3之后,再给P2发信件m1,进程P2在收到进程P1发来的信件m1之后,再给P3发信件m2,进程P3在收到进程P2发来的信件m2之后,再给P1发信件m3。它们对信件的处理过程如图6.10所示。显然,进程P1, P2, P3均处于死锁状态。
综上所述,我们可以看到,死锁是由于资源的使用不加合理的控制而引起的。这里的资源包括永久性资源和临时性资源,上述的信件、消息就是一种临时性资源,而永久性资源是指所有的硬资源和可再入的纯代码过程。包括因此,必须从资源的性质、资源分配的方法来考虑解决死锁问题。

6.6.2死锁的必要条件
Coffman, Elphick和Shoshani于1971年总结了产生死锁的四个必要条件:
互斥条件(mutual exclusion):一个资源一次只能由一个进程使用,如果有其它进程申请使用该资源,申请进程必须等待直到所申请的资源被释放。
部分分配条件(hold and wait);一个进程已占有一定资源后,执行期间又再申请其它资源。
不可抢占条件(no preemption):一个资源仅能由一个占有它的进程来释放,而不能被其它进程抢占使用。
循环等待条件(circular wait):在系统中存在一个由若干进程申请使用资源而形成的循环等待链,其中每一个进程占有若干资源,同时由又在等待下一个进程所占有的资源。
要防止死锁问题,其根本的办法就是要使得上述四个条件之一不存在。破坏其中之一的必要条件。下面我们来分析一下破坏这些条件的可能性。
第一个可能的途径是破坏条件(1),即破坏互斥条件,允许多个进程同时访问资源。但这受到资源本身的使用性质所确定,有些资源必须互斥访问,不能同时访问。如公用数据的访问必须是互斥的,才能保证数据的完整性。又如打印机资源也必须互斥使用,否则几个进程同时使用,一个进程各打印一行,这种输出信息的方式显然是不能被用户接受的。故,要考虑破坏互斥条件来防止死锁是不切实际的。
第二个可能的途径是破坏条件(3),即破坏不可抢占条件,强迫进程把占有的资源暂时让给其它进程使用。但这种强迫进程让出资源的方法目前也只能适用于CPU和主存这类资源的管理,不能用于大多数的资源管理。即使对于象CPU和主存这类资源,可以抢占使用,但也会为抢占付出较大的代价,不但要增加资源在进程间转移的时间开销,而且还会降低资源的有效利用,所以还须小心加以控制。
第三个可能的途径是破坏条件(2),即破坏部分分配条件,一次性为进程分配所有应使用的资源。
第四个可能的途径是破坏条件(4),即破坏循环等待条件,使运行期间不存在进程循环等待现象。
后两种办法都是可行的,而且也被某些系统所采用。下面我们介绍死锁防止的具体方法。

6.6.3死锁的防止
死锁的防止主要是通过破坏部分分配条件和循环等待条件,从而达到使死锁不发生的目的。其主要方法有:资源静态分配法和资源的层次分配法。
1. 资源静态分配法
资源静态分配法是破坏部分分配条件的死锁防止的方法,它是指一个进程必须在执行前就申请它所需的全部资源,并且直到它所需的资源得到满足后才能开始执行。当然,所有并发执行的进程要求的资源总和不超过系统拥有的资源数。采用静态分配后,进程在执行中不再申请资源,因而不会出现进程占有某些资源又再等待另一些资源的情况,从而能防止死锁的产生。
这种策略实现简单,因而早期的许多操作系统常采用这种方法,例如IBM OS/360。但这种分配策略资源利用率低。因为在每个进程所占有的资源中,有些资源是在进程执行后的较长一段时间后才使用,有时甚至有些资源仅在例外的情况下才被使用。这样,就可能是一个进程占有一些几乎不用的资源,而其它想用这些资源的进程又必须等待,无法投入系统运行。一种改进的策略是,把程序分成几个相对独立的“程序步”来运行,并且资源分配以程序步为单位来进行,而不是以整个进程为单位来静态分配。这样可以较好地提高资源的利用率,减少资源浪费现象,但却增加了应用系统的设计与执行的开销。
2. 资源的层次分配法
这种资源分配策略将阻止循环等待条件的出现。这种资源的层次分配法的思想是:把资源分成多个层次,一个进程得到某一层的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占有的较高层的资源;当一个进程获得了某一层的一个资源后,它想再申请该层中的另一个资源,则必须先释放该层中的已占有的资源。
这种策略的简化方式是资源的按序分配法。它是把系统中所有的资源按一个全序的顺序进行排列,例如,系统共有m个资源,每个资源都分给一个唯一的序号,用ri表示第i个资源,于是这m个资源的排列是:r1, r2, ……, rm。规定任何进程只能在占有资源ri后再申请资源rj (1≤i<j≤m),而占有资源rj后不得再申请资源ri (1≤i<j≤m)。显然,由于对资源的请求作了这种限制,在系统中就不可能形成几个进程对资源请求的循环等待链。因而这种资源的按序分配法可以防止死锁。
资源的层次分配法是基于资源的动态分配的一种方法,与资源静态分配法相比资源的利用率有了较大的提高。但是还要特别小心地安排资源所处的层次,把各进程经常用到的、比较普遍的资源安排在较低的层次上,把那些比较贵重或稀少的资源安排在较高的层次上,便有可能较大限度提高最有价值的资源的利用率。而低层次的资源,在进程即使暂时不使用的情况下,但由于进程需要使用高层次的资源,所以在进程请求分配高层次的资源时,也不得不提前同时申请以后需要的低层次的资源,会造成低层次的资源空闲等待的浪费现象。
该策略虽然已经在许多操作系统中使用,但也存在着如下一些缺陷:
各类设备的资源层次一经排定,不可经常随意改动。若系统要添加一些新设备,就必须重新改写已经存在的程序和系统。
资源层次的安排要大体反映大多数进程使用资源的顺序。对资源使用与此层次相匹配的进程,资源能得到有效的利用,否则,资源的浪费现象将仍然存在。

6.6.4死锁的避免
资源的分配不采用防止死锁的方法时,如果能掌握并发进程与每一个进程有关的资源申请情况,仍然可以避免死锁的发生。这只须在为申请者分配资源前先测试系统的资源状况,若把资源分配给申请者会产生死锁的话,则拒绝分配,否则接受申请并为它分配资源。这就是死锁避免方法的基本思想。
死锁的避免与死锁防止的区别在于,死锁防止是严格地破坏死锁的必要条件之一,使之不在系统中出现。而死锁的避免就不那么严格地限制必要条件的存在,因为死锁的必要条件成立,系统未必就一定发生死锁。因此,为了提高系统的资源利用率,只有当测到死锁有可能出现时,才加以小心避免这种情况的最终发生。著名的避免死锁的方法是银行家算法。
银行家算法首先是由Dijkstra于1965年提出的。银行家问题的直观含义是:一个银行家如何将其总数一定的现金,安全地贷给若干顾客,使这些顾客既能满足对资金的需求又能完成其业务,也使银行家可以收回自己的全部资金,不至于产生死帐而破产。即,一个银行家在考虑若干顾客向他贷款时,要求每一位顾客提前说明所需贷款总额,假如该顾客将要贷款的总额不超过银行家现存的资金总数,银行家就接受该顾客的要求,否则拒绝其要求。
银行家的运作思想也可应用于系统中的资源分配管理中,形成一种资源分配的银行家算法。其基本思想是:检查申请者对各类资源的最大需求量,如果系统现存的各类资源可以满足它的最大需求量时,就满足当前的申请。换句话说,仅仅在申请者获得资源最终能运行完毕,无条件地归还它所申请的全部资源时,才分配资源给它。
假如系统能使当前的全部申请资源者在有限的时间内执行完毕,并归还它所申请的资源,那么当前的状态是安全的,反之当前的状态是不安全的。显然,银行家算法是从当前的状态S出发,逐个检查各申请者中,谁获得资源能完成其工作,然后假定其完成工作且归还全部资源,再进一步检查谁又获得资源能完成其工作,……,若所有申请者均能完成工作,则系统状态是安全的。
例如,假设系统现有三个进程P, Q, R,系统只有一类资源共10个,每个进程使用该资源的总数都小于10,目前分配情况如表6.1所示:


表6.1各进程已占有资源和还需申请资源的情况
 
进程 已占有资源数 还需申请数   
P 4 4   
Q 2 2   
R 2 7 
目前系统仅剩余2个资源。根据银行家算法,先检查各进程还需申请的总数,发现只有Q的申请系统剩余的资源能满足其最大需求,Q有申请就为分配资源,而P, R的申请均应拒绝,Q获得资源后就能执行完毕并归还其全部资源,系统中剩余的资源数为4,再检查P, R两进程,只有P的申请系统的剩余资源能满足其最大需求,P的申请可得到满足,而R的申请均拒绝,P获得资源后就能执行完毕并归还其全部资源,才再为R分配资源,最后R执行完毕并归还其全部资源。故,在这种分配状态下,系统状态是安全的。而其它的任何形式的分配,将会导致系统剩余的资源无法满足任何一个进程的资源需求,从而发生死锁现象,所以其它的分配情形系统状态均是不安全的。
银行家算法可以避免死锁,但它是十分保守的,采用这种算法分配资源时,资源的利用率还比较低;而且这种算法还需要考虑每个进程对各类资源的申请情况,系统需花费较多的时间;此外,进程难以确切知道它所需的最大资源需求量,进程的数目也不固定,随时在变化,操作系统中采用这种方法也很难有效地对资源分配进行控制。

6.6.5死锁检测与恢复
由上面的介绍可以看到,对资源的分配加以限制可以防止和避免死锁的发生,但这些方法都不利于各进程对系统资源的充分共享。实际上,在一个系统中,死锁现象并不是经常出现的,有的系统通常不进行死锁的防止和避免,而是采用“死锁检测与恢复”的方法来解决死锁问题。这种方法对资源的分配不加限制,但系统必须定时或不定时地运行一个“死锁检测”程序,判断系统内是否出现死锁,若检测到死锁则采取相应的办法解除死锁,并以尽可能小的代价恢复相应的进程运行。
对于死锁检测算法,在此我们仅考虑每类资源只有一个实例的情形。对于每类资源具有多个实例的情形,算法会更加复杂一些,它还必须与资源分配算法结合起来,可参阅Coffman等人提出的每类资源具有多个实例的死锁检测算法。
对于所有资源只有一个实例的情形,死锁检测算法可基于等待图(wait-for graph)来检测。等待图是从资源分配图(resource-allocation graph)中得到的。资源分配图是这样的一个图,其中方形结点表示资源,圆形结点表示进程,从方形结点指向圆形结点的有向边表示某资源被某进程占有,从圆形结点指向方形结点的有向边表示某进程申请某资源。从资源分配图中移去资源结点并合并相应的有向边,即可得到等待图。在等待图中,Pi到Pj的边意味着进程Pi正在等待进程Pj释放进程Pi所需的资源。在等待图中存在一条边Pi→Pj当且仅当相应的资源分配图在某资源结点Rq上包括两条边Pi→Rq和Rq→Pj。资源分配图和相应的等待图如图6.11所示。
这样死锁检测算法只要检测出等待图中存在一个环时,就意味着检测到了一个进程循环等待链,因此检测到系统中存在死锁。为了检测死锁,系统必须维护一个等待图的数据结构和定期地在该图中调用寻找环的算法,寻找环的算法所需的时间复杂度为O(n2),n表示等待图中的结点数即进程数。
系统何时进行死锁检测呢?这将依赖于死锁出现的频度和当死锁出现时将影响多少个进程等因素来确定。若死锁经常出现,检测算法应经常被调用。一种可能的方法是当进程申请资源得不到满足就进行检测。但死锁检测过于频繁,系统开销大,而检测的间隔时间太长,卷入死锁的进程又会增多,使得系统资源及CPU的利用率大为下降。一个折中的办法是定期检测,如每一小时检测一次,或在CPU的利用率低于40%时检测。

 

 

 

 

 

 

当死锁检测算法检测出系统中存在死锁时,一种可能的方法是通知操作员哪些进程处于死锁状态,并让操作员手工处理死锁问题;另一种方法是操作系统自动解除死锁并在适当时机恢复相应进程运行。操作系统可有两种方法来解除死锁,一种是撤消进程法,另一种是剥夺资源法。
采用撤消进程法时,可有两种形式来撤消进程。一种是撤消所有卷入死锁的进程。该方法代价巨大,因为有些进程已运行很长时间了,撤消后其中间结果均消失。另一种是一次撤消一个进程直到死锁消失。该方法的开销也可观,因为撤消一个进程后,死锁检测算法还必须继续检测是否还有进程处于死锁状态,此外按什么原则撤消进程也是必须认真考虑的。通常应基于成本来选择撤消进程,选择的原则是:
选择使用处理器时间最少的进程;
选择输出工作量最少的进程;
选择具有最多剩余时间的进程;
选择分得资源最少的进程;
选择具有最小优先级的进程。
采用剥夺资源法时,是从一个或多个卷入死锁的进程中强占资源,再把这些资源分配给卷入死锁的其它进程,以解除死锁。剥夺的顺序可以是以花费最小资源数为依据。每次剥夺资源后,也需要再次调用检测程序。资源被剥夺的进程为了再次得到该资源,必须重新提出资源申请,这样必须返回到分配资源前的某一点处重新执行。
设立检查点是一种恢复进程重新运行的有效方法,这样当进程需要恢复执行时,就可以从该检查点开始重新执行,使得不必前功尽弃且尽可能多地利用已执行的结果,从而提高系统效率。

习题
1. 简述进程与程序的主要区别。
2. 什么叫并发进程的执行产生与时间有关的错误?这种错误表现在哪些方面?试举例说明之。
3. 什么叫临界区?对临界区的管理应符合哪些原则?
4. 传统的软件和硬件方法是可以解决临界区问题的,5. 操作系统为什么还要提供解决临界区问题的控制机制呢?
6. 何谓进程互斥?何谓进程同7. 步?进程互斥与进程同8. 步的主要不同9. 点是什么?
10. 在信号量s上作P、V操作时,11. s的值会发生变化,12. 当s的值大于0,13. s的值等于0,14. s的值小于0时,15. 其物理意义各是什么?
16. 若信号量s表示一种资源,17. 则对s作P、V操作的直观含义是什么?
18. 设有N个进程,19. 共享一个资源R,20. 但每个时刻只允许一个进程使用R。算法如下:
设置一个整型数组flag[N],其每个元素对应表示一个进程对R的使用状态,若为0表示该进程不在使用R,为1表示该进程要求或正在使用R,所有元素的初值均为0。
process Pi
{
 …
 flag[i] = 1;
 for (j=0; j<i; j++)
    do while (flag[i]) ;
 for (j=i+1; j<N; j++)
    do while (flag[i]) ;
 use resource R ;
 flag[i] = 0;
 …
}
试问该算法能否实现上述功能?为什么?若不能请用P、V操作改写上述算法。
21. 有三个进程R, M, P,22. R负责从输入设备23. 读入信息并传送给M,24. M将信息加工并传送给P,25. P将打印输出,26. 写出下列条件下的并发进程程序描述。
(1) 一个缓冲区,(2) 其容量为K;
(3) 两个缓冲区,(4) 每个缓冲区容量均为K。
27. 假定一个阅览室最多可以容纳100人阅读,读者进入和离开阅览室时,都必须在阅览室门口的一个登记表上注册或注销。假定每次只允许一个人注册或注销,设阅览室内有100个座位。
(1) 试问:应编制几个程序和设置几个进程?程序和进程的对应关系如何?
(2) 试用P、V操作编写读者进程的同(3) 步算法。
28. 用管程来管理共享资源时应有什么限制?
29. 用管程解决生产者和消费者问题。假设有一个可以存放1件物品的缓冲器,30. 有m个生产者,31. 每个生产者每次生产一件物品放入缓冲器中,32. 有n个消费者,33. 每个消费者每次从缓冲器中取出一件物品。用管程编制能正确运行的生产者和消费者进程的程序。
34. 何谓进程通信?
35. 进程通信机制中应设置哪些基本通信原语?
36. 简述两种通信方式。
37. 为什么要引入线程概念?有什么利和弊?
38. 何谓线程?是比较进程与线程的异同39. 点。
40. 进程和线程的关系是什么?线程是由进程建立的吗?线程对实现并行性比进程机制有何好处?
41. 线程通常有哪些状态?线程在运行中怎样实现这些状态的转换?
42. 试比较纯用户级线程、纯核心级线程和两者结合方式下实现线程机制的优缺点。
43. 什么叫死锁?举一例说明之。
44. 系统有输入机和打印机各一台,45. 有两进程都要使用它们,46. 采用P、V操作实现请求使用和归还释放后,47. 还会产生死锁吗?若不48. 会,49. 说明理由;若会,50. 你认为应怎样来防止死锁。
51. 若系统有同52. 类资源m个,53. 被n个进程共享,54. 问:当m>n和m≤n时,55. 每个进程最多可以请求多少个这类资源,56. 使系统一定不57. 会发生死锁?
58. 设系统有某类资源共12个,59. 用银行家算法判断下列每个状态是否安全。如果是安全的,60. 说明所有进程是如何能运行完毕的。如果是不61. 安全的,62. 说明为什么可能产生死锁。
状态A                                   状态B
进程     占有资源熟    最大需求          进程     占有资源熟    最大需求
进程1        2            6             进程1        4            8 
进程2        4            7             进程2        2            6 
进程3        5            6             进程3        5            7 
进程4        0            2