进程是操作系统里最古老的概念之一,也是最核心的那个。几乎所有的操作系统功能都围着它转:调度器调度的是它,内存管理分配地址空间的单位是它,文件系统记录的是它打开了哪些文件。可以毫不夸张地说,没有进程这个抽象,现代操作系统根本无法实现。
先看看系统里到底跑着什么
在讨论进程怎么被管理之前,先得能看见它们。Linux 提供了几个常用工具,从不同角度把进程的状态铺开给人看。
ps:静态快照
ps 是最常见的进程查看工具,它给出的是当前时刻各进程信息的静态快照,通过不同选项可以控制拍什么:
| 选项 | 含义 |
|---|---|
a |
显示当前终端下的所有进程,包括其他用户的进程 |
u |
以用户为主的格式显示进程状态 |
x |
通常和 a 一起用,显示当前用户在所有终端下的进程 |
-e |
显示系统内所有进程 |
-l |
用长格式显示 |
-f |
用完整格式显示 |
两个最常用的组合是 ps aux 和 ps -elf。前者按用户视角列出,输出大致长这样:
1 | USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND |
USER 是进程所属用户,PID 是进程号,%CPU 和 %MEM 是 CPU 与内存占用百分比,VSZ 是虚拟内存大小,RSS 是常驻物理内存,TTY 是进程挂在哪个终端上(? 表示不占终端,多半是后台守护进程),STAT 是进程状态,START 是启动时间,TIME 是累计占用 CPU 的时间,最后一列是启动命令。
ps -elf 则用长格式给出更丰富的内容,父进程 PID、优先级这些都会列出来,排查进程间的父子关系时更方便。
top:动态刷新
ps 拍完照就结束,top 则是全屏交互式地持续刷新,默认每三秒更新一次,把进程按资源占用排个名次,谁在吃 CPU、谁在占内存一目了然。它承担的角色和 Windows 里的任务管理器差不多。
pgrep 与 pstree
pgrep 按属性查 PID,进程名、运行用户或所在终端都行。比如查用户 fan 正在运行的所有进程:
1 | pgrep -u fan |
pstree 则把进程按树形结构打出来,父子关系一眼可见。这条命令在理解”进程是有层次的”这件事上特别直观,后面讲进程树时还会用到。
为什么需要”进程”这个抽象
从一台 Web 服务器说起
想象一台 Web 服务器在处理页面请求:每个请求到达时,先查本地缓存,缓存没命中就去读磁盘。而读磁盘这个过程相对耗时较长,如果这段时间服务器无法处理其他任务,那吞吐量会很低。
实际上服务器能继续工作,它在等待磁盘响应期间继续接收并处理其他请求。如果机器上配备了多个磁盘,还能通过并行处理机制,在首个磁盘请求尚未返回时,第二个磁盘请求的结果可能已经到达。
桌面上也是一样。哪怕只有你一个用户,系统启动后后台也跑着一堆东西:收邮件、提供网页服务、处理打印作业等等。再加上你自己开的浏览器、编辑器、终端,几十上百个进程同时存在是常态。
一个能同时容纳这么多进程的多道程序系统,就成了现代计算机的基础架构。
伪并行与真并行
CPU 在多个进程之间快速切换,每个进程一次只跑几十到几百毫秒。任一时刻,一个 CPU 核心严格来说只能执行一个进程的指令;但把观察尺度放到一秒,看上去就像所有进程在同时推进。这种效果被称为伪并行或并发。
要和真并行区分开:真并行是多处理器系统里多个 CPU 同时执行不同任务,物理上确实同时在执行。并发是逻辑上的同时,单核也能实现,靠的是快速切换;并行是物理上的同时,必须有多核。
但是伪并行下 CPU 时间片的分配带有不确定性,某个进程这一轮分到多少时间、下一轮还剩多少,都不固定。
而因为切换的速度太快,我们根本没法直接追踪多个并行推进的进程到底执行到哪了。为了能分析和推理,操作系统设计者提出了一个概念模型,顺序进程模型。
进程模型
顺序进程
在进程模型里,计算机上运行的所有软件,包括操作系统本身,都被逻辑地组织成一系列顺序进程(Sequential Processes),简称进程(Processes)。
一个进程,是某个正在执行中的程序的一个具体实例。它包含的东西不止代码:
- 程序的代码
- 程序计数器的当前值
- 寄存器的当前状态
- 程序里变量的即时值
每个进程在抽象层面都像是配了一台专属的虚拟 CPU,可以独立推进。物理层面当然只有一颗 CPU,它在多个进程间快速切换,制造出并发的假象。
四个程序计数器
这是理解进程模型的一张概念图。假设有四个程序ABCD在跑:
1 | 程序计数器 |
把每个程序抽象成一个独立进程后,每个进程都拥有属于自己的逻辑程序计数器(逻辑 PC),各自记录着自己执行到了哪条指令。但物理上,机器只有一个物理程序计数器。
两者的配合方式是这样的:
- 某个进程要运行时,把它的逻辑 PC 当前值加载进物理 PC,CPU 据此执行指令序列;
- 该进程被切走时,物理 PC 的值(也就是它执行到的位置)被记录下来,复制回这个进程自己的逻辑 PC 中;
- 下次再轮到它,从逻辑 PC 恢复,接着往下跑。
flowchart LR
A["进程 A 的逻辑 PC"] -->|"① 加载"| P["物理 PC
(只有一个)"]
P -->|"② 执行指令"| E["CPU 执行"]
E -->|"③ 切走时保存回去"| A
正是这套”加载—保存—恢复”的机制,让多个程序能在单个物理 CPU 上实现逻辑上的并行。观察足够长的一段时间,所有进程都前进过;但在任何一个给定的瞬间,真正在运行的只有一个。
进程和程序的关系
设想一位精通烹饪的计算机科学家要给女儿烤生日蛋糕,他需要一本食谱,一些食材,根据食谱对食材进行操作。
烤到一半,科学家的儿子跑来说,他被蜜蜂蜇了。科学家会暂停烘焙,记下当前进度(蛋糕在烤箱里烤了多久、下一步该做什么),转去查急救手册处理伤口。等处理完再回到烘焙进程,从暂停点接着干。
这就是进程切换:CPU 从”烘焙蛋糕”切到”医疗救助”,处理完再切回来。我们也可以看出,一个进程所需的要素,包含程序、输入输出和状态信息这三样。
| 概念 | 对应物 |
|---|---|
| CPU | 科学家本人 |
| 程序 | 烘焙蛋糕的食谱 |
| 输入数据 | 面粉、鸡蛋等食材 |
| 进程 | 从读食谱、备料、烘烤到出炉这一整套连贯动作 |
另外,即便程序代码相同,每个进程实例的执行也是独立且唯一的。同一个程序启动两次,就是创建了两个不同的进程。
进程的创建
操作系统需要一些机制来创建进程,常见的有四种途径:
- 系统初始化(
init) - 正在运行的程序执行了创建进程的系统调用(比如
fork) - 用户请求创建一个新进程
- 初始化一个批处理工作
系统初始化
系统启动过程中会批量创建进程。这些进程按功能分成两类:一类是前台进程,职责是直接和用户交互,响应用户操作;另一类跑在后台,不面向特定用户,专注执行系统级或服务级的任务。
后台进程的典型工作方式是:大部分时间处于休眠状态,以节省系统资源,一旦检测到新任务到达,它会被立即唤醒并执行相关操作。负责接收邮件的进程平时处于休眠状态,检测到有新邮件到达才被唤醒,执行邮件接收和处理;负责网页服务的进程则监听网络请求,请求到达时同样被唤醒,执行相应的网页服务逻辑。
这类默默待在后台、处理邮件收取和网页服务等任务的进程,在 UNIX/Linux 里有个专门的称呼:守护进程(daemon)。大型系统上服务种类多,跑着大量守护进程是正常现象。
查看它们的工具前面已经见过:UNIX/Linux 下用 ps,Windows 下用任务管理器。
系统调用创建
启动阶段之外,新进程也可以在运行期间被动态创建。典型场景是大规模数据处理:一个进程专门从网络读数据放进共享缓冲区,另一个进程专门从缓冲区取数据处理。分工之后任务管理更简单,吞吐量也更高。在多处理器系统上,把不同进程分给不同 CPU 核心,还能真正并行起来。
用户请求创建
在交互式系统里,用户敲一条命令或者双击一个桌面图标,就能启动一个程序,这些操作都会触发新进程的创建。
UNIX 系统上通过 X Window 启动程序时,新创建的进程通常会接管与之关联的图形窗口界面,保证用户交互顺畅。Windows 的做法不同,启动进程时并不总是立刻创建窗口,但进程随时可以按需创建一个或多个窗口来展示信息、接收输入。每个窗口是一个独立的交互界面,背后对应着某个进程,用户用鼠标或键盘在窗口间切换,就是在和不同的进程打交道。
游戏、浏览器、IDE 这类以人机之间大量交互为特征的系统,都属于交互式系统。
批处理创建
大型机的批处理系统里还有另一种场景。用户提交一批作业交给操作系统管理,当系统资源允许、判定还能处理更多任务时,操作系统主动创建新进程,从作业队列里挑出下一个待执行的作业运行。
到这里为止,我们把四种创建方式都过了一遍。不管是在批处理系统、用户交互环境(比如通过键盘或鼠标操作),还是自动运行的批处理程序里,新进程的创建最终都要落实成系统调用的执行。系统调用是软件和操作系统之间沟通的桥梁,它指示操作系统启动一个新的进程实例,并可能指定要在这个进程里运行的程序。
UNIX的系统调用:fork 与 execve
UNIX 提供的这个系统调用叫 fork。它的行为很特别:生成一个和当前进程(父进程)几乎完全相同的副本,内存映像、环境变量、打开的文件描述符统统复制一份。这个副本就是子进程。
复制出来之后,父子进程要分道扬镳,分叉的依据是 fork() 的返回值。UNIX 里每个进程都有一个唯一的整型进程标识符(PID)。fork() 一次调用、两次返回:子进程拿到 0,父进程拿到子进程的 PID。两个进程都从 fork() 之后的指令继续往下走,各自判断返回值就知道自己是谁。
flowchart LR
P["父进程"] --> F(["pid = fork()"])
F -->|"pid 非 0:父进程"| W(["wait()"])
W --> R["父进程继续执行"]
F -->|"pid 为 0:子进程"| E(["exec()"])
E --> X(["exit()"])
X --> W
父进程这一路拿到的是子进程的 PID,它调用 wait() 把自己挂起,等子进程结束再继续。子进程这一路调用 execve,用新程序替换掉自己当前的内存映像,程序跑完后通过 exit() 退出。子进程的退出会唤醒父进程的 wait(),父进程从那里接着往下走。
以用户在 shell 里执行 sort 命令为例:
第一步是 fork。shell 调用它复制出一个和自身几乎完全相同的子进程,父进程的内存映像、环境变量和打开的文件描述符都有一份副本。第二步是 execve,由子进程调用,把刚刚复制来的内存映像替换成 sort 程序,此后这个子进程执行的便是 sort。这期间 shell 本身不执行其他任务,它在 fork 之后调用 wait 把自己移出就绪队列,等子进程执行完再恢复。
flowchart LR
S["🐚 shell"] -->|"① fork()"| C["👶 子进程
(shell 的副本)"]
C -->|"② execve('/usr/bin/sort')"| T["📊 sort 程序
(内存映像被替换)"]
S -->|"③ wait() 等待"| W["⏸️ shell 挂起"]
T -->|"④ 执行完毕"| W2["▶️ shell 恢复"]
为什么要拆成两步?因为 fork 和 execve 之间留出了一段空隙,子进程可以在这段空隙里做准备工作,重定向标准输入、输出、错误流就是在这儿做的。等准备工作做完再 execve,新程序一启动就已经拿到了正确的上下文环境。
C 代码演示
1 |
|
pid = fork():调用后系统里多了一个进程。父进程继续往下走,子进程也从这一行之后开始走。pid < 0:fork失败(比如系统进程数已达上限),打印错误并返回。pid == 0:只有子进程会走到这个分支。它调用execlp,用/bin/ls这个程序覆盖掉自己的地址空间,从此它变成了ls。else(pid > 0):父进程走到这里,拿到的pid是子进程的 PID。它调用wait(NULL)把自己移出就绪队列,一直等到子进程终止才继续,然后打印Child Complete。
这里有个细节:execlp 是 exec 家族的一个版本,作用是加载新程序并执行。因为新程序覆盖了原来进程的地址空间,所以 exec 调用除非出错,否则不会返回,原来的代码已经被换掉了,没有”返回”这回事。
子进程除了 pid 值为 0 之外,还继承了父进程的权限、调度属性以及某些资源(比如已经打开的文件)。
Windows 的做法:CreateProcess
UNIX 是”先复制自己,再换成新程序”,两件事拆成两个调用;Windows 则是一个调用全包,CreateProcess 直接把指定的程序加载进新进程。
但是这个调用有 10 个参数,涵盖要执行的程序与命令行参数、安全属性和句柄继承控制位、优先级信息以及窗口规格,还有一个指向结构的指针,新进程的信息通过它返回给调用者。
1 |
|
这段代码创建了一个子进程并加载 mspaint.exe,10 个参数里大部分用了默认值。两个结构体实例的作用分别是:
STARTUPINFO:指定新进程的特性,比如窗口大小和外观、标准输入输出的文件句柄PROCESS_INFORMATION:装着新进程及其线程的句柄与标识符
调用之前先用 ZeroMemory() 把这两个结构体清零。前两个参数是应用程序名和命令行参数,这里应用程序名传 NULL,表示由命令行参数来指定要加载的程序。其余参数走系统默认:继承进程和线程句柄、无创建标志、沿用父进程的环境块和启动目录。
对应的等待方式也不同。UNIX 下父进程调 wait() 等子进程,Windows 下是 WaitForSingleObject(),参数传子进程的句柄 pi.hProcess。子进程一退出,控制权就从这里回到父进程。最后两句 CloseHandle 关掉句柄,避免句柄泄漏。
两大系统的系统调用对照
下表列出了 UNIX 和 Win32 在进程与文件管理上的对应关系。表里每一行都能对上一个”同功能”调用,但对照着看会发现:两边的调用在数量、参数和组织方式上并不对称,这种不对称正是两个系统设计思路差异的直接产物:
| UNIX | Win32 | 说明 |
|---|---|---|
fork |
CreateProcess |
创建一个新进程 |
waitpid |
WaitForSingleObject |
等待一个进程退出 |
execve |
none | CreateProcess = fork + execve 的合并版 |
exit |
ExitProcess |
终止执行 |
open |
CreateFile |
创建或打开一个已有文件 |
close |
CloseHandle |
关闭文件 |
read |
ReadFile |
从文件中读取数据 |
write |
WriteFile |
向文件写数据 |
lseek |
SetFilePointer |
移动文件指针 |
stat |
GetFileAttributesEx |
获得文件属性 |
mkdir |
CreateDirectory |
创建新目录 |
rmdir |
RemoveDirectory |
移除空目录 |
link |
none | Win32 不支持 link |
unlink |
DeleteFile |
销毁一个已有文件 |
mount |
none | Win32 不支持 mount |
umount |
none | 同上,不支持 mount 也就谈不上 umount |
chdir |
SetCurrentDirectory |
切换当前工作目录 |
chmod |
none | Win32 不支持安全 |
kill |
none | Win32 不支持信号 |
time |
GetLocalTime |
获取当前时间 |
思路一:UNIX 的”先复制、再替换”
UNIX 把”造出一个新进程”拆成了两个独立系统调用,复制和替换:
fork:克隆当前进程,产生一个内存映像、环境变量、打开的文件描述符都几乎一致的子进程。克隆出来的子进程跑的还是父进程的程序。execve:加载另一个可执行文件,覆盖掉当前进程的内存空间。进程本身不变(还是同一个 PID、同一个进程表项),但里面跑的代码和已经是另一个程序了。
拆成两步,关键就在于中间那段空隙。在 fork 返回之后、execve 执行之前,子进程有一段只属于自己的执行时间,可以在里面先把环境布置好,再让新程序接手。第 10 页讲 shell 执行 sort 时的说法是:
这种两步法的优势在于,它允许子进程在
fork与execve之间执行一些必要的准备工作,比如重定向标准输入、输出和错误流,从而为即将运行的程序提供必要的上下文环境。
拿 ls > out.txt 举例,shell 的执行过程是:
flowchart LR
A["shell 调用 fork()"] --> B["子进程:把标准输出
重定向到 out.txt"]
B --> C["子进程调用 execve()
加载 /bin/ls"]
C --> D["ls 以为自己在往终端写
实际写进了 out.txt"]
重定向发生在 execve 之前,改的是子进程自己的文件描述符表。等 ls 被加载进来时,它看到的标准输出已经是 out.txt 了,而它自己完全不知情。这套机制到了 Linux 才暴露出真正的威力。因为重定向在 exec 之前,所以管道、输入输出重定向这些 shell 功能对任何程序都自动生效,被加载的程序不用为此写一行代码。UNIX 的工具因此可以写得很小,只干一件事,组合和拼接交给 shell。
但是两次系统调用、两次从用户态陷入内核,以及一次”明知会被丢掉”的地址空间复制,都是这套设计要付出的成本。前者是设计换来的灵活性,后者就是后面写时复制要解决的问题。
思路二:Windows 的”一步到位”
Windows 走了另一条路:**没有 fork,也没有单独的 execve**,而是用 CreateProcess 把两件事一次做完。
从表里能直接读出这条设计路线的三个特点。
1. 调用少,参数多。 fork 不需要参数,execve 三个参数,而 CreateProcess 有 10 个参数。因为”用哪个程序、命令行是什么、安全属性怎么给、优先级多少、窗口什么规格”这些决定,在 UNIX 里是分给 fork 之后的准备动作去做的,在 Windows 里必须在创建进程的这一刻全部确定。
2. 没有中间窗口,也就不需要重定向技巧。 子进程一诞生就带着目标程序,不存在”先跑着父进程的代码、等会儿再换掉”的阶段。所以 Windows 上做输入输出重定向,靠的是创建时传入的 STARTUPINFO。博客前面那段代码里,STARTUPINFO 的作用正是”指定新进程的特性,比如窗口大小和外观、标准输入输出的文件句柄”,而不是像 UNIX 那样在 fork 之后去改子进程的描述符表。
3. 只管创建,不管”接下来干什么”。 UNIX 里父进程可以 fork 出一堆子进程,让它们继续跑同一份程序的不同分支。典型的例子就是网络服务器,父进程 fork 之后,子进程和父进程走同一个代码里不同的 if 分支。Windows 的 CreateProcess 则总要绑定一个外部可执行文件,不能”复制自己但暂时不换程序”。
| UNIX | Windows | |
|---|---|---|
| 基本单元 | 两个小调用 fork + execve |
一个大调用 CreateProcess |
| 参数 | fork 无参,execve 三个 |
10 个 |
| 灵活性的来源 | 两步之间的空隙(重定向、设置环境) | 调用本身带全套选项(安全属性、句柄继承、优先级) |
| 缺点 | 两次陷入内核 + 多一次地址空间复制 | 调用笨重,创建时必须想清楚所有参数 |
UNIX 的组合式设计给了 shell 和工具链极大的自由度,管道和重定向就是直接架在”两步之间的空隙”上的,”一切皆文件””小程序组合”的哲学跟它一脉相承;Windows 的整合式设计则是把进程创建需要的所有信息一次性摆到台面上,但是调用本身笨重。但不管怎么选,”复制一份再换程序”这种方式对操作系统本身是个不小的负担,所以也就有了下面的写时复制。
写时复制
不管哪个系统,进程创建机制都保证了一件事:父子进程各有独立的地址空间。一个进程改了自己地址空间里的数据,另一个进程看不见,数据隔离性由此保证。
UNIX 的实现细节值得一提。子进程的地址空间是父进程的一份初始副本,但两个地址空间在逻辑上完全独立:即便初始数据相同,任何修改都只发生在发起修改的那个进程里。
不过,如果 fork 真的把父进程的整个地址空间逐字节复制一遍,开销会非常大,而实际上大多数子进程紧接着就要 execve 换掉整个地址空间,fork的这步复制会被浪费。于是 UNIX 用了写时复制(copy-on-write)来优化:
flowchart TB
F["fork() 之后"] --> S1["父子进程共享同一份物理内存页
(只读)"]
S1 -->|"子进程尝试写入"| C["内核触发页错误
把该页复制一份给子进程"]
C --> S2["子进程在私有副本上修改
父进程不受影响"]
S1 -->|"双方都只读"| S3["始终共享,一次复制都不发生"]
在子进程尝试修改某个可写区域之前,这块内容一直和父进程共享;真的发生写入时,内核才把那一页复制到子进程的新位置。至于只读段(比如代码段)这类本来就不需要修改的区域,则设计成始终共享。
地址空间隔离并不妨碍进程间共享资源,打开的文件描述符就是例子。新创建的子进程能继承并访问父进程已经打开的文件。
Windows 的做法更直接:从进程创建的那一刻起,父子进程的地址空间就被明确划分成两个完全不同的空间。它同样提供了进程间通信和资源共享的机制,只是这些机制不走”共享地址空间”这条路。
进程的终止
进程不会永远跑下去。触发的条件有四类:
- 正常退出(自愿的)
- 错误退出(自愿的)
- 严重错误(非自愿的)
- 被其他进程杀死(非自愿的)
正常退出
大多数终止是自然发生的:进程完成预定任务后自行结束。编程语境下,这个动作由特定的系统调用触发,通知操作系统”我干完了,可以回收资源了”。
UNIX 里是 exit 调用。它不仅标志进程结束,还允许进程向操作系统传递一个退出码,用来表示自己是成功还是失败。Windows 里对应的是 ExitProcess,作用相同:通知系统当前进程执行完毕,请求清理资源。
这类终止不一定都要程序员写代码触发。面向用户的软件通常会在界面上提供图标、按钮或菜单项让用户主动关闭,点一下关闭按钮,软件先做一系列清理(删除自己创建的临时文件等),然后调用系统调用正式终止进程。
错误退出
第二类终止来自运行中遭遇的严重错误:文件缺失、权限不足或参数错误。
编译场景很典型:
1 | gcc foo.c |
如果 foo.c 根本不存在,GCC 找不到文件,识别出这是严重错误,输出错误消息到控制台,然后调用 exit(UNIX)或 ExitProcess(Windows)终止自己。这种终止立即且不可逆转,没有源文件,编译任务不可能继续。
交互式程序遇到可恢复的错误时,处理方式通常更友好。它不会立刻退出,而是弹个对话框说明情况,给出”重试””取消””退出”之类的选项让用户决策。比如文本编辑器打开一个不存在的文件,会提示”无法找到文件,请检查文件名后重试,或选择取消”。这样可用性更好。
严重错误
第三类来自程序自身的内部错误:编程疏忽、逻辑错误或是运行时异常。典型的有执行非法指令、访问无效内存地址、做除零运算。这些会让进程的运行状态变得不稳定,进而触发终止。
UNIX 为此提供了一套更灵活的机制:信号(signal)。信号可以理解成一种软中断,用来通知进程发生了某个特定事件。
进程收到信号后,可以根据信号类型和自己的设置决定怎么响应:有些信号可以选择忽略(如果系统允许),也可以注册一个信号处理程序(signal handler)来自定义响应动作。
举个例子,进程尝试访问非法内存地址时,操作系统会向它发送一个段错误信号(通常是 SIGSEGV)。如果进程没为这个信号提供处理程序,默认行为就是终止它;如果进程注册了处理程序,那它可以在处理程序里做点清理工作再安全退出,或者尝试恢复到稳定状态继续执行。
信号机制让进程面对错误时能做出更灵活适当的响应,而不只有”被干掉”一条路。
被其他进程杀死
第四类终止,是某个进程(通常要有相应权限)主动请求操作系统结束另一个进程。
UNIX 下用 kill 系统调用实现。它向目标进程发送一个信号,该信号可以被接收方解释为终止指令。比如进程 A 想结束进程 B,可以通过 kill 发送 SIGTERM(软件终止信号)给 B,B 收到后通常会先做清理工作再退出。
Windows 没有和 UNIX kill 对等的系统级接口,但提供 TerminateProcess 函数。这里有个区别需要注意:**TerminateProcess 不是系统调用**,它是 Win32 API 的一部分,由 Windows 的应用程序接口层提供。它允许一个进程立即终止另一个进程,不给目标进程任何清理资源或保存状态的机会。正因为如此,使用它要格外小心,可能导致数据丢失或其他副作用。
实际操作的差别:UNIX 下要终止名为 example_process 的进程,先用 ps 查到它的 PID,然后 kill [PID] 发送终止信号。Windows 下则可能通过任务管理器,或者编程调用 TerminateProcess 并传入目标进程的句柄或 PID。考虑到 TerminateProcess 的潜在风险,更推荐用温和一些的方式,比如给应用程序窗口发送 WM_CLOSE 消息,让程序有机会走完有序关闭的流程。
进程的层次结构
UNIX:以 init 为根的进程树
UNIX 及其衍生系统里,进程之间通过明确的父子关系构成层次结构。父进程创建子进程,子进程还能继续创建自己的子进程,一层层展开形成进程树。
除了父子关系,UNIX 里还有进程组的概念:一个进程和它的所有后代进程(直接子进程、子进程的子进程……)可以共同属于一个或多个进程组。进程组主要是为信号处理服务的。用户从终端(比如键盘)发出一个信号时,这个信号通常会被送到与终端关联的当前前台进程组中的所有成员,每个成员再各自决定怎么响应:捕获、忽略,还是执行默认操作。
进程树在系统启动过程的体现:
flowchart TB
I["init(PID 1)
所有其他进程的根"] --> T1["getty / 终端进程
(每个可用终端一个)"]
I --> T2["getty / 终端进程"]
T1 -->|"用户登录"| L["login 进程"]
L -->|"登录成功"| S["shell 环境"]
S -->|"输入命令"| C1["用户启动的进程"]
C1 --> C2["它再创建的子进程"]
init 是系统启动时最先被创建的特殊进程,作为所有其他进程的根,负责系统初始化和后续进程的创建。它会根据系统配置读取信息,为每个可用的终端创建一个进程,这些进程负责等待用户登录。用户登录成功后,登录进程启动一个 shell 环境,用户在里面敲命令启动新进程,新进程还可能再创建自己的子进程,一棵以 init 为根的庞大进程树就是这样长出来的。
(现代 Linux 发行版多数用 systemd 接替了传统的 init,但”第一个进程是所有进程的根”这个位置没有变。)
Windows:所有进程一律平等
Windows 不采用 UNIX 的进程层次结构模型。在 Windows 里,所有进程都被视为平等的实体,彼此之间不存在直接的父子关系或层次结构。
那它怎么管理进程?靠句柄。一个进程创建另一个进程时,父进程会拿到一个句柄,通过这个句柄可以对子进程做某些操作,比如查询状态或者终止它。但这个句柄不代表层次结构,它甚至可以被传递给其他进程,那样一来就和最初的创建者失去直接联系了。
两种设计各有考量:UNIX 的层次结构在信号处理、资源管理等场景下提供了便利,Windows 的灵活性和独立性则在跨进程通信、安全模型等方面展现出优势。
进程状态
进程是独立的执行实体,各有专属的程序计数器和内部状态,但进程之间又必须协作。看个例子:
1 | cat chapter1 chapter2 chapter3 | grep tree |
cat 进程负责连续输出三个文件的内容,grep 进程负责从收到的输入里筛选出含 tree 的行。
两个进程的执行速度受各自逻辑复杂度和系统资源分配(尤其是 CPU 时间片)影响,不可能总是一致。很可能会出现这种局面:grep 已经准备就绪要处理输入了,cat 还没读完文件。为了防止数据丢失或处理出错,操作系统会让 grep 进入阻塞状态暂停执行,直到 cat 把数据完整传过来。
这就是进程状态概念的由来。
三种核心状态
flowchart LR
R(["🟢 运行态
Running"]) -->|"1 等待输入/事件"| B(["🔴 阻塞态
Blocked"])
B -->|"4 事件出现"| Y(["🟡 就绪态
Ready"])
Y -->|"3 被调度选中"| R
R -->|"2 时间片用完"| Y
运行态(Running):进程正在实际占用 CPU 时间片执行。比如文本编辑器进程正在处理键盘输入并实时显示到屏幕上时,它就处于运行态。
就绪态(Ready):进程已经准备好执行,但 CPU 正被其他进程占用,只能等着轮到自己。这就像运动员在起跑线上等着发令枪响,都做好了起跑准备,但比赛还没开始。
阻塞态(Blocked):进程因为等待某个外部事件(I/O 操作完成、信号量释放等)而无法继续执行。除非这个事件发生,否则即便 CPU 空闲着,它也不能运行。比如进程尝试从网络读数据,但数据还没到达,这时它就进入阻塞态。
逻辑上,运行态和就绪态在”可运行性”上是相似的,区别只在于有没有真的拿到 CPU 时间。阻塞态和它们有本质不同,外部条件没满足,它就无法执行,跟 CPU有没有空闲没关系。
四种状态转换
上图三种核心状态的四种转换,各自由不同原因触发:
| 转换 | 方向 | 触发原因 |
|---|---|---|
| 1 | 运行 → 阻塞 | 进程等待输入或某个事件。某些系统里进程调用 pause 之类的系统调用主动获取阻塞状态;UNIX 里从管道或特殊文件(比如终端)读取时没有可用输入,进程也会被自动终止 |
| 2 | 运行 → 就绪 | 进程调度器认定当前进程已经运行了足够长的时间,该把 CPU 让给别的进程了 |
| 3 | 就绪 → 运行 | 轮到该进程获得 CPU 时间片。所有其他进程都运行过之后,第一个进程重新获得时间片时就会发生 |
| 4 | 阻塞 → 就绪 | 进程等待的外部事件发生了(比如输入数据到达)。如果此时没有其他进程在运行,立刻触发转换 3 让它开始跑;否则它先进就绪队列,等 CPU 空闲再说 |
转换 2 和转换 3 都是由进程调度程序引起的,进程本身不知道调度程序的存在。它以为自己一直在独占 CPU,实际上被切来切去。
调度程序的位置
顺着上面的讨论,可以得到这样一个模型:操作系统的核心基石是调度程序,它位于系统架构的最底层,上面跑着众多进程。调度程序负责处理所有与中断管理、进程启动与终止相关的复杂细节。
flowchart BT
P0["进程 0"]
P1["进程 1"]
PD["..."]
P2["进程 n-2"]
P3["进程 n-1"]
S["调 度 程 序"]
S --- P0
S --- P1
S --- PD
S --- P2
S --- P3
基于进程的操作系统中,最底层的是中断和调度处理,在该层之上是顺序进程。
尽管承担的任务繁重,调度程序本身设计得相当精简,一小段精心编写的、体积小巧但功能强大的程序。这个设计哲学保证了它能高效运行、快速响应系统需求,同时少占系统资源。
举例来说,系统收到外部中断信号(键盘输入、网络数据包到达)时,调度程序立即介入,根据当前系统状态和优先级规则,决定是马上处理这个中断还是挂起等待。决定启动或停止某个进程时,它也会综合考虑进程优先级、系统负载等因素,确保资源分配合理。
进程的实现
前面讲的都是概念,现在落到实现层面:操作系统到底用什么数据结构来管理这一堆进程。
进程表
答案是进程表(Process Table)。系统为每个处于运行、就绪或阻塞状态的进程在表里维护一个表项,这张表是管理和调度进程的核心组件。
每个表项封装了进程运行所必需的全部状态参数:
- 程序计数器(Program Counter):指示进程下一次要执行指令的内存地址,是控制流的核心指针
- 堆栈指针(Stack Pointer):指向进程调用栈的顶部,用于管理函数调用、局部变量和返回地址
- 内存分配状况(Memory Allocation Status):记录进程占用了哪些内存区域及其访问权限
- 所打开文件的状态(File Status):进程打开的所有文件描述符及其当前状态,比如读写位置、打开模式
- 账号信息(Account Information):进程的所有者、权限等安全相关属性
- 调度信息(Scheduling Information):进程优先级、等待时间以及时间片等,供调度算法使用
除此之外,表项还要存放进程在状态转换(从运行态转到就绪态或阻塞态)时必须保存的信息。这样进程因外部事件被挂起后,上下文环境能完整保留下来,等时机合适,比如资源可用、优先级提升时,再恢复执行,仿佛从未被中断过。
实际系统里的进程表字段通常按功能分成三组:
1 | ┌─────────────────────┬────────────────────────┬──────────────────┐ |
三列分别对应进程管理、存储管理和文件管理。注意第一列里那句”子进程的 CPU 时间”,它说明进程表不只在描述单个进程,还记着进程之间的关系。
中断处理与进程切换
有了进程表,接下来看单个 CPU 是怎么营造出同时运行多个顺序进程的错觉的。核心机制是中断处理与进程调度的紧密协作。
每一类 I/O 操作都关联着一个特定的中断向量,它位于内存底部的一个固定区域,存放着对应中断服务程序(ISR)的入口地址。
以磁盘 I/O 为例。磁盘中断发生时,假设用户进程 3 正在执行,整个流程分成硬件和软件两段。
硬件这一段的动作是:把当前的程序计数器(PC)、程序状态字(PSW)以及可能的一个或多个寄存器值压入堆栈,保存进程 3 的上下文;然后根据中断向量里存的地址,跳转到相应的中断服务程序开始执行。到此硬件完成职责范围内的工作。
软件这一段由中断服务程序接手(通常是个 C 程序),负责处理中断引发的具体事务,比如把磁盘数据读到缓存。处理完之后它通知操作系统,此时可能有进程因为等待的 I/O 完成而变为就绪态。操作系统接着调用调度程序,按调度算法(轮转调度、优先级调度等)决定下一个该运行哪个进程。
调度决策做出后,控制权又转移到一段汇编代码,由它为新进程恢复上下文:从进程表里加载寄存器值、设置堆栈指针指向正确的内存区域,最后启动执行。
完整的八步是这样的:
flowchart TB
A["① 硬件压栈
PC、PSW、寄存器入栈"] --> B["② 硬件跳转
按中断向量跳到 ISR"]
B --> C["③ 汇编保存上下文
保存当前进程的状态"]
C --> D["④ 设置新堆栈
(如果需要)"]
D --> E["⑤ C 中断服务程序执行
处理 I/O 等具体事务"]
E --> F["⑥ 调度器决策
决定下一个运行的进程"]
F --> G["⑦ C 到汇编的转移
准备切换进程"]
G --> H["⑧ 汇编恢复上下文并启动
加载登记项、设置栈指针、开始执行"]
一个进程在其生命周期中可能会经历数千次中断。靠这套精心设计的上下文保存与恢复机制,每次中断后被中断的进程都能准确回到中断发生前的执行状态,系统的稳定性和进程执行的连续性就是这么保证的。
小结
- 进程是对”正在执行的程序”的高级抽象,包含代码、程序计数器、寄存器状态和变量值。它的存在让多个程序能在单 CPU 上并发推进,同时为每个程序提供隔离的执行环境。
- 创建在 UNIX 里是
fork+execve两步走,Windows 里是CreateProcess一步到位;地址空间靠写时复制(UNIX)实现高效隔离。 - 终止分自愿和非自愿两类,UNIX 的信号机制让进程能对错误做出更灵活的响应。
- 层次结构上,UNIX 是以
init为根的进程树,Windows 是所有进程一律平等。 - 状态有运行、就绪、阻塞三种,四种转换靠调度程序推动,进程自己对此毫不知情。
- 实现落在进程表上,中断处理与进程调度配合完成上下文切换,让”同时运行多个进程”这个错觉得以成立。