word文档插入图片和表格


第一章 绪论

操作系统概述

一个完整的计算机系统主要由硬件和软件组成。硬件是软件建立与活动的基础,而软件是对硬件功能的扩充。硬件包括CPU、内存、I/O设备和总线等。软件通常分为应用软件、支撑软件和系统软件。操作系统是裸机之上的第一层系统软件。它向下管理系统中各种资源,向上为用户和程序提供服务。

操作系统是控制和管理计算机系统内各种硬件和软件资源、有效地组织多道程序运行的系统软件(或程序集合),是用户与计算机之间的接口。操作系统是由一系列程序模块和数据组成的。它的基本功能是管理系统内各种资源和方便用户的使用。具体有五大功能,即:进程和处理机管理、存储管理、设备管理、文件管理和用户接口管理。

看待操作系统有不同的观点,主要是资源管理观点和扩展机器观点。上述五大功能就是从资源管理的观点出发,看待操作系统应完成的任务。从扩展机器的观点出发,操作系统的任务是为用户提供一台比物理计算机更容易使用的虚拟计算机。

操作系统的形成和发展是与计算机硬件发展密切相关的。随着电子元器件的不断更新换代,操作系统的理论和技术也逐渐成熟和完善。反过来,操作系统的发展对硬件也提出更高的要求。

从传统意义上讲,操作系统的基本类型有批处理系统、分时系统和实时系统三种。现在,计算机技术得到飞速发展和广泛应用,操作系统也形成更多类型,如个人机操作系统、多处理器操作系统、嵌入式操作系统、网络操作系统和分布式操作系统。

各种操作系统有不同的设计目标,具有不同的性能。但操作系统作为整体,有自己的基本特征,这就是并发、共享、不确定性和抽象性。并发不同于并行,它是宏观上的并行、微观上的串行。在单CPU的情况下,多道程序的并发活动很明显。共享反映出系统资源的共用。这两点也决定了操作系统具有不确定性—— 程序的动态活动要受现场环境的影响。所以,操作系统要对系统中所有资源实施统一调度和管理,使各种实体充分并行,安全地共享资源,约束和协调进程间的关系。

UNIX系统和Linux系统是当代最著名的多用户、多进程的分时操作系统,各自具有一系列特点。尤其是Linux系统,其技术和应用都得到迅速发展。

第二章 进程管理

本章介绍了处理机调度中的进程调度,接触的是操作系统中最重要的概念——进程。

学习目标

通过本章的学习,我们希望您能够:

  1. 深入理解引入“进程”概念的原因,简述进程的概念、进程与程序的区别和联系;能够简述进程的三种基本状态及其转换条件,并能解决综合应用问题;简述进程的3个组成部分,特别要明确进程控制块PCB的作用。
  2. 理解引入线程的原因,线程的概念、组成和状态,了解线程的实现。
  3. 深入理解进程的同步和互斥的含义,并能用信号量和P、V操作解决一般的同步互斥问题。了解高级进程通信的3种方式。
  4. 简述死锁产生的根本原因、死锁的必要条件以及解决死锁的三种方法。
  5. 理解进程族系、进程管理的主要内容、Linux进程的5种状态和结构,学会使用常用的进程的操作命令和系统调用。
  6. 本章需要学习6个知识点,其中带★的为本章核心知识点。
    img

1.进程概述

学习了这个知识点,你要深入理解引入“进程”概念的原因,简述进程的概念、进程与程序的区别和联系;能够简述进程的三种基本状态及其转换条件,并能解决综合应用问题;简述进程的3个组成部分,特别要明确进程控制块PCB的作用。

(一)进程的定义

1.为何要引入进程的概念

大家还记得第1章学习的操作系统的特征吗?即并发、共享和异步性。其中并发是操作系统最重要的特征。并发是指两个或多个活动在同一给定的时间间隔中进行,这是一个宏观上的概念。

多道程序设计的并发环境下,操作系统产生了不同于单道程序顺序执行的三个新特征

    • 失去封闭性
      并发程序的执行过程不再像单道程序系统那样总是顺序连贯的,而具有“执行—暂停—执行”的活动规律,各程序活动的工作状态与所处的系统环境密切相关。多个程序并发执行时的相对速度是不确定的,每个程序都会经历“走走停停”的过程。
    • 程序与计算不再——对应
      “程序”是指令的有序集合,是“静态”概念;而“计算”是指令序列在处理机上的执行过程,是“动态”概念。在并发执行过程中,一个共享程序可被多个用户作业调用,从而形成多个“计算”。例如,在分时系统中,一个编译程序副本往往为几个用户同时服务,该编译程序便对应几个“计算”。
    • 并发程序在执行期间相互制约
      并发执行的多个程序共享系统中的资源,系统中很多资源具有独占性质,即一次只让一个程序使用,如打印机、磁带机及系统表格等。这就使逻辑上彼此独立的程序由于共用这类独占资源而形成相互制约的关系——在顺序执行时可连续运行的程序,在并发执行时却不得不暂停下来,等待其他程序释放自己所需的资源。

综上所述,用程序这个静态概念已不能如实反映程序并发执行过程中的这些特征。为此,人们引入“进程(process)”这一概念来描述程序动态执行过程的性质

2.进程的定义

进程(或任务)是在20世纪60年代中期由美国麻省理工学院(MIT)J. H. Saltzer首先提出的,并在所研制的MULTICS系统上实现。IBM公司把进程叫做任务(task),它在TSS/360系统中实现了。

“进程”是操作系统的最基本、最重要的概念之一。进程最根本的属性是动态性并发性。我们将进程定义为:程序在并发环境中的执行过程。

进程和程序的关系
进程和程序有密切的关系,但又是两个完全不同的概念,它们在4个方面有重要区别。

  • 动态性
    程序是静态、被动的概念,本身可以作为一种软件资源长期保存;而进程是程序的一次执行过程,是动态、主动的概念,有一定的生命期,会动态地产生和消亡。
    例如,从键盘上输入一条命令:
    $ date
    则系统就针对这条命令创建一个进程,这个进程执行date命令所对应的程序(以可执行文件的形式存放在系统所用的磁盘上)。当工作完成后,显示出当前日期和时间,这个进程就终止了,并从系统中消失。而date命令所对应的程序仍旧在磁盘上保留着。

  • 并发性
    传统的进程是作为资源申请和调度单位存在的;而通常的程序是不能作为一个独立运行的单位而并发执行的。多道程序设计中程序的并发执行是通过进程实现的。这也是引入进程的一个目的。
    程序在CPU上才能得到真正的执行。系统中以进程为单位进行CPU的分配。因为进程不仅包括相应的程序和数据,还有一系列描述其活动情况的数据结构。系统中的调度程序能够根据各个进程的当时状况,从中选出一个最适合运行的进程,将CPU控制权交给它,令其运行。而程序是静态的,系统无法区分内存中的程序哪一个更适合运行。所以,程序不能作为独立的运行单位。

  • 非对应性
    程序和进程无一一对应关系。一个程序可被多个进程共用;一个进程在其活动中又可顺序地执行若干程序。

    例如,在分时系统中,多个用户同时上机,做C程序的编译。
    张三在终端上输入命令:
    $ cc f1.c
    系统就创建了一个进程(例如A),它调用C编译程序,对f1.c文件进行编译。
    用户李四也在自己的终端上输入命令:
    $ cc a1.c
    系统又为这条命令创建一个进程(例如B),它也调用C编译程序,对a1.c文件进行编译。这样,一个C编译程序就对应到多个用户进程:A进程要用到它,它属于A的一部分;B进程也用到它,它又属于B的一部分。
    即使只有张三进行C程序编译,但他前后两次使用cc命令对文件f1.c进行编译,系统也要相应地创建两个进程。

    一个进程在活动过程中又要用到多个程序。例如,进程A在执行编译过程中除了调用C编译程序和用户张三编写的C程序外,还要用到C预处理程序、连接程序、内存装入程序、结果输出程序,等等。

  • 异步性
    各个进程在并发执行过程中会产生相互制约关系,造成各自前进速度的不可预测性。而程序本身是静态的,不存在这种异步特征。

3.进程的特征

进程具有如下基本特征:

  • 动态性

    进程是程序的执行过程,它有生有亡,有活动有停顿。可以处于不同的状态。

  • 并发性

    多个进程的实体能存在于同一内存中,在一段时间内都得到运行。这样就使得一个进程的程序与其它进程的程序并发执行了。

  • 调度性

    进程是系统中申请资源的单位,也是被调度的单位。就象体育比赛一样,裁判“调”你上场,你才能登台献技。操作系统中有很多调度程序,它们根据各自的策略调度合适的进程,为其运行提供条件。

  • 异步性

    各进程向前推进的速度是不可预知的,即异步方式运行。这造成进程间的相互制约,使程序执行失去再现性。为保证各程序的协调运行,需要采取必要的措施。

  • 结构性

    进程有一定的结构,它由程序段、数据段和控制结构(如进程控制块)等组成。程序规定了该进程所要执行的任务,数据是程序操作的对象,而控制结构中含有进程的描述信息和控制信息,是进程组成中最关键的部分。

(二)进程的状态及其转换

1.进程的三种基本状态

进程是有生存期的,其动态性质是由其状态及转换决定的。通常在操作系统中,进程至少要有3种基本状态,它们是处理机挑选进程运行的主要因素。这3种基本状态是:运行态、就绪态和阻塞态(或等待态)。

  • 运行态(Running)
    运行态是指当前进程已分配到CPU,它的程序正在处理机上执行。处于这种状态的进程的个数不能大于CPU的数目。在一般单CPU系统中,任何时刻处于运行状态的进程至多是一个。在多处理器系统中,同时处于运行状态的进程可以有多个。

  • 就绪态(Ready)
    就绪状态是指进程已具备运行条件,但因为其它进程正占用CPU,所以暂时不能运行而等待分配CPU的状态。一旦把CPU分给它,它立即就可以运行。在操作系统中,处于就绪状态的进程数目可以是多个。

  • 阻塞态(Blocked)
    阻塞状态是指进程因等待某种事件发生(例如等待某一输入输出操作完成,等待其他进程发来的信号等)而暂时不能运行的状态。就是说,处于阻塞状态的进程尚不具备运行条件,即使CPU空闲,它也无法使用。这种状态有时也称为封锁状态或等待状态。系统中处于这种状态的进程可以有多个。

图2-4表示了进程的状态及其转换。

图2-4 进程的状态及其转换

在一个具体的系统中,为了调度的方便、合理,往往设立了更多个进程状态,如在Linux操作系统中,进程状态可分为5种;而在UNIX操作系统中,进程状态划分为9种。但是上述3种状态是最基本的。因为如果不设立运行状态,就不知道哪一个进程正在占有CPU;如果不设立就绪状态,就无法有效地挑选出适合运行的进程,或许选出的进程根本就不能运行;如果不设立阻塞状态,就无法区分各进程除CPU之外是否还缺少其它资源,那样一来,准备运行的进程和不具备运行条件的进程就混杂在一起了。

2.进程状态的转换

进程在其生存期间不断发生状态转换——从一种状态变为另一种状态。就像电影底片上记录的动作状态那样,由状态的转换反映出动态效果。一个进程可以多次处于就绪状态和运行状态,也可以多次处于阻塞状态,但可能排在不同的阻塞队列上。

进程状态的转换需要一定的条件和原因。

  • 就绪→运行
    处于就绪状态的进程被调度程序选中,分配到CPU后,该进程的状态就由就绪态变为运行态。处于运行态的进程也称作当前进程。此时当前进程的程序在CPU上执行,它真正是活动的。

  • 运行→阻塞
    正在运行的进程因某种条件未满足而放弃对CPU的占用,例如该进程要求读入文件中的数据,在数据读入内存之前,该进程无法继续执行下去,它只好放弃CPU,等待读文件这一事件的完成。这个进程的状态就由运行态变为阻塞态。不同的阻塞原因对应不同的阻塞队列。就好像排队买火车票那样,不同方向的车次对应不同的队列(窗口)。

  • 阻塞→就绪
    处于阻塞状态的进程所等待的事件发生了,例如读数据的操作完成,系统就把该进程的状态由阻塞态变为就绪态。此时该进程就从阻塞队列中出来,进入到就绪队列中,然后与就绪队列中的其它进程竞争CPU。

  • 运行→就绪
    正在运行的进程如用完了本次分配给它的CPU时间片,它就得从CPU上退下来,暂停运行。该进程的状态就由运行态变为就绪态,以后进程调度程序选中它,它就又可以继续运行了。

(三)进程的组成

进程通常由程序、数据集合、栈和PCB等4部分组成。

程序和数据组成进程的实体,是进程的静态成分;进程控制块(Process Control Block, PCB)描述进程当前的状态、本身的特性、对资源的占用及调度信息等,是进程的动态成分;程序的执行过程必须包含一个或多个栈,用来保存过程调用和相互传送参数的踪迹,栈按“后进先出”的方式操作。进程的这4部分构成进程在系统中存在和活动的实体,有时也统称为“进程映像”。
图2-5 进程映像模型

图2-5 进程映像模型

1.进程控制块的组成

进程控制块是进程组成中最关键的部分,其中含有进程的描述信息和控制信息,是进程动态特性的集中反映,它是系统对进程施行识别和控制的依据。总起来说,进程控制块一般应包括如下内容:

  1. 进程名。它是唯一的标志对应进程的一个标识符或数字。有的系统用进程标识符作为进程的外部标志,用进程标志数(在一定数值范围内的进程编号)作为进程的内部标志。
  2. 特征信息。包括是系统进程还是用户进程,进程实体是否常驻内存等。
  3. 进程状态信息。表明该进程的执行状态,是运行态、就绪态还是阻塞态。
  4. 调度优先权。表示进程获取CPU的优先级别。当多个就绪进程竞争CPU时,系统一般让优先权高的进程先占用CPU。
  5. 通信信息。反映该进程与哪些进程有什么样的通信关系,如等待哪个进程的信号等。
  6. 现场保护区。当对应进程由于某个原因放弃使用CPU时,需要把它的一部分与运行环境有关的信息保持起来,以便在重新获得CPU后能恢复正常运行。通常被保护的信息有:程序计数器,程序状态字、各工作寄存器的内容等。
  7. 资源需求、分配和控制方面的信息。如进程所需要或占有的I/O设备、磁盘空间、数据区等。
  8. 进程实体信息。指出该进程的程序和数据的存储情况,在内存或外存的地址、大小等。
  9. 族系关系。反映父子进程的隶属关系。
  10. 其它信息。如文件信息、工作单元等。

2.进程控制块的作用

每个进程有唯一的进程控制块。操作系统根据PCB对进程实施控制和管理。进程的动态、并发等特征是利用PCB表现出来的。若没有进程控制块,则多道程序环境中的程序(和数据)是无法实现并发的。

当系统创建了一个新进程时,就为它建立一个PCB;当进程终止后系统回收其PCB,该进程在系统中就不存在了。所以,PCB是进程存在的唯一标志

3.进程队列

为了对所有进程进行有效地管理,常将各进程的PCB用适当的方式组织起来。一般说来,有以下几种方式:线性方式、链接方式和索引方式。

  • 线性方式
    线性方式最简单,最容易实现。如图2-6所示。操作系统预先确定整个系统中同时存在的进程的最大数目,比如是n,静态分配空间,把所有进程的PCB都放在这个表中。这种方式存在的主要问题是:限定了系统中同时存在的进程的最大数目。当很多用户同时上机时,会造成无法为用户创建新进程的情况。更严重的缺点是,在执行CPU调度时,为选择合理的进程投入运行,经常要对整个表进行扫描,降低了调度效率。
    img

  • 链接方式
    链接表的方式是经常采用的方式,其原理是:按照进程的不同状态分别放在不同的队列中,如图2-7所示。在单CPU情况下,处于运行态的进程只有一个,可以用一个指针指向它的PCB。
    若干个进程排成一个(或多个)队列,通过PCB结构内部的拉链指针把同一队列的PCB链接起来。该队列的第一个PCB由队列指针(如就绪队列等)指向,最后一个PCB的拉链指针置为0,表示结尾。CPU调度程序把第一个PCB由该队列中摘下(设仅有一个队列),令其投入运行。
    img

  • 索引方式
    索引方式是利用索引表记载相应状态进程的PCB地址,就是说,系统建立几张索引表,各对应进程的不同状态,如就绪索引表、阻塞索引表等。把状态相同进程的PCB组织在同一索引表中,每个索引表的表目中存放该PCB的地址。各索引表在内存的起始地址放在专用的指针单元中。图2-8给出PCB的索引结构示意图。
    图2-8 PCB索引结构示意图

2. 线程的引入和实现

理解引入线程的原因、线程的概念、组成和状态,了解线程的实现。

(一)线程的引入

在传统操作系统中,每个进程有一个地址空间和一条控制线索及一个程序计数器,所以在一个进程内部是顺序执行的。在这种环境下,进程这个活动实体兼有两个角色:资源分配单位和调度运行单位。但在很多现代操作系统中,把这两个角色赋予两个实体:进程只作为资源拥有者,负责申请和占有所需的全部资源(除CPU外);而参与调度和运行的职责赋予新的实体——线程(Thread)。

引入线程概念的理由主要有:

(1)使并行实体获得共享同一地址空间和所有可用数据的能力。如上所述,许多应用中会同时发生着多种活动。这些活动具有准并行(因为它们要共享同一进程的资源)和异步性的特点。在传统多进程模型中由于不同进程具有不同的地址空间,所以,无法表达这种能力。通过将这些应用程序分解为多个线程,就使程序设计模型变得更简单了。

(2)易于切换,代价低。由于线程只具有少量私有资源(如Thread结构、程序计数器、堆栈),不具有单独的地址空间,所以又把线程称为轻载进程(LWP)。这样,线程的创建、撤销就比进程快得多。在许多系统中,创建一个线程要比创建一个进程快10~100倍。

(3)可以改善系统的性能。如果存在大量的计算和I/O处理,采用多线程机制就允许这些活动彼此重叠进行,从而会加快应用程序的执行速度。

(4)在多处理器系统中,各个线程可以在单独的CPU上运行,从而大大提高了系统的效率。

1.线程概念

线程是进程中执行运算的最小单位,亦即执行处理机调度的基本单位。如果把进程理解为在逻辑上操作系统所完成的任务,那么线程表示完成该任务的许多可能的子任务之一。例如,用户启动了一个窗口中的数据库应用程序,操作系统把对数据库的调用表示为一个进程。该进程所完成的任务可以分为若干子任务,例如:用户利用数据库产生一份工资单报表,并传到一个文件中;在产生工资单报表的同时,又输入数据库查询请求。可以看出,这两个子任务是相互独立的,各自可以被单独调度、执行。于是,就创建两个线程,各对应一个子任务。

2.进程与线程的区别

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程与进程有关联,但二者又有区别:

(1)一个进程可以有多个线程,但至少要有一个线程;而一个线程只能在一个进程的地址空间内活动。

(2)资源分配给进程,同一进程的所有线程共享该进程的所有资源。

(3)处理机分配给线程,即真正在处理机上运行的是线程。

(4)线程在执行过程中需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。

3.线程的组成

每个线程有一个thread结构,即线程控制块,用于保存自己私有的信息,主要由以下4个基本部分组成:

(1)一个惟一的线程标识符。

(2)描述处理器工作情况的一组寄存器(如程序计数器、状态寄存器、通用寄存器等)的内容。

(3)每个thread结构有两个栈指针。一个指向核心栈,一个指向用户栈。当用户线程转变到核心态方式下运行时,就使用核心栈;当线程在用户态下执行时,就使用自己的用户栈。

(4)一个私有存储区,存放现场保护信息和其他与该线程相关的统计信息等。

thread结构如图2-13所示。

img

图2-13 thread结构示意图

线程必须在某个进程内执行。它所需的其他资源,如代码段、数据段、打开的文件和信号等,都由它所属的进程拥有。即操作系统分配这些资源时以进程为单位。

一个进程可以包含一个线程或多个线程。其实,传统的进程就是只有一个线程的进程。当一个进程包含多个线程时,这些线程除各自私有少量资源外,它们还要共享所属进程的全部资源。图2-14为单线程和多线程的进程模型。

img

图2-14 单线程和多线程的进程模型

4.线程的状态

与进程相似,线程也有若干种状态,如运行状态、阻塞状态、就绪状态和终止状态。

运行状态:线程正在CPU上执行程序,真正处于活动状态。

就绪状态:线程具备运行条件,一旦分到CPU,可以马上投入运行。

阻塞状态:线程正等待某个事件发生。例如,一个线程执行一个系统调用,需要从键盘上读取数据,那么它就变为阻塞状态,直至数据输入完毕。当线程变为阻塞状态时,系统为它保存寄存器内容、程序计数器内容和栈指针等。然后,系统把处理器分配给另一个就绪线程。

终止状态:当一个线程完成任务后,它占用的寄存器和栈等私有资源会被系统回收,并重新分配给另外的线程。

线程是一个动态过程。它的状态转换是在一定的条件下实现的。通常,当一个新进程创建时,该进程的一个线程也被创建。以后,这个线程还可以在它所属的进程内部创建另外的线程,为新线程提供指令指针和参数,同时为新线程提供私有的寄存器内容和栈空间,并且放入就绪队列中。

当CPU空闲时,线程调度程序就从就绪队列中选择一个线程,令其投入运行。

线程在运行过程中如果需要等待某个事件,它就让出CPU,进入阻塞状态。以后,当该事件发生时,这个线程就从阻塞状态变为就绪状态。

(二)线程的实现

在很多系统中已经实现线程,如Solaris 2,Windows 2000,Linux及Java语言等。但是,它们实现的方式并不完全相同,主要有在用户空间实现和在核心空间实现两种实现方式。

  • 用户级线程

在这种方式下,整个管理线程的线程库放在用户空间,管理线程的工作全部由应用程序完成,核心对线程一无所知,只对常规进程实施管理。图2-15(a)示出用户级线程的实现方式。常见的用户线程库包括POSIX Pthreads, Mach C-threads和Solaris 2 UI-threads。

img

图2-15 在用户空间和核心空间实现线程

从图2-15(a)中看出,运行时系统由一系列过程组成,负责管理线程的创建、终止、等待等工作。所有线程运行在运行时系统的顶端。每个进程有一个私有的线程表,其中记载该进程中每个线程的情况。而在核心空间中有一个进程表,其中记载系统中各个进程的情况。

用户级线程方式的优点主要有:

(1)线程切换速度很快,无须进行系统调度。这比使用系统调用并陷入到核心去处理要快得多。

(2)调度算法可以是应用程序专用的。允许不同的应用程序采用适合自己要求的不同的调度算法,并且不干扰底层的操作系统的调度程序。

(3)用户级线程可以运行在任何操作系统上,包括不支持线程机制的操作系统。线程库是一组应用级的实用程序,所有应用程序都可共享。

用户级线程的主要缺点包括:

(1)系统调用的阻塞问题。在典型的操作系统中,多数系统调用是阻塞式的。当一个线程执行系统调用时,不仅它自己被阻塞,而且在同一个进程内的所有线程都被阻塞。

(2)在单纯用户级线程方式中,多线程应用程序不具有多处理器的优点。因为核心只为每个进程一次分配一个处理器,每次只有该进程的一个线程得以执行,在该线程自愿放弃CPU之前,该进程内的其他线程不会执行。

  • 核心级线程

在这种方式下,核心知道线程存在,并对它们实施管理。如图2-15(b)所示。线程表不在每个进程的空间中,而是在核心空间中。线程表中记载系统中所有线程的情况。当一个线程想创建一个新线程或者删除一个现有线程时,必须执行系统调用,后者通过更新核心空间的线程表来完成上述工作。线程表中的信息与用户级线程相同。另外,核心空间除保存一个线程表外,还保存一个传统的进程表,其中记载系统中所有进程的信息。核心进行调度时以线程为基本单位。

核心级线程方式的主要优点是:

(1)在多处理器系统中,核心可以同时调度同一进程的多个线程,真正实现并行操作。

(2)如果一个进程的某个线程阻塞了,核心可以调度同一个进程的另一个线程。

(3)核心线程本身也可以是多线程的。

核心级线程方式也存在一些缺点,主要是:

(1)控制转移开销大。在同一个进程中,从一个线程切换到另一个线程时,需将模式切换到核心态。统计表明,在单CPU系统中,针对线程的创建、调度、执行直至完成的时间以及线程间同步开销的时间,核心级线程方式都比用户级线程方式高一个数量级。

(2)调度算法由核心确定,应用进程无法影响线程的切换。

针对上述两种方式的优缺点,有些操作系统(如Solaris 2)把用户级线程和核心级线程这两种方式结合在一起,从而取长补短。利用组合方式,同一个进程内的多个线程可在多个处理器上并行运行,且阻塞式系统调用不必将整个进程阻塞。所以,这种方式吸收了上述二者的优点,克服了各自的不足。

3.进程之间的关系和通信

进程间的制约关系有互斥、同步和通信三种形式。互斥体现了竞争关系,同步体现了合作关系,通信完成进程间的众多信息交换。你要深入理解进程的同步和互斥的含义,并能够正确区分它们,了解高级进程通信的3种方式,即共享存储器、管道文件和消息传递。

(一)同步

在日常生活中,同步关系的事例不胜枚举。例如,接力赛中前一棒运动员把棒交给下面的运动员,不能犯规。在工业生产的流水作业过程中,每道工序都有自己的特定任务,它们协同工作才能完成产品的生产,在前一道工序未完成或加工质量不合格时,后一道工序不能进行下去。

img

接力赛图示

img

工业生产的流水线图示

在计算机系统中,属于这种关系的进程是很多的。例如,SPOOLing系统的输入功能可以由两个进程A和B完成,进程A负责从读卡机上把卡片上的信息读到一个缓冲区中,进程B负责把该缓冲区中的信息进行加工并写到外存输入井中。

image-20240417101615724

要实现二者的协同工作,两个进程必须满足如下制约关系:只有当取空该缓冲区中的内容时,进程A才能向其中写入新信息;只有当写满该缓冲区时,进程B才能从中取出内容做进一步加工和转送工作。可见,在缓冲区内容取空时,进程B不应继续运行,需要等待进程A向其中送入新信息;反之,当缓冲区中的信息尚未取走时,进程A应等待,防止把原有的信息冲掉而造成信息丢失的结果。针对该缓冲区,进程A和进程B就是一种同步关系。

通过上面分析可以看出,同步进程通过共享资源来协调活动,在执行时间的次序上有一定约束。虽然彼此不直接知道对方的名字,但知道对方的存在和作用。在协调动作的情况下,多个进程可以共同完成一项任务。

(二)互斥

在日常生活中,人与人之间还存在另一种形式的关系,就是竞争一个共用的事物,如汽车在交叉路口争用车道,篮球比赛中两方队员争抢篮板球,等等。这些人之间可能彼此并不认识,由于想要共用一个事物,产生了相互排斥的竞争关系。

img

汽车在交叉路口争用车道

img

争抢篮板球

在计算机系统中,进程之间的关系通常也是与此类似的互斥关系,这是由于进程共享某些资源而引起的。例如系统中只有一台打印机,有两个进程都要使用,为了保证打印结果的正确和方便使用,只能是一个进程用完打印机之后,另一个进程才能使用,否则,两个进程同时使用一台打印机,则结果交织在一起,根本无法辨认。这两个进程对打印机的申请没有顺序关系,谁都可能先提出请求。

image-20240417101556393

为了解决这个问题,可以采取以下办法:使用前,各进程先申请打印机,如果打印机未分给其他进程,就把打印机分给该进程,做上占用标记,以后一直归它使用;当它用完之后,释放打印机,清除标记,这时另一个进程的申请才能得到满足。在一个进程使用打印机期间,另一个进程的申请不被满足,该进程必须等待。

从以上分析看出,在逻辑上这两个进程本来完全独立,毫无关系,只是由于竞争同一个物理资源而相互制约。这种互斥关系不同于前面讲的同步关系,它们的运行不具有时间次序的特征,谁先向系统提出申请,谁就先执行。

(三)通信

进程通信是指进程间的信息交换。各进程在执行过程中为合作完成一个共同的任务,需要协调步伐、交流信息。就如同人们在社会活动中进行交流一样。进程间交换的信息量可多可少。少则仅是一个状态或数值,多则可交换成千上万个字节的数据。

上述进程的互斥和同步机构因交换的信息量少,被归结为低级进程通信。本节介绍高级进程通信,它是方便高效地交换大量信息的通信方式。

高级进程通信方式有很多种,大致可归并为3类:共享存储器、管道文件和消息传递。

img

图 进程通信方式对比图

1.共享内存方式

共享内存方式是在内存中分配一片空间作为共享内存。需要进行通信的各个进程把共享内存附加到自己的地址空间中,然后就象正常操作一样对共享区中的数据进程读或写。如果用户不需要某个共享内存,可以把它取消。通过对共享内存的访问,相关进程间就可以传输大量数据。

2.管道文件方式

管道文件也称作管道线,它是连接两个命令的一个打开文件。一个命令向该文件中写入数据,称作写者;另一个命令从该文件中读出数据,称作读者。例如在Linux系统中,下述命令行就实现两个命令间的通信:

who | wc -l

写者和读者按先入先出(FIFO)的方式传送数据,由管道通信机制协调二者的动作,提供同步、互斥等功能。

在UNIX系统中,命令是通过进程实现的。所以,读者和写者又分别称作读进程和写进程。

3.消息传递方式

消息传递方式以消息(message)为单位在进程间进行数据交换。

它有两种实现方式:

(1)直接通信方式。发送进程直接将消息挂在接收进程的消息缓冲队列上,接收进程从消息缓冲队列中得到消息。

消息缓存通信:

消息缓冲通信这种方法是P.B.Hansen于1969年在他设计RC4000机器的操作系统时首先提出,在1970年公布的。其设计思想是:由系统管理一组缓冲区,其中每个缓冲区可以存放一个消息(即一组信息)。当进程要发送消息时,先要向系统申请一缓冲区,然后把消息写进去,接着把该缓冲区连到接收进程的一个消息队列中,并用通知接收者,接收进程可以在适当时候从消息队列中取出消息,并释放该缓冲区。

消息缓冲区一般包含下列几种信息:

name:发送消息的进程名或标识号。

size:消息长度。

text:消息正文。

next:下个缓冲区的地址。

在采用消息通信机构的系统中,进程PCB中一般包含有下列项目:

mutex:消息队列操作互斥信号量。消息队列是临界资源,不允许两个或两个以上进程对它同时进行访问。

Sm:表示接收消息计数和同步的信号量,用于接收消息进程与发送消息进程进行同步,其值表示消息队列中的消息数目。

Pm:指向该进程的消息队列中第一个缓冲区的指针。

接收消息进程的PCB和它的消息缓冲链的关系如图2-21所示。

img

图2-21 PCB与消息缓冲链

两个进程进行消息传送的过程如图2-22所示,发送进程在发送消息之前,要先在自己的内存空间设置一发送区,把准备发送的消息正文以及接收消息的进程名(或标识号)和消息长度填入其中,完成上述准备工作之后,调用发送消息的程序send(addr),其中参数addr是消息发送的起始地址。send程序的流程如图2-23所示,图中mutex是接收进程PCB中的互斥信号量,Sm是接收进程PCB中的同步信号量。

img

图2-22 消息发送与接收

接收进程在读取消息之前,先在自己占用的内存空间中指定一个接收消息区,然后调用接收消息程序receive(ptr),其中参数ptr是指向消息接收区的指针。receive程序的流程如图2-24所示。

img img
图2-23 send程序流程图 图2-24 receive程序流程图

在实际通信时,发送进程往往要求接收进程在收到消息后立即回答,或按消息的规定,在执行了某些操作后予以回答。此时接收进程在收到发来的消息后,便对消息进行分析,若是完成某项任务的命令,接收进程便去完成指定任务,并把所得结果转换成回答消息。同样,通过send程序将回答消息回送给原发送进程,原发送进程再用receive程序读取回答消息。至此两个进程才结束由一次服务请求而引起的通信全过程。

这种通信方式的好处是扩大了信息传递能力,但系统也要花费一定的代价,主要反映在:PCB中多了两个信号量和一个指针。使其规模变大;系统开设多个缓冲区,占用内存空间;增加对缓冲区的管理程序;对于缓冲区和消息队列的管理要涉及复杂的同步关系,给系统增加了复杂性和调度负担。

(2)间接通信方式。发送进程将消息送到称作信箱的中间设施中,接收进程从信箱中取得消息。这种通信方式也称作信箱通信方式。

信箱通信:

信箱是实现进程通信的中间实体,可以存放一定数量的消息。发送进程将消息送入信箱,接收进程从信箱中取出发给自己的消息。这有点类似于我们日常使用信箱的情况。

信箱是一种数据结构,在逻辑上可分为两个部分:信箱头——包括信箱名字、信箱属性(公用、私用或者共享)和信箱格状态等;信箱体——用来存放消息的多个信箱格。

消息在信箱中存放受到安全保护,得到核准的用户(进程)可以随时读取消息,而其他用户被拒绝访问信箱。

信箱可以动态创建和撤消。进程可以利用信箱创建原语来创建一个新信箱,创建时应给出信箱名字及其属性等,以及谁可以共享此信箱(对共享信箱)。不再使用信箱时,可用信箱撤消原语删除它。

当进程之间要通信时,它们必须有共享信箱。发送和接收消息的原语形式为:

send(mailbox,message)

其中,mailbox为信箱,message是要发送的消息。发送进程要发送消息时,先判断该信箱的信箱格是否为空。若为空,则将消息送入相应信箱格中,并置信箱格状态为满;否则重复测试。当信箱全满时,发送者挂起,直至有消息从信箱中取走。

receive(mailbox,message)

接收进程要接收一个消息时,先判断信箱状态是否为满。若为满,表示有消息,则从中取走消息。否则若为空,则接收者挂起。

信箱可分为3类:

① 公用信箱:它由操作系统创建,系统中所有核准进程都可使用它——既可把消息送到该信箱中,又可从中取出发给自己的消息。

② 共享信箱:它由某个进程创建,对它要指明共享属性以及共享进程的名字。信箱的创建者和共享者都可以从中取走发给自己的消息。

③ 私有信箱:用户进程为自己创建的信箱。创建者有权从中读取消息,而其它进程(用户)只能把消息发送到该信箱中。

4.信号量和P、V操作的一般应用

学习完这个知识点,要求你简述进程进入临界区的调度原则,能用信号量和P、V操作解决一般的同步互斥问题。

(一)临界资源和临界区

先看一个互斥的例子。

在计算机系统中必须互斥使用的资源很多,如读卡机、磁带机等硬件资源和一些公用变量、表格、队列、数据等软件资源。下面考虑两个进程共用同一表格的例子。

假定进程Pa负责为用户作业分配打印机,进程Pb负责释放打印机。系统中设立一个打印机分配表,由各个进程共用。表2-2给出打印机分配表的情况。

打印机编号 分配标志 用户名 用户定义的设备名
0 1 Meng PRINT
1 0
2 1 Liu OUTPUT

表2-2 打印机分配表

Pa进程分配打印机的过程是:

(1)逐项检查分配标志,找出标志为0的打印机号码;

(2)把该打印机的分配标志置1;

(3)把用户名和设备名填入分配表中相应的位置。

Pb进程释放打印机的过程是:

(1)逐项检查分配表的各项信息,找出标志为1且用户名和设备名与被释放的名字相同的打印机编号;

(2)该打印机的标志清0;

(3)清除该打印机的用户名和设备名。

如果进程Pb先执行,它释放用户Meng占用的第0号打印机,它的三步动作完成后,再执行Pa进程,就能按正常顺序对打印机进行释放和分配。也就是说,只要这两个进程对打印机分配表是串行使用的,那么,结果是正确的,不会出现什么问题。

由于进程Pa和Pb是平等的、独立的,二者以各自的速度并发前进,所以,它们的执行顺序有随机性。如果它们按以下次序运行,就会出现问题。

系统调度Pb运行:

(1)查分配表,找到分配标志为1、用户名为Meng、设备名为PRINT的打印机,其编号为0;

(2)将0号打印机的分配标志置为0。

接着,系统调度Pa运行:

(1)查分配表中的分配标志,找到标志为0的第0号打印机的表项;

(2)将分配标志置为1;

(3)填入用户名Zhang和设备名LP。

然后,系统调度Pb继续执行:

清除0号打印机的用户名Zhang和设备名LP。

这样一来,打印机分配表中的数据就变成如表2-3所示的情况。

打印机编号 分配标志 用户名 用户定义的设备名
0 1
1 0
2 1 Liu OUTPUT

表2-3 打印机分配表(出错情况)

由于0号打印机的分配标志为1,又没有用户名和用户定义的设备名,因此,它无法被释放。此后,它就再也不能由进程Pa分给用户使用了。

上述这种情况称作竞争条件(Race Condition),即两个或多个进程同时访问和操纵相同的数据时,最后的执行结果取决于进程运行的精确时序。

可见,Pa和Pb对分配表的使用必须互斥执行:在一个进程使用分配表期间,不允许另外的进程同时使用它。

很显然,包含有竞争条件的程序在运行时其结果不确定。要避免这种错误,关键是找到某种途径来阻止一个以上的进程同时使用这种资源。就是说,多个进程共享这种资源时必须互斥执行。

从上面可以看出,并发进程对共享资源的竞争形成各个进程的互斥关系。这些共享资源都具有一个共同的性质:一次仅允许一个进程使用。我们把这类共享资源称为临界资源(Critical Resource)。例如,打印机、读卡机、公共变量和表格等资源都是临界资源。在每个进程中访问临界资源的那段程序叫做临界区(Critical Section),简称CS区。例如,上节所写的进程Pa和Pb的程序段都是CS区。

进程互斥进入临界区都要遵循一种通用模式:进入前要申请,获准后方可进入;执行后要退出,然后才可以执行其他代码。图2-16为典型进程进入临界区的一般结构。

img

图2-16 典型进程进入临界区的一般结构

为使临界资源得到合理使用,必须禁止两个或两个以上的进程同时进入临界区内。即欲进入临界区的若干进程要满足如下关系:

(1)如果若干进程要求进入空闲的临界区,一次仅允许一个进程进入。

(2)任何时候,处于临界区内的进程不可多于一个。如果已有进程进入自己的临界区,则其他所有试图进入临界区的进程必须等待。

(3)进入临界区的进程要在有限时间内退出,以便其他进程及时进入自己的临界区。

(4)如果进程不能进入自己的临界区,则应让出CPU,避免进程出现”忙等”现象。

图2-17为进程A和进程B互斥使用临界区的过程。

img

图2-17 互斥使用临界区

互斥进程遵循上述准则,就能保证安全使用临界资源。由此可见,对系统中任何一个进程而言,它能否正常工作不仅与自身的正确性有关,而且与它在执行过程中能否与相关进程实施正确的同步和互斥有关。所以,解决进程间同步和互斥问题是十分重要的。

(二)互斥实现方式

为了解决进程互斥进入临界区的问题,需要采取有效措施。从实现机制方面来说,分为硬件方法和软件方法。

1.利用硬件方法解决进程互斥问题

利用硬件方法解决进程互斥问题有禁止中断和专用机器指令两种常见方式。

禁止中断是使每个进程在进入临界区之后立即关闭所有的中断,在它离开临界区之前才重新开放中断。由于禁止中断,时钟中断也被禁止,就不会把CPU切换到另外的进程。这种把关闭中断的权利交给用户进程的方法存在很大弊病:一旦某个进程关闭中断后,如果它不再开放中断,那么,系统可能会因此而终止。

另一种方法是设置专用机器指令。很多计算机(特别是多处理器计算机)都有一条名为TSL(Test and Set Lock,即测试并上锁)的指令:TSL RX,LOCK。它把内存字LOCK的内容读到寄存器RX中,然后在该地址单元中存入一个非零值。读数和存数的操作是不可分割的,即在这条指令完成之前,其他进程不能访问该单元。然而,利用TSL指令解决进程互斥进入临界区问题,可能导致“忙式等待”——如果前面已有一个进程进入临界区,则后者就不断利用TSL指令进行测试并等待前者开锁。

2.原语操作

操作系统在完成某些基本操作时,往往利用原语操作来实现。所谓原语(primitive)是机器指令的的延伸,往往是为完成某些特定的功能而编制的一段系统程序,为保证操作的正确性,在许多机器中规定,执行原语操作时,要屏蔽中断,以保证其操作的不可分割性。原语操作也称作“原子操作”,即一个操作中的所有动作要么全做,要么全不做。就好象进入考场参加高考,要么自始至终考完,要么不参加考试或提前交卷,不允许考试中间出考场去打电话,然后又回来继续答卷。

3.利用软件方法解决进程互斥问题

为解决进程互斥进入临界区的问题,可为每类临界区设置一把锁,该锁有打开和关闭两种状态,进程执行临界区程序的操作按下列步骤进行:

(1)关锁。先检查锁的状态,若为关闭状态,则等待其打开;如果已经打开,则将其关闭,继续执行②的操作。

(2)执行临界区程序。

(3)开锁。将锁打开,退出临界区。

一般情况下,锁可用布尔变量表示,也可用整型量表示。例如用变量W表示锁,其值为0表示锁打开,其值为1表示锁关闭。这种锁也称软件锁。关锁和开锁的原语可描述为:

关锁原语lock(W):

while (W==1);

W=1;

开锁原语unlock(W):

W=0;

下面用锁操作法写一个实现进程互斥执行的程序段。设系统中有一台打印机,有两个进程A和B都要使用它,以变量W表示锁,预先把它的值置为0。下面是两个进程的部分程序流程:

img

用上述软件方法解决进程间的互斥问题有较大的局限性,效果不很理想。例如,只要有一个进程由于执行lock(W)而进入互斥段运行,则其他进程在检查锁状态时都将反复执行lock(W)原语,从而造成处理机的“忙等”。虽然对上述算法可进行种种改进以便解决问题,但结果并不令人满意。

(三)信号量及P、V操作原语

信号量及信号量上的P操作和V操作是E.W.Dijkstra在1965年提出的一种解决同步、互斥问题的更通用的方法,并在THE操作系统中得以实现。

信号量,也叫信号灯,一般是由两个成员组成的数据结构,其中一个成员是整型变量,表示该信号量的值,另一个是指向PCB的指针。当多个进程都等待同一信号量时,它们就排成一个队列,由信号量的指针项指出该队列的头,而PCB队列是通过PCB本身所包含的指针项进行链接的。最后一个PCB(即队尾)的链接指针为0。

信号量的值是与相应资源的使用情况有关的。当它的值大于0时,则表示当前可用资源的数量;当它的值小于0时,则其绝对值表示等待使用该资源的进程个数,即在该信号量队列上排队的PCB的个数。图2-18表示信号量的一般结构以及信号量上PCB队列的情况,这种信号量也称作记录型信号量,因为它采用了记录型的数据结构。

img

图2-18 信号量的一般结构及PCB队列

对信号量的操作有如下严格限制:

(1)信号量可以赋初值,且初值为非负数。信号量的初值可由系统根据资源情况和使用需要来确定。在初始条件下,信号量的指针项可以置为0,表示队列为空。

(2)在使用过程中,信号量的值可以修改,但只能由P和V操作来访问,不允许通过其他方式来查看或操纵信号量。

设信号量为S,对S的P操作记为P(S),对S的V操作记为V(S)。

它们各自的含义是:

P(S):顺序执行下述两个动作:

① 信号量的值减1,即S=S-1;

② 如果S≥0,则该进程继续执行;

如果S<0,则把该进程的状态置为阻塞态,把相应的PCB链入该信号量队列的末尾,并放弃处理机,进行等待(直至其它进程在S上执行V操作,把它释放出来为止)。

V(S):顺序执行下述两个动作:

① S值加1,即S=S+1;

② 如果S>0,则该进程继续运行;

如果S≤0,则释放信号量队列上的第一个PCB(即信号量指针项所指向的PCB)所对应的进程(把阻塞态改为就绪态),执行V操作的进程继续运行。

P操作也称作wait操作,V操作也称作signal操作。

应注意,P和V操作都应作为一个整体实施,不允许分割或相互穿插执行。也就是说,P和V操作各自都好象对应一条指令,需要不间断地做下去,否则会造成混乱。为了保证这一点,在单CPU系统中通常是在封锁中断的条件下执行P和V操作。

从物理概念上讲,信号量S>0时,S值表示可用资源的数量。执行一次P操作意味着请求分配一个单位资源,因此S值减1;当S<0时,表示已无可用资源,请求者必须等待别的进程释放了该类资源,它才能运行下去。所以它要排队。而执行一次V操作意味着释放一个单位资源,因此S值加1;若S≤0时,表示有某些进程正在等待该资源,因而要把队列头上的进程唤醒,释放资源的进程总是可以运行下去的。

由于信号量本身是系统中若干进程的共享变量,所以P和V操作本身就是临界区,对它的互斥进入可借助于更低级的硬件同步工具来实现,如关锁、开锁操作等。

(四)用信号量实现进程的互斥和同步

利用信号量机制可以解决并发进程的互斥和同步问题。

1.用信号量实现进程的互斥

下面我们用P和V操作实现对打印机分配表的互斥使用。为此,设一个互斥信号量mutex,其初值为1。这样,Pa(分配进程)和Pb(释放进程)的临界区代码可按下述形式组成:

img

分析:如果Pb先运行,那么当它执行P(mutex)后,由于mutex=0,Pb进入临界区。在Pb退出临界区之前,若由于某种原因(如时间片到时)发生进程调度转换,选中Pa投入运行;当Pa执行P(mutex)时,因mutex= -1而进入等待队列,直到Pb退出临界区之后,Pa才能进入临界区。反过来,结果也是正确的。

可见,利用信号量实现互斥的一般模型是:

img

其中,信号量mutex用于互斥,初值为1。

使用P和V操作实现互斥时应注意两点

(1)在每个进程中用于实现互斥的P(mutex)和V(mutex)必须成对出现,即先做P,进入临界区;后做V,退出临界区。

(2)互斥信号量mutex的初值一般为1。

2.用信号量实现进程简单同步

下面我们对缓冲区同步使用问题使用信号量和P、V操作来实现。供者和用者对缓冲区的使用关系如图2-19所示。

img

图2-19 简单供者和用者的关系

可以看出,供者和用者间要交换两个消息:缓冲区空和缓冲区满的状态。当缓冲区空时,供者进程才能把信息存入缓冲区中;当缓冲区满时,表示其中有可供加工的信息,用者进程才能从中取出信息。用者不能超前供者,即缓冲区中未存入信息时不能从中取出信息;如供者已把缓冲区写满,但用者尚未取走信息时,供者不能再向其中写信息,避免冲掉前面写入的信息。

为此,设置两个信号量:

S1——表示缓冲区是否空(0表示不空,1表示空);

S2——表示缓冲区是否满(0表示不满,1表示满)。

规定S1和S2的初值分别为1和0,则对缓冲区的供者进程和用者进程的同步关系用下述方式实现:

设供者进程先得到CPU,它执行P(S1),申请空的缓冲区。此时,S1的值变为0,从而它继续执行:启动读卡机,将信息送入缓冲区(因为初始情况下,缓冲区中没有信息)。填满缓冲区之后,执行V(S2),表示缓冲区中有可供用者加工的信息了,S2的值变为1。然后执行goto L1;接着执行P(S1),由于S1的值变为-1,表示无可用缓冲区,于是供者在S1上等待,放弃CPU。以后调度到用者进程,它执行P(S2),条件满足(S2的值变为0),然后从缓冲区取出信息,并释放一个空缓冲区,执行V(S1),由于S1的值变为0,表示有一个进程正在等待空缓冲区资源,于是把该进程(即供者)从S1队列中摘下,置为就绪态。用者继续对信息进行加工和存盘处理。当这批数据处理完之后,它又返回到L2,然后执行P(S2)。但这时S2的值变为-1,所以用者在S2队列上等待,并释放CPU。如调度到供者进程,它就执行P(S1)之后的代码:启动读卡机,把卡片上信息送入缓冲区。这样,保证整个工作过程有条不紊地进行。

如果用者进程先得到CPU,会怎样呢?其实当它执行P(S2)时就封锁住了(因为S2的值变为-1),因而不会取出空信息或已加工过的信息。

在代码中P和V操作出现的顺序与信号量的初值设置有关。本例中若S1和S2的初值都为0,那么供者进程代码中P(S1)应出现在“V(S2);”之后。请同学们自行把这种初值设置条件下供者和用者的程序代码写完整,并分析其执行结果是否正确。

解析:

用P和V操作实现同步时应注意:

(1)分析进程间的制约关系,确定信号量种类。在保持进程间有正确的同步关系情况下,哪个进程应先执行,哪些进程后执行,彼此间通过什么资源(信号量)进行协调,从而明确要设置哪些信号量。

(2)信号量的初值与相应资源的数量有关,也与 P和V操作在程序代码中出现的位置有关。

(3)同一信号量的P和V操作要“成对”出现,但它们的分别在不同的进程代码中。例如,P(S1)在供者进程中,而V(S1)在用者进程中,若供者进程因执行P(S1)而阻塞,则以后将由用者进程在执行V(S1)时把它唤醒。

(五)生产者-消费者问题

在多道程序环境下,进程同步是一个十分重要而又令人感兴趣的问题。生产者—消费者问题就是其中一个有代表性的进程同步问题。

在计算机系统中,通常每个进程都要使用资源(硬资源和软资源——如缓冲区中的数据、通信的消息等),还可以产生某些资源(通常是软资源)。例如,在上例中,供者进程将读卡机读入的信息送入缓冲区,而用者进程从缓冲区中取出信息进行加工,因此供者进程就相当于生产者,而用者进程就相当于消费者。如果还有一个打印进程,它负责将加工的结果(设放在另一个缓冲区中)打印出来,那么在输出时,刚才的用者进程就是生产者,打印进程就是消费者。

所以,针对某类资源抽象地看,如果一个进程能产生并释放资源,则该进程称做生产者;如果一个进程单纯使用(消耗)资源,则该进程称做消费者。因此,生产者-消费者问题是同步问题的一种抽象,是进程同步、互斥关系方面的一个典型。这个问题可以表述如下:一组生产者进程和一组消费者进程(设每组有多个进程)通过缓冲区发生联系。生产者进程将生产的产品(数据、消息等统称为产品)送入缓冲区,消费者进程从中取出产品。假定缓冲区共有N个,不妨把它们设想成一个环形缓冲池,如图2-20所示。

img

图2-20 生产者-消费者问题

其中,有斜线的部分表示该缓冲区中放有产品,否则为空。in表示生产者下次存入产品的单元,out表示消费者下次取出产品的单元。

为使这两类进程协调工作,防止盲目的生产和消费,它们应满足如下同步条件:

(1)任一时刻所有生产者存放产品的单元数不能超过缓冲区的总容量(N)。

(2)所有消费者取出产品的总量不能超过所有生产者当前生产产品的总量。

设缓冲区的编号为0~N-1,in和out分别是生产者进程和消费者进程使用的指针,指向下面可用的缓冲区,初值都是0。

为使两类进程实行同步操作,应设置3个信号量:两个计数信号量full和empty,一个互斥信号量mutex。

full:表示放有产品的缓冲区数,其初值为0。

empty:表示可供使用的缓冲区数,其初值为N。

mutex:互斥信号量,初值为1,表示各进程互斥进入临界区,保证任何时候只有一个进程使用缓冲区。

下面是解决这个问题的算法描述。

img

(1)在每个程序中必须先做P(mutex),后做V(mutex),二者要成对出现。夹在二者中间的代码段就是该进程的临界区。

(2)对同步信号量full和empty的P和 V操作同样必须成对出现,但它们分别位于不同的程序中。

(3)无论在生产者进程中还是在消费者进程中,两个P操作的次序不能颠倒:应先执行同步信号量的P操作,然后执行互斥信号量的P操作。否则可能造成进程死锁。

请大家针对以下情况分析上述方案中各进程的运行过程:

解析:

用P和V操作实现同步时应注意:

(1)只有生产者进程在运行,消费者进程未被调度运行;

(2)消费者进程要超前生产者进程运行;

(3)生产者进程和消费者进程被交替调度运行。

5.死锁

学习完这个知识点之后,你应该能够简述死锁产生的根本原因、死锁的必要条件以及解决死锁的三种方法。

(一)死锁的定义

死锁是进程死锁的简称,是由Dijkstra于1965年研究银行家算法时首先提出来的。它是计算机操作系统乃至并发程序设计中最难处理的问题之一。

我们先看看这样一个生活中的例子:在一条河上有一座桥,桥面较窄,只能容纳一辆汽车通过,无法让两辆汽车并行。如果有两辆汽车A和B分别由桥的两端驶上该桥,则发生如图2-25所示的状况。

img

图2-25 汽车过窄桥时的冲突

对于A车来说,它走过桥面左边的一段路(即占有了桥的一部分资源),要想过桥还须等待B车让出右边的桥面,此时A车不能前进;对于B车来说,它走过桥面右边的一段路(即占有了桥的一部分资源),要想过桥还须等待A车让出左边的桥面,此时B车也不能前进。两边的车都不倒车,结果造成互相等待对方让出桥面,但是谁也不让路,就会无休止地等下去。这种现象就是死锁。如果把上图中的汽车比做进程,桥面作为资源,那么上述问题就描述为:进程A占有资源Rl,等待进程B占有的资源Rr;进程B占有资源Rr,等待进程A占有的资源Rl。而且资源Rl和Rr只允许一个进程占用,即:不允许两个进程同时占用。结果,两个进程都不能继续执行,若不采取其他措施,这种循环等待状况会无限期持续下去,就发生了进程死锁。

在计算机系统中,涉及软件、硬件资源都可能发生死锁。例如:系统中只有一台CD-ROM驱动器和一台打印机,某一个进程占有了CD-ROM驱动器,又申请打印机;另一个进程占有了打印机,还申请CD-ROM。结果,两个进程都被阻塞,永远也不能自行解除。又比如,在一个数据库系统中,一个进程对记录R1加锁,另一个进程对记录R2加锁,然后两个进程又试图将对方的记录加锁,这也将导致死锁。

所谓死锁,是指在一个进程集合中的每个进程都在等待仅由该集合中的另一个进程才能引发的事件而无限期地僵持下去的局面。

在多数情况下,进程是在等待该集合中另一个进程释放其所占有的资源。也就是说,每个进程都期待获得另一个进程正占用的资源。由于集合中的所有进程都不能运行,因而谁也不会释放资源。

从上面的例子可以看出,计算机系统产生死锁的根本原因就是资源有限操作不当。即:一种原因是系统提供的资源太少了,远不能满足并发进程对资源的需求。这种竞争资源引起的死锁往往是人们关注的核心。例如:消息是一种临时性资源。某一时刻,进程A等待进程B发来的消息,进程B等待进程C发来的消息,而进程C又等待进程A发来的消息。消息未到,A、B、C这3个进程均无法向前推进,也会发生进程通信上的死锁。

但除此之外,另一种原因是由于进程推进顺序不合适引发的死锁。资源少也未必一定产生死锁。就如同两个人过独木桥,如果两个人都要先过,在独木桥上僵持不肯后退,必然会因竞争资源产生死锁;但是,如果两个人上桥前先看一看有无对方的人在桥上,当无对方的人在桥上时自己才上桥,那么问题就解决了。所以,如果程序设计得不合理,造成进程推进的顺序不当,也会出现死锁。

(二)产生死锁的必要条件

从以上分析可见,如果在计算机系统中同时具备下面4个必要条件时,那么会发生死锁。换句话说,只要下面4个条件中有一个不具备,系统就不会出现死锁。

1.互斥条件

互斥条件即某个资源在一段时间内只能由一个进程占有,不能同时被两个或两个以上的进程占有。这种独占资源如平板式绘图仪、CD-ROM驱动器、打印机等等。必须在占有该资源的进程主动释放它之后,其它进程才能占有该资源。这是由资源本身的属性所决定的。如独木桥就是一种独占资源,两方的人不能同时过桥。

2.不可抢占条件

进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者进程自行释放。如过独木桥的人不能强迫对方后退,也不能非法地将对方推下桥,必须是桥上的人自己过桥后空出桥面(即主动释放占有资源),对方的人才能过桥。

3.占有且申请条件

进程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外进程占有,此时该进程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。还以过独木桥为例,甲乙两人在桥上相遇。甲走过一段桥面(即占有了一些资源),还需要走其余的桥面(申请新的资源),但那部分桥面被乙占有(乙走过一段桥)。甲过不去,前进不能,又不后退;乙也处于同样的状况。

4.循环等待条件

存在一个进程等待序列{P1,P2,…Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一资源,……,而Pn等待P1所占有的某一资源,形成一个进程循环等待环。就象前面的过独木桥问题。甲等待乙占有的桥面,而乙又等待甲占有的桥面,从而彼此循环等待。

上面我们提到的这4个条件在死锁时会同时发生。也就是说,只要有一个必要条件不满足,则死锁就可以排除。

(三)对待死锁的策略

前面介绍了死锁发生时的4个必要条件,而且我们知道,只要破坏这4个必要条件中的任意一个条件,死锁就不会发生。这就为我们解决死锁问题提供了可能。一般地,解决死锁的方法分为死锁的预防、避免、检测与恢复3种(注意:死锁的检测与恢复是一个方法)。

死锁的预防是保证系统不进入死锁状态的一种策略。它的基本思想是要求进程申请资源时遵循某种协议,从而打破产生死锁的4个必要条件中的一个或几个,保证系统不会进入死锁状态。它是排除死锁的静态策略。

死锁的避免是排除死锁的动态策略,它不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态地检查,并根据检查结果决定是否进行资源分配。就是说,在资源分配过程中若预测有发生死锁的可能性,则加以避免。

一般来说,由于操作系统有并发、共享以及异步性等特点,通过预防和避免的手段达到排除死锁的目的是很困难的。这需要较大的系统开销,而且不能充分利用资源。为此,一种简便的办法是系统为进程分配资源时,不采取任何限制性措施,但是提供了检测和解脱死锁的手段:能发现死锁并从死锁状态中恢复出来。因此,在实际的操作系统中往往采用死锁的检测与恢复方法来排除死锁。

死锁检测与恢复是指系统设有专门的机构,当死锁发生时,该机构能够检测到死锁发生的位置和原因,并能通过外力破坏死锁发生的必要条件,从而使得并发进程从死锁状态中恢复出来。

6.小结

当程序顺序执行时,具有封闭性和可再现性,但为了提高计算机的速度和增强系统的处理能力,广泛采用多道程序设计技术,从而导致程序的并发执行和资源共享。这带来新的特性,即:失去封闭性,程序与计算活动失去一一对应,并使程序并发执行时产生了相互制约的关系。为了更好描述程序的并发活动,引入了“进程”概念。

广义上将进程定义为:一个具有独立功能的程序关于某个数据集合的一次运行活动。简单说,进程就是:程序在并发环境中的执行过程。它最基本的特性是并发性和动态性。进程的活动是利用状态来反映的。一个进程至少应有3种状态,它们在一定条件下进行转化。实际上,只有真正占用CPU并正在执行程序的进程才是真正处于活动状态。

每一个进程都有唯一的一个进程控制块(PCB),它是进程存在的唯一标志。由PCB和进程执行的程序与数据一起构成进程的映像。系统对进程的管理(如调度、通信等)就是利用PCB实现的。

PCB表的物理组织方式有若干种,最常用的是线性方式、链接方式和索引方式。线性表实现简单,链接表使用灵活,索引表处理速度快。

进程有族系关系。利用创建原语可生成新进程,利用终止原语可终止进程,进程用阻塞原语可主动把自己从运行态变为阻塞态,利用唤醒原语可把另外的阻塞态进程唤醒。在Linux系统中,提供了一系列控制进程的系统调用和命令,可以在不同的层次使用它们。本章介绍了用于进程管理的几个常用命令和系统调用,在机器上使用它们,并分析所显示的结果,对加深理解进程概念颇有益处。

在现代操作系统中,引入了线程概念。线程是进程中执行运算的最小单位,亦即执行处理机调度的基本单位。允许一个进程拥有一个或多个线程。这样,进程是资源分配单位,而线程作为进程中调度和运行的基本单位。

进程在活动过程中会彼此发生作用,主要分为同步、互斥和通信3种形式。简单说来,同步是协作关系,互斥是竞争关系,而通信实现进程间众多信息的交换。

一次仅允许一个进程使用的资源称为临界资源,对临界资源实施操作的那段程序称为临界区(CS)。利用信号量和P、V操作可以很容易地解决进程间的互斥与同步问题。从物理概念上讲,信号量是表示系统中某类资源的数目,其值大于0时,表示系统中尚有的可用资源数目;其值小于0时,其绝对值表示系统中因请求该类资源而被阻塞的进程数目。P操作意味着请求系统分配一个单位资源,而V操作意味着释放出一个单位资源。

利用信号量实现进程互斥时,应为该临界区设置一信号量mutex,初值为1,表示该临界资源尚未使用,临界区应置于P(mutex)和V(mutex)之间。同样可利用信号量实现进程同步,主要应全面考虑诸进程间的协作关系。

生产者—消费者问题是进程同步和互斥的一般化形式,同样可用信号量来解决。应注意,在对该问题的描述中,两个P操作的次序不能颠倒。否则会产生死锁。这是互斥进程因对共享资源使用不当而发生的相互等待状态。死锁是有很大危害性的。

当需要交换大量数据时,P和V操作就不能满足进程通信的要求了,利用消息缓冲方式和信箱通信方式可实现发送进程和接收进程之间大量消息的传送。

所谓死锁,是指多个进程循环等待他方占有的资源而无限期地僵持下去的局面。很显然,如果没有外力的作用,那么死锁涉及到的各个进程都将永远处于封锁状态。

计算机系统产生死锁的根本原因就是资源有限且操作不当。一种原因是竞争资源引起的死锁,另一种原因是由于进程推进顺序不合适引发的死锁。如果在计算机系统中同时具备下面四个必要条件时,那么会发生死锁:互斥条件,不可抢占条件,占有且申请条件,循环等待条件。

一般地,解决死锁的方法分为死锁的预防、避免、检测与恢复3种。

7.案例扩展:Linux进程管理

理解进程族系、进程管理的主要内容、Linux进程的5种状态和结构,学会使用常用的进程的操作命令和系统调用。

(一)进程族系

就如同人类的族系一样,系统中众多的进程也存在族系关系:由父进程创建子进程,子进程再创建子进程,从而构成一棵树形的进程族系图,如图2-9所示。图中结点代表进程。

img

图2-9 进程创建的层次关系

在开机时,首先引导操作系统,把它装入内存。之后生成第一个进程(在UNIX中称作0号进程),由它创建1号进程及其它核心进程;然后1号进程又为每个终端创建命令解释进程(shell进程);用户输入命令后又创建若干进程。这样便形成了一棵进程树。树的根结点(即第一个进程0#)是所有进程的祖先。上一层结点对应的进程是其直接相连的下一层结点对应进程的父进程,例如1#进程是P20,P21,P2n这些进程的父进程。

(二)进程管理

进程管理主要包括创建进程、终止进程、阻塞进程、唤醒进程和更换进程映像等。

  • 创建进程

一个进程可以动态地创建新进程,前者称作父进程,后者称作子进程。引发创建进程的事件通常是调度新的批作业、交互式用户登录、操作系统提供服务和现有进程派生新进程。

创建新进程时要执行创建进程的系统调用(如UNIX/Linux系统中的fork),其主要操作过程有如下4步:

(1)申请一个空闲的PCB。从系统的PCB表中找出一个空闲的PCB项,并指定惟一的进程标识号PID(即进程内部名)。

(2)为新进程分配资源。根据调用者提供的所需内存的大小,为新进程分配必要的内存空间,存放其程序和数据及工作区。

(3)将新进程的PCB初始化。根据调用者提供的参数,将新进程的PCB初始化。这些参数包括新进程名(外部标识符)、父进程标识符、处理机初始状态、进程优先级、本进程开始地址等。一般将新进程状态设置为就绪状态。

(4)将新进程加到就绪队列中。一个进程派生新进程后,有两种可能的执行方式:

① 异步方式。即:父、子进程分道扬镳——父进程继续运行,子进程也可以被调度运行。

② 同步方式。即:父进程睡眠,等待它的某个或全部子进程终止,然后再继续运行。

建立子进程的地址空间也有两种可能的方式:

① 子进程复制父进程的地址空间;

② 把新的程序装入子进程的地址空间。

不同的操作系统采用不同的实现方式来创建进程。例如在UNIX系统中,每个进程有惟一的进程标识号(即PID),父进程利用fork系统调用创建新进程。父进程创建子进程时,把自己的地址空间制作一个副本,其中包括user结构、正文段、数据段、用户栈和系统栈。这种机制使得父进程很容易与子进程通信。两个进程都可以继续执行fork系统调用之后的指令,但有一个差别:fork的返回值(即子进程的PID)不等于0时,表示父进程在执行;而返回值等于0时,表示子进程在执行。Linux系统中也采用这种方式。

  • 终止进程

当一个进程完成自己的任务后要终止自己,这是正常终止。如果进程在运行期间由于出现某些错误和故障而被迫终止,这是非正常终止。也可应外界的请求(如父进程请求)而终止进程。

一旦系统中出现要求终止进程的事件后,便调用进程终止原语(原语是其操作不可分割的一段系统程序)。终止进程的主要操作过程是:

(1)从系统的PCB表中找到指定进程的PCB。若它正处于运行态,则立即终止该进程的运行。

(2)回收该进程所占用的全部资源。

(3)若该进程还有子孙进程,则还要终止其所有子孙进程,回收它们所占用的全部资源。

(4)释放被终止进程的PCB,并从原来队列中摘走。

  • 阻塞进程

正在运行的进程因提出的服务请求未被操作系统立即满足,或者所需数据尚未到达等原因,只能转变为阻塞态,等待相应事件出现后再把它唤醒。

正在运行的进程通过调用阻塞原语(如UNIX / Linux系统的sleep),主动地把自己阻塞。进程阻塞的过程如下:

(1)立即停止当前进程的执行;

(2)将现行进程的CPU现场送到该进程的PCB现场保护区中保存起来,以便将来重新运行时恢复此时的现场;

(3)把该进程PCB中的现行状态由”运行”改为阻塞,把它插入到具有相同事件的阻塞队列中。

(4)然后转到进程调度程序,重新从就绪队列中挑选一个合适进程投入运行。

  • 唤醒进程

当阻塞进程所等待的事件出现时(如所需数据已到达,或者等待的I/O操作已经完成),则由另外的、与阻塞进程相关的进程(如完成I/O操作的进程)调用唤醒原语(如UNIX /Linux系统的wakeup),将等待该事件的进程唤醒。可见,阻塞进程不能唤醒自己。

唤醒原语执行过程是:

(1)首先把被阻塞进程从相应的阻塞队列中摘下;

(2)将现行状态改为就绪态,然后把该进程插入到就绪队列中;

(3)如果被唤醒进程比正在运行进程有更高的优先级,则设置重新调度标志。

阻塞原语与唤醒原语恰好是一对相反的原语:调用前者是自己去睡眠,调用后者是把“别人”唤醒。使用时也要成对,前边有睡的,后边要有叫醒的。否则,前者就要“长眠”了。

  • 进程映像的更换

子进程被创建之后,通常处于“就绪态”。以后,被进程调度程序选中才可以运行,即取得CPU的控制权。在有些系统(如UNIX/Linux)中,由于创建子进程时是把父进程的映像复制给子进程,所以父子进程的映像基本相同。如果子进程不改变自己的映像,就必然重复父进程的过程,这不是我们所要求的。为此,要改变子进程的映像,使它执行自己的特定程序。

改变进程映像的工作很复杂,其主要过程是:

(1)释放子进程原来的程序和数据所占用的内存空间;

(2)从磁盘上找出子进程所要执行的程序和数据(通常以可执行文件的形式存放);

(3)分配内存空间,装入新的程序和数据;

(4)为子进程建立初始的运行环境——主要是对各个寄存器初始化,返回到用户态,运行该进程的程序。

应当指出,以上几个进程管理原语是基本的功能性描述。在不同的操作系统中有不同的实现方式。

(三)Linux进程管理

1.Linux进程状态

在Linux系统中,进程有下述5种状态:

(1)运行态(TASK_RUNNING)。此时,进程正在运行(即系统的当前进程)或准备运行(即就绪态)。当前进程由运行指针所指向。

(2)可中断等待态(TASK_INTERRUPTIBLE)。此时进程在“浅度”睡眠——等待一个事件的发生或某种系统资源,它能够被信号或中断唤醒。当所等待的资源得到满足时,它也被唤醒。

(3)不可中断等待态(TASK_UNINTERRUPTIBLE)。进程处于“深度”睡眠的等待队列中,不能被信号或中断唤醒,只有所等待的资源得到满足时,才能被唤醒。

(4)停止态(TASK_STOPPED)。通常由于接收一个信号,致使进程停止。正在被调试的进程可能处于停止态。

(5)僵死态(TASK_ZOMBIE)。由于某些原因,进程被终止了,但是该进程的控制结构task_struct仍然保留着。

如图2-10所示是Linux进程状态的变化。

img

图2-10 Linux进程状态的变化

2.进程的模式和类型

在Linux系统中,进程的执行模式划分为用户模式(用户态)和内核模式(核心态)。如果当前运行的是用户程序、应用程序或者内核之外的系统程序,那么对应进程就在用户模式下运行;如果在用户程序执行过程中出现系统调用或者发生中断事件,就要运行操作系统(即核心)程序,进程模式就变成内核模式。在内核模式下运行的进程可以执行机器的特权指令,并且该进程不受用户的干预,即使是root用户也不能干预内核模式下进程的运行。

按照进程的功能和运行的程序来分,进程划分为两大类:一类是系统进程,只运行在内核模式,执行操作系统代码,完成一些管理性的工作,如内存分配和进程切换。另一类是用户进程,通常在用户模式中执行,并通过系统调用或在出现中断、异常时进入内核模式。

用户进程既可以在用户模式下运行,也可以在内核模式下运行,如图2-11所示。

img

图2-11 用户进程的两种运行模式

3.Linux进程结构

  • task_struct结构

Linux系统中的每个进程都有一个名为task_struct的数据结构,它相当于“进程控制块”。系统中有一个进程向量数组task,该数组的长度默认值是512B,该数组的元素是指向task_struct结构的指针。在创建新进程时,Linux就从系统内存中分配一个task_struct结构,并把它的首地址 加入task数组。当前正在运行的进程的task_struct结构用current指针指示。

task_struct结构包含下列信息:

(1)进程状态。

(2)调度信息。调度算法利用这个信息来决定系统中的哪一个进程需要执行。

(3)标识符。系统中每个进程都有唯一的一个进程标识符(PID)。PID并不是指向进程向量的索引,仅仅是一个数字而已。每个进程还包括用户标识符(UID)和用户组标识符(GID),用来确定进程对系统中文件和设备的存取权限。

(4)内部进程通信。Linux系统支持信号、管道、信号量等内部进程通信机制。

(5)链接信息。在Linux系统中,每个进程都与其他进程存在联系。除初始化进程外,每个进程都有父进程。该链接信息包括指向父进程、兄弟进程和子进程的指针。

(6)时间和计时器。内核要记录进程的创建时间和进程运行所用的CPU时间。Linux系统支持进程的时间间隔计时器。

(7)文件系统。进程在运行时可以打开和关闭文件。task_struct结构中包括指向每个打开文件的文件描述符的指针,并且包括两个指向VFS(虚拟文件系统)索引节点的指针。第一个索引节点是进程的根目录,第二个节点是当前的工作目录。两个VFS索引节点都有一个计数字段,该计数字段记录访问该节点的进程数。

(8)虚拟内存。大多数进程都使用虚拟内存空间。Linux系统必须了解如何将虚拟内存映射到系统的物理内存。

(9)处理器信息。每个进程运行时都要使用处理器的寄存器及堆栈(参见下文关于堆栈的讲解)等资源。当一个进程挂起时,所有有关处理器的内容都要保存到进程的task_struct中。当进程恢复运行时,所有保存的内容再装回到处理器中。

  • 进程系统堆栈

在Linux系统中,每个进程都有一个系统堆栈,用来保存中断现场信息和进程进入内核模式后执行子程序(函数)嵌套调用的返回现场信息。每个进程的系统堆栈和task_struct数据结构之间存在紧密联系,因而二者物理存储空间也连在一起,如图2-12所示。

img

图2-12 进程的系统堆栈和task_struct结构

由图2-12中可以看出,内核在为每个进程分配task_struct结构的内存空间时,实际上是一次分配两个连续的内存页面(共8KB),其底部约1KB的空间用于存放task_struct结构,而上面的约7KB的空间用于存放进程系统堆栈。

另外,系统空间堆栈的大小是静态确定的,而用户空间堆栈可以在运行时动态扩展。

(四)对进程的操作命令

在Linux中,通常执行任何一个命令都会创建一个或多个进程,即命令是通过进程实现的。当进程完成了预期的目标,自行终止时,该命令也就执行完了。不但用户可以创建进程,系统程序也可以创建进程。可以说,一个运行着的操作系统就是由许许多多的进程组成的。

1.ps命令

ps命令是查看进程状态的最常用的命令,它可以提供关于进程的许多信息。操作者可以根据显示的信息确定哪个进程正在运行,哪个进程是被挂起或出了问题,进程已运行了多久,进程正在使用的资源情况,进程的相对优先级以及进程的标识号(PID)。所有这些信息对用户都很有用,对于系统管理员来说更为重要。

ps命令的一般格式是: ps [选项]

例如,不带选项的ps命令可以列出每个与当前shell有关的进程的基本信息:

$ ps

PID TTY TIME CMD
1788 pts/1 00:00:00 bash
1822 pts/1 00:00:00 ps

其中,各字段的含义如下:

PID 进程标识号。

TTY 该进程建立时所对应的终端,“?”表示该进程不占用终端。

TIME 报告进程累计使用的CPU时间。注意,尽管有些命令(如sh)已经运转了很长时间,但是它们真正使用CPU的时间往往很短。所以,该字段的值往往是00:00:00。

CMD 执行进程的命令名,command的缩写。

Ps命令的常用选项有:

-a 显示系统中与tty相关的(除会话组长之外)所有进程的信息。

-e 显示所有进程的信息。

-f 显示进程的所有信息。

-l 以长格式显示进程信息。

-r 只显示正在运行的进程。

-u 显示面向用户的格式(包括用户名,CPU及内存使用情况、进程运行状态等信息)。

-x 显示所有终端上的进程信息。 例如,下面的命令行可以显示系统中所有进程的全面信息:

$ ps -ef

UID PID PPID C STIME TTY TIME CMD
root 1 0 1 20:42 ? 00:00:05 init [5]
root 2 1 0 20:42 ? 00:00:00 [keventd]
root 3 1 0 20:42 ? 00:00:00 [ksoftirqd_CPU0]
……
mengqc 1823 1788 0 21:39 pts/1 00:00:00 ps -ef

各项的含义是(只标出与前例不同的部分,其余含义相同):

UID 进程属主的用户ID号。

PPID 父进程的ID号。

C 进程最近使用CPU的估算。

STIME 进程开始时间,以“小时:分:秒”的形式给出。

2.kill命令

信号(signal,也称作软中断)机制是在软件层次上对中断机制的一种模拟。异步进程可以通过彼此发送信号来实现简单通信。系统预先规定若干个不同类型的信号(如x86平台中Linux内核设置了32种信号,而现在的Linux和POSIX.4定义了64种信号),各表示发生了不同的事件,每个信号对应一个编号。进程遇到相应事件或者出现特定要求时(如进程终止或运行中出现某些错误——非法指令和地址越界等),就把一个信号写到相应进程task_struct结构的signal位图(表示信号的整数)中。接收信号的进程在运行过程中要检测自身是否收到了信号,如果已收到信号,则转去执行预先规定好的信号处理程序。在处理之后,再返回原先正在执行的进程。

kill命令是通过向指定进程发送指定的信号来终止相应进程。终止一个前台进程可以使用键,也可以使用kill命令。但是,对于一个后台进程就只能用kill命令来终止。

kill命令的一般格式是:

kill [-s 信号|-p ] 进程号…

kill -l [信号]

其中,各选项的含义如下:

-s 指定要发送的信号——可以是信号名(如SIGKILL),也可以是对应信号的编号(如9)。

-p 指定kill命令只是显示进程的PID(进程标识号),并不真正发出终止进程的信号。

-l 显示信号名称列表,这也可以在/usr/include/linux/signal.h文件中找到。

使用kill命令时应注意:

(1)kill命令可以带信号,也可以不带。如果没有带信号,kill命令就会发出终止信号(编号为15),这个信号可以被进程捕获,使得进程在退出之前清理并释放资源。也可以用kill向进程发送特定的信号,例如:kill -2 123 。它的效果等同于:当在前台运行PID为123的进程时,按下键。但是,普通用户使用kill命令时不要带信号,或者至多带信号编号9。

(2)kill可以用进程ID号作为参数。当用kill向这些进程发送信号时,必须是这些进程的主人。如果试图撤销一个没有权限撤销的进程或撤销一个不存在的进程,就会得到一个错误信息。

(3)可以向多个进程发信号或终止它们。

(4)当kill成功地发送了信号后,shell会在屏幕上显示出进程的终止信息。有时这个信息不会马上显示,只有当按下键使shell的命令提示符再次出现时,才会显示出来。

(5)应注意,信号使进程强行终止,这常会带来一些副作用,如数据丢失或者终端无法恢复到正常状态。发送信号时必须小心,只有在万不得已时,才用SIGKILL信号(编号为9),因为进程不能首先捕获它。

要撤销所有的后台作业,可以输入kill 0。因为有些在后台运行的命令会启动多个进程,跟踪并找到所有要杀掉的进程的PID是件很麻烦的事。这时,使用kill 0 来终止所有由当前shell启动的进程,是个有效的方法。

一般可以用kill命令来终止一个已经挂起的进程或者一个陷入死循环的进程。例如:首先执行以下命令,人为建立一个好像陷入死循环的后台进程:

$ find / -name core -print > /dev/null 2>&1&

这是一条后台命令,执行时间较长。其功能是:从根目录开始搜索名为core的文件,将结果输出(包括错误输出)都定向到 /dev/null文件。

现在决定终止该进程。为此,运行ps命令来查看该进程对应的PID。例如,该进程对应的PID是1651,现在用kill命令“杀死”这个进程:

$ kill 1651

再用ps命令查看进程状态时,就可以看到,find进程已经不存在了。

3.sleep命令

sleep命令的功能是使进程暂停执行一段时间。其一般使用格式是:

sleep 时间值

其中,“时间值”参数以秒为单位,即让进程暂停由时间值所指定的秒数。此命令大多用于shell程序设计中,使两条命令执行之间停顿指定的时间。

例如,下面的命令使进程先暂停100秒,然后查看用户mengqc是否在系统中:

$ sleep 100; who | grep ‘mengqc’

(五)有关进程控制的系统调用

1.系统调用的使用方式

虽然在一般应用程序的编制过程中,利用系统提供的库函数就能很好地解决问题,但在处理系统底层开发、进程管理等方面的涉及系统内部操作的问题时,利用系统调用编程就很必要,而且程序执行的效率会得到改进。

在UNIX/Linux系统中,系统调用和库函数都是以C函数的形式提供给用户的,它有类型、名称和参数,并且要标明相应的文件包含。例如,open系统调用可以打开一个指定文件,其函数原型说明如下:

#include

#include

#include

int open(const char *path, int oflags);

不同的系统调用所需要的头文件(又称前导文件)是不同的。这些头文件中包含了相应程序代码中用到的宏定义、类型定义、全称变量及函数说明等。对C语言来说,这些头文件几乎总是保存在/usr/include及其子目录中。系统调用依赖于所运行的UNIX/Linux操作系统的特定版本,所用到的头文件一般放在/usr/include/sys或者 /usr/include/linux目录中。

在C语言程序中,对系统调用的调用方式与调用库函数的方式相同,即:调用时提供的实参的个数、出现的顺序和实参的类型应与原型说明中形参表的设计相同。例如,要打开在目录/home/mengqc下面的普通文件myfile1,访问该文件的模式为可读可写(用符号常量O_RDWR表示),则代码片段为:

int fd;

fd=open(“/home/mengqc/myfile1”,O_RDWR);

虽然从感觉上系统调用类似于库函数调用:都由程序代码构成,使用方式相同——调用时传送参数。但两者有实质差别:库函数属于用户层,它只能在用户态下运行,不能进入核心态;而系统调用属于操作系统核心层,在核心态下运行,并且可以实现处理机从用户态到核心态的转变。

2.有关进程控制的系统调用

常用的有关进程控制的系统调用有:fork,exec,wait,exit,getpid,sleep和nice等。查看表2-1列出了这些系统调用的格式和功能说明 。

表2-1 有关进程控制的系统调用的格式和功能说明

格式 功能
#include #include pid_t fork(void); 创建一个子进程。pid_t表示有符号整型量。若执行成功,在父进程中,返回子进程的PID(进程标识符,为正值);在子进程中,返回0。若出错,则返回1,且没有创建子进程
#include #include pid_t getpid(void); pid_t getppid(void); getpid返回当前进程的PID,而getppid返回父进程的PID
#include int execve(const char *path,char *const argv[],char *const envp[]); int execl(const char *path, const char *arg,…); int execlp(const char *file, const char *arg,…); int execle(const char *path, const char *arg,…,char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); 这些函数被称为”exec函数系列”,其实并不存在名为exec的函数。只有execve是真正意义上的系统调用,其他都是在此基础上经过包装的库函数。该函数系列的作用是更换进程映像,即根据指定的文件名找到可执行文件,并用它来取代调用进程的内容。换句话说,即在调用进程内部执行一个可执行文件。其中,参数path是被执行程序的完整路径名;argv和envp分别是传给被执行程序的命令行参数和环境变量;file可以简单到仅仅是一个文件名,由相应函数自动到环境变量PATH给定的目录中去寻找;arg表示argv数组中的单个元素,即命令行中的单个参数
#include void _exit(int status); #include void exit(int status); 终止调用的程序(用于程序运行出错)。参数status表示进程退出状态(又称退出值、返回码、返回值等),它传递给系统,用于父进程恢复。_exit函数比exit函数简单些,前者使得进程立即终止;后者在进程退出之前,要检查文件的打开情况,执行清理I/O缓冲的工作
#include #include pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status,int option); wait( )等待任何要僵死的子进程;有关子进程退出时的一些状态保存在参数status中。如成功,返回该终止进程的PID;否则,返回1而waitpid( )等待由参数pid指定的子进程退出。参数option规定了该调用的行为:WNOHANG表示如没有子进程退出,则立即返回0;WUNTRACED表示返回一个已经停止但尚未退出的子进程的信息。可以对它们执行逻辑”或”
#include unsigned int sleep(unsigned int seconds); 使进程挂起指定的时间,直至指定时间(由seconds表示)用完或者收到信号
#include int nice(int inc); 改变进程的优先级。普通用户调用nice时,只能增加进程的优先数(inc为正值);只有超级用户才能减少进程的优先数(inc为负数)。如成功,返回0;否则,返回1

3.应用示例

【例2-1】子进程被创建后,一般会使用一个exec函数来更换自己的映像,即用一个程序(如可执行文件)取代原来内存空间中的内容,然后开始执行。此后,父、子进程就各行其道了。父进程可以创建多个子进程。当子进程运行时,如果父进程无事可做,就执行wait系统调用,把自己插入睡眠队列中,等待子进程的终止。

下面这个C程序展示了Linux系统中父进程创建子进程以及各自分开活动的情况。

img

【例2-2】每个进程都有唯一的进程ID号(PID)。PID通常在数值上逐渐增大,因此,子进程的PID一般要比其父进程大。当然,PID的值不可能无限大,当它超过系统规定的最大值时,就反转回来使用最小的尚未使用的PID值。

如图2-9所示,进程族系呈树型结构。除0#外,任何进程都必须有父进程,不允许出现“孤儿”进程。如果父进程先于子进程死亡或退出,则子进程会被指定一个新的父进程init(其PID为1)。

下面的程序利用fork( )创建子进程,利用getpid( )和getppid( )分别获得本进程的PID和父进程的PID,使用sleep( )将相关进程挂起几秒钟。本程序的可执行文件名为proc1。

/*proc1.c演示有关进程操作*/
#include
#include
#include
#include

int main(int argc,char **argv)
{
pid_t pid,old_ppid,new_ppid;
pid_t child,parent;
parent=getpid(); /*获得本进程的PID*/
if((child=fork())<0){ /*如果新创建子进程的PID小于0*/
fprintf(stderr,"%s:fork of child failed:%s\n",argv[0],strerror(errno)); /*输出创建子进程失败*/
exit(1); /*终止,返回码为1*/
}
else if(child==0){ /*此时是子进程被调度运行*/
old_ppid=getppid(); /*取得父进程的PID*/
sleep(2); /*睡眠2秒钟,以便调度其他进程运行*/
new_ppid=getppid(); /*当再次被调度运行后,取得新父进程的PID*/
}
else { /*父进程运行*/
sleep(1); /*睡眠1秒钟,以便调度其他进程运行*/
exit(0); /*被调度运行时,父进程终止*/
}
/*下面仅子进程运行*/
printf("Original parent:%d\n",parent); /*输出最初父进程的PID*/
printf("Child:%d\n",getpid()); /*取得并输出子进程的PID*/
printf("Child's old ppid:%d\n",old_ppid); /*输出父进程的PID*/
printf("Child's new ppid:%d\n",new_ppid); /*输出新父进程的PID*/
exit(0); /*程序终止*/

}

程序运行的结果如下:

$ ./proc1
$ Original parent:2009
Child:2010
Child's old ppid:2009
Child's new ppid:1

请读者根据输出结果自行分析程序的执行情况。注意,进程是并发执行的;当子进程被成功调度后,调度程序的返回值是0。如果父进程先终止,则其子进程的父进程就被系统指定为init进程(其PID为1)。

第三章


评论
  目录