引言
操作系统要实现 CPU 虚拟化(CPU virtualization),首先必须回答一个基础问题:怎样把一个静态的可执行文件,表示成一个可以被调度、暂停、恢复和终止的运行实体?
答案就是进程(process)。进程并不只是“正在运行的程序”这一句简单定义,而是操作系统对一次程序执行所做的完整抽象。它把代码、数据、CPU 执行现场和已经取得的系统资源组织为一个可管理的单位,使多个程序能够在有限的处理器上交替推进。
程序与进程
程序(program)通常是保存在磁盘上的静态文件。它包含指令和初始数据,但在尚未被加载执行时,不会占用 CPU 时间,也没有独立的执行位置。
进程则是程序的一次运行实例(instance)。同一个可执行程序可以被启动多次,因此可以同时对应多个不同进程。虽然它们的代码可能来自同一个文件,但各自拥有独立的运行状态、地址空间和资源引用,运行结果也可以完全不同。
从操作系统的视角看,一个进程至少具有以下特征:
- 正在执行一串指令;
- 具有当前执行状态,例如下一条将执行的指令位置;
- 占有或引用一组系统资源,例如内存、打开的文件和 I/O 设备;
- 能够被分配给某个 CPU 核执行。
因此,进程是操作系统进行调度、隔离和资源核算的基本单位。
进程包含什么
为了在进程暂停后仍能准确恢复执行,操作系统必须保存与该进程有关的关键信息。它们可以概括为两部分:进程可访问的内存,以及进程当下的执行现场。
地址空间
地址空间(address space)是一个进程所看到的内存地址范围。它是逻辑视图,并不等同于某一段连续的物理内存;操作系统和硬件会共同把虚拟地址转换成实际的物理位置。
一个典型进程的地址空间通常由以下区域组成:
| 区域 | 作用 |
|---|---|
| 文本段(text segment) | 存放处理器要执行的程序代码 |
| 数据段(data segment) | 存放全局变量和静态变量 |
| 堆(heap) | 存放运行时动态分配的数据,例如 malloc() 申请的对象 |
| 栈(stack) | 存放函数调用中的局部变量、参数、返回地址和部分保存的寄存器状态 |
堆和栈的存在说明,进程的内存内容会在运行中不断变化。程序代码可能相同,但每次执行所处理的数据、函数调用深度和动态分配结果都不同,这也是不同进程能够独立运行的基础。
执行现场
CPU 在执行进程时,会持续更新寄存器(register)中的值。例如,程序计数器(program counter)指出下一条指令的位置,栈指针(stack pointer)指出当前栈顶的位置,通用寄存器则保存计算过程中的中间结果。
当操作系统切换到另一个进程时,必须先保存当前进程的这些值;之后重新调度该进程时,再恢复这份状态。否则进程会丢失自己执行到了哪里,无法继续正确运行。
进程状态
进程并非从创建到结束始终占用 CPU。操作系统通过有限个离散状态描述进程当前正在做什么,并据此决定它是否可以被调度。
| 状态 | 含义 |
|---|---|
| 就绪(ready) | 已具备运行条件,但正在等待 CPU 分配 |
| 运行(running) | 正在某个 CPU 核上执行 |
| 阻塞(blocked) | 正在等待某个事件,例如磁盘 I/O 完成、网络数据到达或锁可用 |
| 终止(terminated) | 已经结束执行,等待操作系统和父进程完成后续清理 |
有些教材会把“新建(new)”列为初始状态,但真实系统中的内部实现不一定会显式维护这个名称。更重要的是理解状态转移的原因。
- 新进程完成必要初始化后进入就绪队列(ready queue)。
- 调度器(scheduler)选中该进程后,它从就绪变为运行。
- 运行中的进程请求尚未完成的 I/O 时,会从运行变为阻塞,让出 CPU。
- 等待事件发生后,阻塞进程回到就绪状态,而不是直接占用 CPU。
- 时间片耗尽或更高优先级任务到来时,操作系统可能抢占当前进程,使其从运行回到就绪。
- 进程执行结束或因错误、信号等原因终止时,进入终止相关状态。
这里必须区分就绪和阻塞。就绪进程唯一缺少的是 CPU;阻塞进程即使有空闲 CPU,也仍然无法继续执行,因为它等待的事件尚未发生。
进程控制块
操作系统使用进程控制块(Process Control Block, PCB),也称进程描述符(process descriptor),记录一个进程的管理信息。PCB 是进程抽象在内核中的主要数据结构。
典型 PCB 会保存:
- 进程标识符(process identifier, PID),即系统中的唯一编号;
- 当前进程状态;
- 程序计数器和寄存器上下文(register context);
- 调度信息,例如优先级和调度队列指针;
- 身份凭据(credentials),用于决定进程可访问哪些资源;
- 内存管理信息;
- CPU 使用时间、资源使用量和时间限制等核算信息;
- 指向父进程、子进程及已分配资源的引用。
可以把 PCB 理解为操作系统保存的“运行档案”。进程离开 CPU 时,最核心的任务之一就是把执行现场写回 PCB;恢复运行时,再从 PCB 取回状态。
进程表与队列
系统同时管理大量进程,因此不能靠遍历零散数据来寻找某个进程。操作系统会维护进程表(process table),其中保存或索引每个 PCB。Linux 等系统会使用适合快速查询的数据组织方式来管理这些记录。
除了按 PID 查询,调度还需要按状态访问进程。于是内核通常维护不同的链表或队列,例如:
- 就绪队列保存未运行但可立即调度的进程;
- 阻塞队列保存等待特定事件的进程;
- 每个 CPU 核还可能保留与当前执行进程相关的引用。
这些数据结构直接影响调度器的效率。系统不仅要“知道有哪些进程”,还要能迅速找到“此刻最适合运行的进程”。
在 Linux 或 macOS 中,可以用下面的命令观察进程信息和父子层级:
ps -ef
pstreeps 偏向列出进程属性,pstree 则突出进程之间的父子关系。
进程的基本操作
操作系统通常提供一组面向进程的基础操作:
- 创建进程;
- 终止进程;
- 挂起和恢复进程;
- 调整优先级;
- 等待子进程;
- 查询进程状态;
- 进行进程间通信(Interprocess Communication, IPC)。
这些操作共同构成进程管理接口。用户程序不能直接修改 PCB 或调度队列,而是必须通过系统调用(system call)请求内核执行相应操作。
创建进程
进程之间常常形成父子关系。创建新进程的一方称为父进程(parent process),新产生的执行实体称为子进程(child process)。子进程还可以继续创建后代,因此整个系统可以形成进程树(process tree)。
创建一个进程并不是只分配一个 PID。操作系统至少需要:
- 分配唯一的进程标识符;
- 为 PCB 及相关内核数据结构分配空间;
- 建立或复制所需的地址空间;
- 初始化程序计数器、栈指针和寄存器状态;
- 建立父子关系、文件引用和资源核算等关联;
- 将新进程设为就绪并加入合适的就绪队列。
fork() 与 exec()
Unix 系统将“创建进程”和“加载新程序”分成两个概念。
fork() 会创建调用进程的一个子进程。子进程最初继承父进程的执行环境,因此两者都会从 fork() 返回的位置继续执行。返回值用于区分两条执行路径:
- 返回负值表示创建失败;
- 在父进程中,返回值是子进程的 PID;
- 在子进程中,返回值为
0。
exec() 函数族则用新的程序映像(program image)替换当前进程的地址空间和代码。它不会创建新的 PID;成功后,原先的程序代码不再继续执行,进程从新程序的入口开始运行。
这两个操作分离,使父进程能够在子进程执行新程序之前调整文件描述符、环境变量或输入输出重定向。Shell 启动外部命令时,经常利用的正是这种模式:先 fork() 出子进程,再由子进程 exec() 目标程序。
Windows 的 CreateProcess() 把创建进程、创建主线程和加载程序组合成一个接口。从用途上看,它接近 Unix 中 fork() 后接 exec() 的常见组合。
终止与僵尸进程
进程可以主动执行 exit(),或从 main() 返回来结束运行。它也可能被父进程发送终止信号,或因非法内存访问等错误被操作系统终止。
进程退出时,操作系统会回收绝大多数资源,但通常不会立即抹去全部记录。父进程可能需要读取子进程的退出状态(exit status)和资源使用信息,因此内核会暂时保留必要的 PCB 信息。
在 Unix 中,已经结束执行、但父进程尚未通过 wait()、waitpid() 或 waitid() 收取其状态的子进程称为僵尸进程(zombie process)。它不再执行,也不占用 CPU,但仍在进程表中保留一项记录。
这解释了僵尸状态的意义:它让父进程能够判断子进程是否成功完成,而不是在退出瞬间丢失所有结果。父进程完成等待操作后,内核才会清除对应记录。若父进程先退出,子进程通常会被系统中的其他进程接管,以便其最终状态仍能被正确回收。
挂起与阻塞
挂起(suspension)意味着暂时停用一个进程,使其不参与处理器调度。被挂起的进程必须由其他进程或操作系统恢复后,才可能再次获得运行机会。
挂起可能源自用户请求、父进程控制,或者操作系统为了释放内存而暂时移出某个进程。
挂起和阻塞的区别在于触发原因:
- 阻塞通常源于进程自身的执行逻辑,例如它发起 I/O 后需要等待结果;
- 挂起通常来自外部控制,例如用户、父进程或内核的资源管理决策。
一个进程甚至可以同时具有“正在等待事件”和“被外部挂起”这两层含义。实际系统会用更细致的内部状态表达这种组合。
UNIX 信号
信号(signal)是 Unix 系统中用于通知进程某个事件已经发生的机制,常被称为软件中断(software interrupt)。它们让内核、终端和其他进程能够向目标进程传递异步控制信息。
常见例子包括:
SIGINT:通常由终端中的Ctrl-C触发;SIGCHLD:子进程退出或终止时通知父进程;SIGSEGV:进程发生非法内存访问时产生。
同步与异步信号
同步信号(synchronous signal)由当前进程正在执行的指令直接引起。例如,进程解引用空指针、执行非法指令或发生除零错误时,硬件会触发异常,内核识别原因后立即向该进程递送相应信号。
异步信号(asynchronous signal)则来自当前执行路径之外的事件,抵达时间不可由目标进程的当前指令预测。例如,父进程调用 kill() 向子进程发送信号,或者一个进程退出后内核向其父进程发送 SIGCHLD。
信号处置
进程收到某类信号后,可以设置不同的处理方式:
- 捕获(catch):预先注册信号处理函数(signal handler),信号到达时由内核转入该处理函数;
- 忽略(ignore):告知内核该进程不处理此类信号;
- 屏蔽(mask):暂时阻止某类信号递送,直到进程解除屏蔽;
- 默认动作(default action):采用操作系统为该信号预设的行为。
程序通常通过 sigaction() 等系统调用配置处理方式。相关信息会记录在 PCB 所关联的信号处理结构中。子进程在创建时会继承父进程的设定;但当进程使用 exec() 装载新程序时,先前自定义的信号处理函数会重置为默认处置。
SIGKILL 和 SIGSTOP 是两个重要例外:进程不能捕获、忽略或屏蔽它们。这保证了操作系统始终保留强制终止或停止进程的能力。
整体理解
进程抽象把一次程序运行变成操作系统可以精确管理的对象。地址空间描述它能访问什么,寄存器上下文描述它执行到哪里,PCB 保存这些状态,进程表和队列使内核能够高效查找与调度。
在这一层之上,fork()、exec()、wait()、信号、挂起和终止共同构成了进程的生命周期控制机制。理解它们之后,后续的上下文切换、CPU 调度、多线程、并发和进程间通信才有明确的基础。