在学习本节时,请读者思考以下问题:
- 为什么要引入进程?
- 什么是进程?进程由什么组成?
- 引入进程主要解决了多道程序并发执行中的哪些关键问题?
希望读者带着这些问题进入本节的学习,并在学习过程中多思考,从而更深入地把握核心概念。进程本身是一个抽象的逻辑实体,它并非物理存在的对象,初学者在理解时往往感到困难。为此,在系统阐述相关理论之后,我们将通过直观的例子帮助读者理解。
2.1.1 进程的概念和特征
1. 进程的概念
在多道程序环境下,允许多个程序并发执行,但并发性破坏了程序原本的封闭性,使其执行过程呈现间断性,并可能导致结果不可再现。为有效描述和控制程序的并发行为,操作系统引入了进程(Process)这一核心概念,以支撑操作系统的两大基本特性:并发性与共享性。
为使每个参与并发执行的程序能独立运行,必须为其配置一个专门的数据结构,称为进程控制块(Process Control Block, PCB)。操作系统通过PCB记录进程的基本信息和运行状态,从而实现对其的控制与管理。通常,程序段、相关数据段和PCB三者共同构成进程实体(也称进程映像)。因此,创建进程的本质是创建其PCB;相应地,撤销进程即释放该PCB及相关资源。
从不同视角出发,进程可有不同的定义,比较典型的定义如下。
- 进程是正在执行的程序实例。
- 进程是程序及其数据从外存加载到内存后,在CPU上运行的过程。
- 进程是具有独立功能的程序在一个特定数据集合上的执行活动。
在传统操作系统中,进程被正式定义为:“进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位”。这里所说的“系统资源”不仅包括内存空间、I/O设备等硬件资源,也包括CPU的使用权,而CPU的使用权通常通过时间片的分配来实现。正因进程是资源分配与调度的基本单位,它必然不是静态的程序,而必须是一个动态的、具有生命周期的过程性实体。
2. 进程的特征
进程源于多道程序并发执行的需求,与静态的程序有着本质区别。其核心特征如下。
动态性。进程是程序的一次执行实例,具有明确的生命周期,包括创建、就绪、运行、阻塞和终止等状态变化。动态性是进程最根本的特征。并发性。多个进程可同时驻留于内存,并在一段时间内交替或并行执行。引入进程的根本目的,正是为了支持程序的并发执行。独立性。进程是能够独立运行、独立获取系统资源,并作为调度基本单位参与CPU竞争的实体。任何未建立PCB的程序,均不能被操作系统调度执行。异步性。进程相互制约,以不可预知的速度向前推进。这种异步性可能导致执行结果不可再现,因此操作系统必须提供相关机制,以协调进程行为,确保执行的正确性。
尽管考试中较少直接考查四大特征,但对其内涵的理解是分析进程调度、同步互斥、死锁等问题的基础。建议读者着重把握各特征的成因、表现及其对系统设计的影响,而非机械记忆。
2.1.2 进程的组成
进程是操作系统进行资源分配和调度的基本单位,由三部分共同构成:程序段、数据段和进程控制块(PCB),其中PCB是最核心的组成部分。
1. 进程控制块
进程创建时,操作系统为其分配一个PCB;该结构常驻内存,可随时被系统访问,并在进程终止时被回收。PCB是进程存在的唯一标志,系统唯有通过它才能感知并管理进程。
在进程的整个生命期内,系统始终依赖PCB对其进行管理。操作系统通过PCB记录并维护进程的当前状态,以便在需要调度、切换或干预时,能够准确地控制其行为。
具体而言,当操作系统准备调度某个进程运行时,首先从其PCB中读取当前状态、优先级等调度所需信息;一旦该进程被选中,系统便根据PCB中保存的CPU上下文(如寄存器值)恢复其执行现场,并依据其中记录的内存地址找到对应的程序段和数据段。在进程运行过程中,若需与其他进程实现同步、通信,或访问文件,同样需要查询PCB中的相关资源信息。而当进程因某种原因而暂停执行时,其当前的CPU状态会被保存回PCB,以便后续重新调度时能从中断处继续执行。当进程正常结束或被强制终止时,系统将回收其PCB,该进程也随之消亡。由此可见,从创建到终止,系统对进程的所有操作,都离不开对其PCB的访问与更新。
PCB主要包括如表2.1所示的四类信息,各部分的主要说明如下。
表2.1 PCB通常包含的内容
| 进程标识信息 | 进程调度信息 | 资源和内存信息 | 处理机状态信息 |
|---|---|---|---|
| 进程ID(PID) | 进程当前状态 | 代码段指针 | 通用寄存器值 |
| 父进程ID(PPID) | 进程优先级 | 数据段指针 | 程序计数器值 |
| 用户ID(UID) | CPU占用时间 | 堆栈段指针 | 程序状态字(PSW) |
| 等待时间 | 文件描述符列表 | 栈指针(SP) | |
| 进入内存时间 | I/O资源清单 | ||
| 阻塞原因 |
进程标识信息。用于唯一标识一个进程及其归属关系。进程ID(PID)是系统为每个进程分配的唯一编号;父进程ID(PPID)记录创建该进程的父进程标识符;用户ID(UID)指明进程所属用户,为资源共享与安全保护提供依据。进程调度信息。支持操作系统的调度决策与状态管理。进程当前状态是CPU分配的基本依据;进程优先级决定其抢占CPU的能力;此外,还包括CPU占用时间、等待时间、进入内存时间等统计信息,以及阻塞原因,用于调整调度策略及后续唤醒。资源和内存信息。记录进程所占用的各类系统资源。包括代码段、数据段和堆栈段在内存中的起始地址(或指针)、已打开文件的描述符列表,以及所占用的I/O资源。处理机状态信息。也称CPU上下文,主要包括通用寄存器、程序计数器、栈指针及程序状态字(PSW)等寄存器的值。当进程被切换出CPU时,这些信息必须完整保存到PCB中;再次被调度运行时,系统将其恢复至CPU,从而能从中断处继续执行。
在一个实际系统中,通常同时存在大量进程,有的处于就绪态,有的处于阻塞态,并且阻塞原因各不相同。为高效地进行调度与管理,操作系统需将各进程的PCB以适当方式组织起来。常用的组织方法主要有两种:链接方式和索引方式。在链接方式中,系统将处于相同状态的PCB通过指针链接成一个队列,如就绪队列、阻塞队列;对于阻塞进程,还可根据其阻塞原因(如等待I/O完成、等待信号量等)进一步划分为多个阻塞子队列。在索引方式中,系统为每种状态建立一个索引表(如就绪索引表、阻塞索引表等),表项指向对应状态的PCB。
2. 程序段
程序段是进程中可被CPU执行的代码部分。需要指出的是,程序本身是静态的,可被多个进程共享。例如,多个用户同时运行同一个文本编辑器程序时,系统只需在内存中保留一份代码副本,各进程通过各自的PCB指向该共享代码段,从而节省内存空间并提高系统效率。
3. 数据段
数据段包含进程运行所需的各种数据,既包括程序处理的原始输入,也包括执行过程中产生的中间结果和最终输出。与程序段不同,每个进程的数据段是私有的,即使多个进程运行同一程序,它们的数据段也彼此隔离。这种设计有效保障了进程间的独立性与安全性。
2.1.3 进程的内存映像
当程序被加载运行时,操作系统会为其建立一个虚拟地址空间,该空间的逻辑布局称为进程的内存映像。它反映了进程在运行时代码、数据、堆、栈等部分的组织方式。与磁盘上的静态可执行文件不同,内存映像是动态的。在虚拟内存机制(见3.2节)下,进程的整个地址空间无须全部驻留物理内存,只有在实际访问某一部分时,系统才会通过缺页中断将其从磁盘调入内存。
典型的进程虚拟地址空间由以下几个主要区域构成。
1. 代码段
代码段是指存放程序的机器指令,即编译后的可执行代码。该段通常是只读的,防止程序运行时意外修改指令,同时也允许多个进程共享同一份代码。代码段通常包含以下几个子区域。
.init:包含程序启动时由系统自动调用的初始化代码。.text:存放主程序的指令。如main()函数、自定义函数编译后的机器指令。.rodata:存放只读数据。如字符串常量”Hello World!”。
2. 数据段
数据段用于存放全局变量和静态变量(static变量),分为如下两部分。
.data:存放已初始化(已显式赋初值)的全局变量和静态变量,如int x=5;。.bss:存放未初始化的全局变量和静态变量,如int y;。
3. 进程控制块(PCB)
虽然PCB是管理进程的核心数据结构,但它并不属于进程的虚拟地址空间。它由操作系统内核维护,存放在系统内核区中,对用户进程不可见。
4. 堆
堆是指程序运行时动态申请内存的区域。通过 malloc() 等函数从堆中申请内存,不再需要时通过 free() 释放。堆从低地址向高地址增长,大小不固定,适合存储生命周期不确定的数据。例如,C语言语句 int* ptr = (int*)malloc(4) 会在堆上分配4字节内存,用于存储一个整数。
5. 栈
栈用于支持函数调用,存放局部变量、函数参数、返回地址等临时信息,由系统自动管理。遵循后进先出(LIFO)原则,栈从高地址向低地址扩展。例如,调用函数 add(3,5) 时,参数3、5和返回地址会被压入栈;函数执行结束后,这些数据被弹出,栈指针自动回退。
6. 共享库的存储映射区
共享库用于提供进程所依赖的公共函数代码(如 printf() 等标准库函数)。它们在运行时被动态映射到进程的地址空间,代码是只读的,且可被多个进程共享,从而节省内存资源。
一个典型进程在内存中的映像。最高地址处是用户栈,随函数调用向下增长;其下方是共享库的映射区域,用于加载动态库;再往下是堆,由 malloc() 等函数动态扩展;堆下方是读/写数据段(.data和.bss);底部是只读代码段(.init、.text、.rodata);注意,代码段和数据段的大小在程序加载时就已确定,而堆和栈则可在运行过程中动态扩展和收缩。
2.1.4 进程的状态与转换
进程在其生命周期中,由于与其他进程的相互制约以及系统运行环境的变化,其状态会不断发生转换。通常,进程具有以下五种状态,其中前三种为基本状态。
运行态。进程正在CPU上执行。在单CPU中,任一时刻最多只有一个进程处于运行态。就绪态。进程已获得除CPU外的所有必要资源,一旦获得CPU,便可立即投入运行。系统中可能有多个就绪进程,通常将它们组织成一个队列,称为就绪队列。阻塞态,也称等待态。进程因等待某一事件(如某个资源可用、I/O操作完成等)而暂停运行。此时即使CPU空闲,该进程也无法执行。系统通常将处于阻塞态的进程组织成一个阻塞队列,甚至根据阻塞原因的不同,还可进一步划分为多个子队列。创建态。进程正处于创建过程中,尚未进入就绪态。创建进程包括多个步骤:首先申请一个空白的 PCB,并向其中填写用于控制和管理进程的信息;然后为该进程分配运行时所需的资源(如内存空间);最后,当所有必要资源都已到位,系统将该进程转入就绪态,并插入就绪队列。创建态是一个短暂的中间状态,正常情况下不会长期停留。终止态。进程已完成执行(无论是正常结束还是被强制终止),正在等待系统回收其资源。此时,进程不再参与调度,但其PCB等信息可能暂时保留,直至完成最终清理。
区分就绪态与阻塞态:就绪态是指进程仅缺少CPU,只要获得CPU就可立即运行;而阻塞态是指进程需要其他资源(除CPU外)或等待某一事件。之所以将CPU与其他资源分开,是因为在分时系统的时间片轮转机制中,每个进程分得的时间片很短(通常为几毫秒),因此进程在运行过程中会频繁地在运行态与就绪态之间切换;而其他资源(如外设)的分配或事件(如I/O完成)的发生往往需要较长时间,导致进程进入阻塞态的频率远低于就绪态。由此可见,就绪态和阻塞态在进程生命周期中代表了两种性质完全不同的等待情形。
五种进程状态之间的转换关系。
就绪态→运行态:处于就绪态的进程被调度程序选中,获得CPU的使用权(例如分配到一个时间片),随即由就绪态转换为运行态。运行态→就绪态:处于运行态的进程在时间片用完后,必须主动让出CPU,从而转回就绪态;此外,在可剥夺式调度系统中,若有更高优先级的进程变为就绪,则当前运行的进程也会被强制切换回就绪态,以便让更高优先级的进程执行。运行态→阻塞态:当运行中的进程通过系统调用请求某项服务(如发起I/O操作),而该请求无法立即满足(需等待I/O完成),进程便会主动进入阻塞态。需注意,并非所有系统调用都会导致阻塞,只有那些需要等待外部事件的操作才会引发状态转换。阻塞态→就绪态:当进程所等待的事件发生时(如I/O操作完成),相应的中断处理程序会将其状态由阻塞态修改为就绪态,并重新插入就绪队列,等待下一次调度。
值得注意的是,进程从运行态变为阻塞态是一种主动行为(由自身发起的系统调用触发),而从阻塞态变为就绪态则是一种被动行为,依赖于外部事件的发生或其他进程的协助。
2.1.5 进程控制
进程控制的功能是对系统中所有进程进行有效的管理,主要包括创建新进程、撤销已有进程、实现进程状态转换等操作。在操作系统中,这些控制操作通常以原语的形式实现。原语在执行期间不允许被中断,是一个不可分割的基本单位,确保了关键操作的原子性和一致性。
1. 进程的创建
允许一个进程创建另一个进程。创建者称为父进程,被创建的进程称为子进程。子进程在创建时会复制父进程的当前状态,包括程序代码、数据段、堆栈内容、打开的文件描述符、环境变量等。然而,子进程与父进程是相互独立的实体,拥有各自的PCB和虚拟地址空间。
在操作系统中,终端用户登录系统、作业调度启动批处理任务、系统提供服务、用户程序发起应用请求等,都会触发进程的创建。操作系统通过创建原语建立新进程,具体步骤如下。
分配唯一标识并申请PCB:为新进程分配唯一的进程标识号(PID),并从系统PCB池中申请一个空白PCB。若PCB资源耗尽,则创建失败,系统返回错误。分配新进程运行所需的资源:如内存、文件、I/O设备等(记录在PCB中)。这些资源或从操作系统获得,或从其父进程继承。初始化PCB:初始化的内容包括:标识信息、处理机状态信息、调度与控制信息和内存与资源信息。通常将新进程设置为最低优先级,除非用户显式提出高优先级要求。插入就绪队列:若系统允许新进程参与调度,则将其PCB插入就绪队列,等待调度。
2. 进程的终止
引起进程终止的事件主要包括:
- 正常结束,表示进程完成其预定任务后主动退出。
- 异常结束,表示进程在运行过程中发生严重错误,导致无法继续执行。常见原因包括:存储区越界、非法指令、特权指令违规、算术运算错误、I/O故障、运行超时等。
- 外界干预,由外部实体请求终止进程,如操作员强制终止、操作系统因资源不足或安全策略终止进程、父进程通过系统调用请求终止其子进程。
操作系统通过终止原语完成进程的撤销,具体步骤如下。
- 根据被终止进程的标识符,检索其PCB,并读取当前状态。
- 若该进程正处于运行态,立即剥夺其CPU,并触发调度程序选择下一个就绪进程。
- 释放该进程占用的所有系统资源,统一归还给操作系统。
- 若系统支持级联终止,则递归终止其所有子孙进程;否则,子孙进程继续运行。
- 将该进程的PCB从所在队列(或链表)中移除,完成清理。
在某些系统中,当一个进程终止时,其所有子孙进程也必须随之终止,这种机制称为级联终止。然而,并非所有操作系统都采用这一策略。以Linux 为例,当父进程终止时,其子进程并不会被自动终止,而是由init进程(PID=1)收养,并继续正常运行。
3. 进程的阻塞和唤醒
当一个正在运行的进程因等待某个外部事件而暂时无法继续执行时,会主动调用阻塞原语(Block),使自己从运行态转换为阻塞态。常见的等待事件包括:请求系统资源暂时不可用、等待I/O操作完成、等待接收数据、等待信号量等。需要强调的是,阻塞是进程的主动行为,因此只有处于运行态的进程才能执行阻塞操作。阻塞原语的执行过程如下。
- 保存当前进程的CPU现场(如程序计数器、状态寄存器等)到其PCB中。
- 将其PCB中的状态字段修改为阻塞态,并将该PCB插入所等待事件的等待队列中。
- 调用调度程序,从就绪队列中选择另一个进程投入运行,以释放CPU资源。
当被阻塞进程所等待的事件发生时(如I/O操作完成、所需数据到达),由内核的中断处理程序或合作进程调用唤醒(Wakeup)原语,将该进程重新激活。唤醒原语的执行过程如下。
- 在指定事件的等待队列中找到目标进程的PCB。
- 将其从等待队列中移出,并将状态置为就绪态。
- 将该PCB插入就绪队列,等待调度程序在适当时机选中并恢复其执行。
需要注意是,Block与Wakeup是一对作用相反的原语,必须成对使用。若某进程调用了Block进入阻塞态,但系统中没有任何机制调用对应的Wakeup,则该进程将永久停留在阻塞态,无法再被调度执行。因此,在设计并发程序时,必须确保每个阻塞操作都有相应的唤醒路径。
2.1.6 进程的通信
进程通信(IPC)是指进程之间的信息交换。根据通信效率和数据量的不同,可分为低级通信和高级通信两类:低级通信主要用于传递控制信息(如同步、互斥),典型代表是PV操作(见2.3节);高级通信用于高效传输大量数据,主要包括共享存储、消息传递和管道通信、信号等方式。
1. 共享存储
进程的地址空间通常是相互隔离的,一个进程不能直接访问另一个进程的内存。因此,若要实现高效的数据交换,操作系统提供了共享存储机制:通过特殊的系统调用,创建一段独立的内存区域,并将其映射到多个进程的虚拟地址空间中,使这些进程都能直接读/写该区域。在共享存储方式中,参与通信的进程通过对这一共享区域进行读/写来交换信息,如图2.3所示。由于多个进程可能并发访问同一块内存,必须由用户程序引入同步互斥机制(如信号量的P、V操作),以避免数据竞争或不一致。需要注意的是,操作系统仅负责分配共享内存区并完成地址映射,并不会自动提供同步保护;具体的读/写逻辑与同步控制,均需由用户程序自行设计和实现。
通俗地理解,可以想象甲和乙之间放着一个公共的大布袋:甲把要传递的物品放进布袋,乙再从里面取出。但乙不能直接伸手去拿甲手里的东西,甲也不能直接取走乙的东西,所有交换都必须通过这个公共区域完成。
2. 消息传递
当进程之间没有共享内存,或出于安全等考虑不便使用共享存储时,可采用操作系统提供的消息传递机制进行通信。在消息传递系统中,进程间的数据交换以格式化的消息(Message)为单位,通过操作系统提供的发送原语和接收原语进行。这种方式将通信的底层细节封装在内核中,对用户透明,从而简化了应用程序的设计。消息传递是目前应用最广泛的进程间通信机制,在微内核操作系统中,微内核与服务器之间的交互正是基于消息传递。此外,该机制很好地支持多处理器系统、分布式系统和计算机网络,因此也成为这些环境中的主流通信方式。
消息传递可分为两种基本模式:
直接通信方式。发送进程通过发送原语将消息发给指定的接收进程;内核负责将消息从发送方复制到接收方的内核缓冲队列中;接收进程通过接收原语接收消息。间接通信方式。通信双方不直接指定对方,而是通过一个中间实体(通常称为信箱)进行消息交换。发送进程将消息投递到信箱,接收进程从信箱中取出消息。
通俗地理解,甲要向乙传递信息,便写好一封信交给邮差。在直接通信中,邮差将信件直接交到乙本人手中;在间接通信中,乙家门口设有一个邮箱,邮差只需将信投入该邮箱,乙随后自行取阅。无论哪种方式,消息的投递均由邮差(操作系统内核)完成消息的可靠投递。
3. 管道通信
管道通信是一种基于先进先出原则的进程间通信机制,常用于具有亲缘关系的进程(如父子进程)之间,以生产者-消费者模式进行数据交换。从编程接口上看,管道(Pipe)表现为一种特殊的共享文件,支持标准的 read() 和 write() 操作;但从实现上看,它并非存储在磁盘上的文件,而是由操作系统内核维护的一块固定大小的内存缓冲区。
为确保通信的正确性,管道机制必须提供三方面的协调能力:
- 互斥,指任一时刻只允许一个进程对管道进行读或写操作,防止多个进程并发访问导致数据混乱;
- 同步,指当管道满时,写进程自动阻塞,直到读进程取走部分数据;当管道空时,读进程自动阻塞,直到写进程向其中写入新数据;
- 写端关闭检测,当写端关闭后,读端
read()返回0,表明无数据可读。
在Linux系统中,管道是一种广泛使用的IPC机制。其主要特点包括:
固定缓冲区大小。管道使用内核中的环形缓冲区(如64KB),不会像普通文件那样无限制增长。当缓冲区满时,后续的write()调用将被阻塞,直到有足够空间可用。自动阻塞与唤醒。若读进程的消费速度快于写进程,则管道中的数据会被迅速读空。此时,任何read()调用将被阻塞,等待写进程再次写入数据。
管道由父进程通过 pipe() 系统调用创建。由于子进程在创建时会继承父进程的文件描述符表,因此能够通过这些描述符访问同一管道,从而与父进程进行通信。
NOTE从管道中读取数据是一次性操作,数据一旦被读出,其占用的缓冲区空间即被释放,供后续写入使用;普通管道仅支持单向通信,若需双向通信,则必须创建两个管道,分别用于两个方向的数据传输。
4. 信号
信号(Signal)是一种用于通知进程某个事件已发生的机制。不同的系统事件对应不同的信号类型,每类信号对应一个序号。例如,Linux内核实现了约30种信号,编号通常为 。
在进程的PCB中,用一个 位向量(如Linux使用一个32位整型变量)记录该进程当前待处理的信号。当向某进程发送一个信号时,内核会将该信号对应的位置为1;一旦该信号被成功处理,对应位即被清零。此外,PCB中还维护另一个 位向量,用于记录被阻塞(屏蔽)的信号。当某一位为1时,表示对应的信号不会被立即处理,而是暂存起来,直到解除阻塞。
信号的发送主要有以下两种方式:
内核发送信号。当内核检测到特定的系统事件时,会向相关进程发送相应的信号。例如,若进程执行了非法指令,则内核将向其发送SIGILL信号(序号为4)。由进程发送信号。一个进程可以通过调用kill()函数,请求内核向指定进程(需提供目标进程的PID和信号序号)发送信号。当然,进程也可以向自身发送信号。
当操作系统从内核态返回用户态时(如系统调用结束),会检查当前进程是否存在未被阻塞的待处理信号。若存在,则立即处理其中一个信号(通常优先处理序号较小的信号)。
信号的处理方式有两种:
执行默认的信号处理程序。操作系统为每类信号预设了默认操作。例如,收到SIGILL信号的默认操作是终止进程。执行进程自定义的信号处理程序。进程可以为某类信号定义自己的处理函数。例如,可定义收到SIGILL时输出”hello world”,而不是终止进程。
信号处理程序执行结束后,通常会返回到原程序被中断的位置,继续执行后续指令。
