引言

操作系统要实现 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 中,可以用下面的命令观察进程信息和父子层级:

bash
ps -ef
pstree

ps 偏向列出进程属性,pstree 则突出进程之间的父子关系。

进程的基本操作

操作系统通常提供一组面向进程的基础操作:

  1. 创建进程;
  2. 终止进程;
  3. 挂起和恢复进程;
  4. 调整优先级;
  5. 等待子进程;
  6. 查询进程状态;
  7. 进行进程间通信(Interprocess Communication, IPC)。

这些操作共同构成进程管理接口。用户程序不能直接修改 PCB 或调度队列,而是必须通过系统调用(system call)请求内核执行相应操作。

创建进程

进程之间常常形成父子关系。创建新进程的一方称为父进程(parent process),新产生的执行实体称为子进程(child process)。子进程还可以继续创建后代,因此整个系统可以形成进程树(process tree)。

创建一个进程并不是只分配一个 PID。操作系统至少需要:

  1. 分配唯一的进程标识符;
  2. 为 PCB 及相关内核数据结构分配空间;
  3. 建立或复制所需的地址空间;
  4. 初始化程序计数器、栈指针和寄存器状态;
  5. 建立父子关系、文件引用和资源核算等关联;
  6. 将新进程设为就绪并加入合适的就绪队列。

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 调度、多线程、并发和进程间通信才有明确的基础。

返回目录