0%

操作系统:进程模型

进程是操作系统里最古老的概念之一,也是最核心的那个。几乎所有的操作系统功能都围着它转:调度器调度的是它,内存管理分配地址空间的单位是它,文件系统记录的是它打开了哪些文件。可以毫不夸张地说,没有进程这个抽象,现代操作系统根本无法实现。


先看看系统里到底跑着什么

在讨论进程怎么被管理之前,先得能看见它们。Linux 提供了几个常用工具,从不同角度把进程的状态铺开给人看。

ps:静态快照

ps 是最常见的进程查看工具,它给出的是当前时刻各进程信息的静态快照,通过不同选项可以控制拍什么:

选项 含义
a 显示当前终端下的所有进程,包括其他用户的进程
u 以用户为主的格式显示进程状态
x 通常和 a 一起用,显示当前用户在所有终端下的进程
-e 显示系统内所有进程
-l 用长格式显示
-f 用完整格式显示

两个最常用的组合是 ps aux 和 ps -elf。前者按用户视角列出,输出大致长这样:

1
2
3
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root 1 0.0 0.1 169432 13156 ? Ss 09:12 0:03 /sbin/init
fan 2317 0.2 0.8 912344 65432 pts/0 Sl 10:05 0:12 /usr/bin/bash

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
2
3
4
5
6
7
8
9
10
     程序计数器
┌──────────┐
A │ ↓ │ ← 物理 PC 当前指向 A 的某条指令
├──────────┤
B │ ↓ │
├──────────┤ 进程切换
C │ ↓ │
├──────────┤
D │ ↓ │
└──────────┘

把每个程序抽象成一个独立进程后,每个进程都拥有属于自己的逻辑程序计数器(逻辑 PC),各自记录着自己执行到了哪条指令。但物理上,机器只有一个物理程序计数器。

两者的配合方式是这样的:

  1. 某个进程要运行时,把它的逻辑 PC 当前值加载进物理 PC,CPU 据此执行指令序列;
  2. 该进程被切走时,物理 PC 的值(也就是它执行到的位置)被记录下来,复制回这个进程自己的逻辑 PC 中;
  3. 下次再轮到它,从逻辑 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
pid_t pid;

/* fork a child process */
pid = fork();

if (pid < 0) { /* error occurred */
fprintf(stderr, "Fork Failed");
return 1;
}
else if (pid == 0) { /* child process */
execlp("/bin/ls", "ls", NULL);
}
else { /* parent process */
/* parent will wait for the child to complete */
wait(NULL);
printf("Child Complete");
}
return 0;
}
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <windows.h>

int main(VOID)
{
STARTUPINFO si;
PROCESS_INFORMATION pi;

/* allocate memory */
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));

/* create child process */
if (!CreateProcess(NULL, /* use command line */
"C:\\WINDOWS\\system32\\mspaint.exe", /* command */
NULL, /* don't inherit process handle */
NULL, /* don't inherit thread handle */
FALSE, /* disable handle inheritance */
0, /* no creation flags */
NULL, /* use parent's environment block */
NULL, /* use parent's existing directory */
&si,
&pi))
{
fprintf(stderr, "CreateProcess Failed");
return -1;
}

/* parent will wait for the child to complete */
WaitForSingleObject(pi.hProcess, INFINITE);
printf("Child Complete");

/* close handles */
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}

这段代码创建了一个子进程并加载 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────┬────────────────────────┬──────────────────┐
│ 进程管理 │ 存储管理 │ 文件管理 │
├─────────────────────┼────────────────────────┼──────────────────┤
│ 寄存器 │ text segment 的指针 │ 根目录 │
│ 程序计数器 │ data segment 的指针 │ 工作目录 │
│ 程序状态字 │ stack segment 的指针 │ 文件描述符 │
│ 堆栈指针 │ │ 用户 ID │
│ 进程状态 │ │ 组 ID │
│ 优先级 │ │ │
│ 调度参数 │ │ │
│ 进程 ID │ │ │
│ 父进程 │ │ │
│ 进程组 │ │ │
│ 信号 │ │ │
│ 进程开始时间 │ │ │
│ 使用的 CPU 时间 │ │ │
│ 子进程的 CPU 时间 │ │ │
│ 下次定时器时间 │ │ │
└─────────────────────┴────────────────────────┴──────────────────┘

三列分别对应进程管理、存储管理和文件管理。注意第一列里那句”子进程的 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 是所有进程一律平等。
  • 状态有运行、就绪、阻塞三种,四种转换靠调度程序推动,进程自己对此毫不知情。
  • 实现落在进程表上,中断处理与进程调度配合完成上下文切换,让”同时运行多个进程”这个错觉得以成立。