1一句话 + 关键问题

只有少数几个物理 CPU,操作系统如何制造出"CPU 几乎无穷多"的假象?

HOW TO PROVIDE THE ILLUSION OF MANY CPUS? Although there are only a few physical CPUs available, how can the OS provide the illusion of a nearly-endless supply of said CPUs?

一句话:进程(process)就是运行中的程序。磁盘上的程序只是一堆静止的指令和数据;操作系统把它加载进内存、准备好栈和堆、打开标准输入输出,然后让它在 CPU 上跑起来,它才成为"进程"。

上一章(第 2 章)我们看到四个 cpu 程序在一个 CPU 上"同时"打印。本章给出背后的核心抽象:操作系统通过时分共享(time sharing)——运行一个进程一会儿、停下、再运行另一个——虚拟化 CPU。本章回答"进程是什么、由什么组成、处于哪些状态、操作系统用什么数据结构记录它";接下来第 5 章讲创建进程的真实 API(fork/exec/wait),第 6 章讲切换进程的底层机制,第 7–10 章讲决定运行谁的策略。

2核心概念速览

进程(process)

运行中的程序。操作系统对"一个正在执行的程序"的抽象。

机器状态(machine state)

进程运行时能读写的一切:地址空间里的内存、寄存器(含 PC、栈指针)、打开的文件等 I/O 信息。

时分共享(time sharing)

让资源先给一个实体用一会儿,再给另一个。CPU 就是这样被共享的;代价是每个进程都跑得慢一些。

空分共享(space sharing)

把资源在空间上切分给不同使用者,例如磁盘块分配给不同文件。

机制 vs 策略

机制(mechanism)回答"怎么做"(如何上下文切换);策略(policy)回答"做哪个"(现在该运行哪个进程)。

进程状态

运行(Running)、就绪(Ready)、阻塞(Blocked),外加初始状态和最终的僵尸(zombie)状态。

进程列表 / PCB

OS 用进程列表(process list)记录所有进程;每个进程一项,称为进程控制块(PCB,Process Control Block)。

加载(loading)

把代码和静态数据从磁盘读进地址空间,分配栈和堆,设置文件描述符,最后跳到 main()。

3进程由什么组成:机器状态

要理解"进程",就要问:一个程序运行时,机器的哪些部分对它的执行是重要的?把这些部分列一张清单,就是进程的机器状态(machine state)。原书给出三类:

一个进程 = 下面三部分的当前内容 ① 内存:地址空间(address space) 代码 code 静态数据 static data 堆 heap ↓ (空闲) 栈 stack ↑ ② 寄存器(registers) PC / IP下一条指令 SP 栈指针栈顶 FP 帧指针当前栈帧 eax ebx …通用寄存器 ③ I/O 信息:打开的文件 fd 0 标准输入 fd 1 标准输出 fd 2 标准错误 fd 3 notes.txt 进程运行中可能读写的 持久存储设备
图 A:进程的机器状态。只要把这三部分的内容保存下来、日后原样恢复,进程就能"无缝"地继续运行——这正是第 6 章上下文切换的依据。

时分共享与空分共享

时分共享:CPU(沿时间轴轮流使用) A B C A B C 时间 空分共享:磁盘(按空间切分,块一旦分给文件就归它) a.txtb.cc.md空闲
图 B:CPU 通常时分共享,磁盘空间通常空分共享。时分共享的潜在代价是性能:CPU 被 N 个进程分享时,每个进程都会跑得更慢。

策略与机制分离(separate policy and mechanism)。机制回答 how:操作系统怎样完成一次上下文切换(context switch)?策略回答 which:哪个进程现在该运行?两者分开,就能在不改动机制的前提下替换策略——这是模块化(modularity)这一通用软件设计原则的体现。调度策略会利用历史信息(过去一分钟谁跑得多)、负载知识(跑的是什么类型的程序)和性能指标(优化交互性能还是吞吐量)来做决定。

4进程 API:任何操作系统都得提供的五类接口

真实的 UNIX API 留到第 5 章。这里先看任何现代操作系统的进程接口都必须包含哪几类功能:

类别含义UNIX 里的例子(第 5 章)
创建(Create)在 shell 里敲命令、双击图标时,OS 被调用来创建新进程运行指定程序fork() + exec()
销毁(Destroy)多数进程运行完会自己退出;不退出时用户可能想强行杀掉失控的进程kill()、Ctrl-C
等待(Wait)等待某个进程停止运行wait() / waitpid()
其他控制(Miscellaneous Control)例如挂起(suspend)一个进程,稍后再恢复(resume)它SIGSTOP / SIGCONT、Ctrl-Z 与 fg
状态(Status)查询进程运行了多久、处于什么状态等ps、top、/proc/<pid>/status

5进程创建:从程序到进程

程序最初以某种可执行格式(executable format)躺在磁盘(或 SSD)上。操作系统要让它跑起来,大致要做下面这几件事(原书 4.3 节)。用下面的模拟器逐步观察,并对比"一次全部加载"与"懒加载":

模拟器

从磁盘到内存:加载一个程序

逐步执行原书 4.3 节的加载流程。切换"懒加载"看现代 OS 的做法:代码页只在第一次被用到时才从磁盘读入。
  1. 加载代码和静态数据(如已初始化的全局变量)到进程的地址空间。早期(或简单)的 OS 急切地(eagerly)一次全部加载;现代 OS 懒惰地(lazily)只在用到时才加载某一部分——要真正理解懒加载,需要后面虚拟内存部分的分页(paging)和交换(swapping)知识。
  2. 分配运行时栈:C 程序用栈存放局部变量、函数参数和返回地址。OS 还会用参数初始化栈——具体说,填好 main() 的 argc 和 argv。
  3. 分配堆(可能):C 程序用堆存放 malloc() 显式申请、free() 显式释放的动态数据(链表、哈希表、树……)。堆一开始很小,程序不断 malloc() 时 OS 可能介入分配更多内存。
  4. I/O 相关初始化:UNIX 中每个进程默认有三个打开的文件描述符(file descriptor):标准输入、标准输出、标准错误。
  5. 从入口点开始运行:跳到 main()(通过下一章讲的特殊机制),把 CPU 的控制权交给新进程。

6进程状态

在简化的视角下,进程在任意时刻处于三种状态之一:

Running运行 Ready就绪 Blocked阻塞 Descheduled(被换下) Scheduled(被调度) I/O: initiate发起 I/O I/O: doneI/O 完成
图 4.2(重绘):进程状态转换。Ready ⇄ Running 由 OS 决定;Blocked 的进程要等事件(如 I/O 完成)发生后回到 Ready——注意不是直接回到 Running(除非 OS 恰好决定立刻运行它)。

原书用两个例子追踪两个进程的状态。第一个只用 CPU、不做 I/O(图 4.3);第二个中进程 0 在运行 3 拍后发起 I/O(图 4.4):

时间进程 0进程 1说明
1RunningReady
2RunningReady
3RunningReady
4RunningReady进程 0 完成
5–Running
6–Running
7–Running
8–Running进程 1 完成

图 4.3:仅 CPU

时间进程 0进程 1说明
1RunningReady
2RunningReady
3RunningReady进程 0 发起 I/O
4BlockedRunning进程 0 阻塞,
5BlockedRunning于是进程 1 运行
6BlockedRunning
7ReadyRunningI/O 完成
8ReadyRunning进程 1 完成
9Running–
10Running–进程 0 完成

图 4.4:CPU 与 I/O

即使这么简单的例子,OS 也做了两个决策:① 进程 0 发起 I/O 时,决定运行进程 1——让 CPU 保持忙碌,提高了资源利用率;② 进程 0 的 I/O 完成时,决定不立刻切回进程 0。第二个决定好不好?原书把问题留给你——下面的模拟器可以让你亲手比较两种做法。这类决策由 OS 的调度器(scheduler)做出,是第 7 章起的主题。

模拟器

进程状态追踪器(网页版 process-run.py)

每行描述一个进程。单步执行,观察每一拍各进程的状态、状态机上的转换、CPU 与 I/O 利用率。表格里还没到的行用"·"遮住——先预测,再按"下一步"验证。
载入示例:

写法:c3,i,c2 表示"计算 3 拍 → 发起一次 I/O → 计算 2 拍"(同 process-run.py 的 -P);5:100 表示"5 条指令,每条有 100% 概率是 CPU 指令、否则是 I/O"(同 -l,由随机种子决定,结果与 Python 版的随机数不同)。每个 I/O 后面会自动跟一条 io_done 指令,用来处理 I/O 完成(占 1 拍 CPU)。

几个关键细节(与 process-run.py 一致):

7数据结构:进程列表与 PCB

OS 也是一个程序,也有自己的关键数据结构。为了跟踪每个进程,它会维护一个进程列表(process list,也叫任务列表 task list),记录所有就绪的进程、当前正在运行的进程,并以某种方式跟踪阻塞的进程——这样 I/O 事件完成时,才能唤醒正确的进程。列表里描述单个进程的结构,常被称为进程控制块(PCB,Process Control Block)或进程描述符(process descriptor)——说穿了就是一个 C 结构体。

下面是教学操作系统 xv6 内核里的 struct proc(原书图 4.5),加了中文注释:

// xv6 在停止、重新启动一个进程时要保存/恢复的寄存器
struct context {
  int eip;   // 指令指针(PC)
  int esp;   // 栈指针
  int ebx; int ecx; int edx; int esi; int edi; int ebp;
};
// 进程可能处于的状态
enum proc_state { UNUSED, EMBRYO, SLEEPING,
                  RUNNABLE, RUNNING, ZOMBIE };
// xv6 为每个进程记录的信息(包括寄存器上下文和状态)
struct proc {
  char *mem;                  // 进程内存的起始地址
  uint sz;                    // 进程内存大小
  char *kstack;               // 该进程内核栈的底部
  enum proc_state state;      // 进程状态
  int pid;                    // 进程 ID
  struct proc *parent;        // 父进程
  void *chan;                 // 非零:正在 chan 上睡眠(等待某事件)
  int killed;                 // 非零:已被杀死
  struct file *ofile[NOFILE]; // 打开的文件
  struct inode *cwd;          // 当前目录
  struct context context;     // 切换到这里来运行进程
  struct trapframe *tf;       // 当前中断的陷阱帧
};
进程列表(process list) proc[0]pid 1state RUNNABLEname initcontext {…}ofile[] … proc[1]pid 7state RUNNINGname shparent → proc[0]context {…} proc[2]pid 9state SLEEPINGchan &diskparent → proc[1]context {…} proc[3]pid 12state ZOMBIExstate 0parent → proc[1]等父进程 wait() 当前运行(current)
图 C:进程列表中每一项就是一个 PCB。I/O 完成时,OS 查找 chan 匹配的 SLEEPING 进程并把它改成 RUNNABLE。

最终状态:僵尸(zombie)

进程退出后并不会立刻消失,而是进入最终状态:已经退出、但还没被清理——UNIX 称之为僵尸状态。这样做的用处是:让其他进程(通常是创建它的父进程)检查它的返回码,看它是否执行成功(UNIX 程序成功时通常返回 0,否则返回非零)。父进程最后调用 wait() 等待子进程完成,同时告诉 OS 可以清理与这个已消亡进程有关的数据结构了。

模拟器

xv6 进程表:一个进程的一生

你来触发事件:fork、时钟中断、发起/完成 I/O、exit、wait。观察 proc[] 表里每个槽位的状态如何在 UNUSED → EMBRYO → RUNNABLE ⇄ RUNNING ⇄ SLEEPING → ZOMBIE → UNUSED 之间流转。

8先预测,再验证

以下题目改编自原书第 4 章作业。先在纸上写出你的预测,再到"进程状态追踪器"里载入对应示例验证(默认 I/O 时长为 5)。

作业 1:两个进程 5:100,5:100(都只用 CPU)。CPU 利用率是多少?为什么不用运行就能知道?
看答案
100%。没有任何 I/O,CPU 从第 1 拍到第 10 拍始终有指令可执行:先是 PID 0 跑 5 拍,结束后 PID 1 跑 5 拍,总时间 10。I/O 利用率为 0。
作业 2:4:100,1:0——进程 0 计算 4 拍,进程 1 只发起一次 I/O。两个进程总共需要多久完成?
看答案
11 拍。进程 0 先跑满 4 拍(第 1–4 拍);第 5 拍进程 1 执行 io;第 6–10 拍它阻塞、CPU 空闲;第 11 拍 I/O 完成(*),执行 io_done。CPU 忙 6 拍(54.55%),I/O 忙 5 拍(45.45%)。
作业 3:把顺序反过来:1:0,4:100。结果有变化吗?为什么顺序很重要?
看答案
总时间缩短为 7 拍。进程 0 第 1 拍发起 I/O 后阻塞,SWITCH_ON_IO 让进程 1 立刻在第 2–5 拍使用 CPU,与 I/O 重叠(overlap);第 7 拍 I/O 完成、执行 io_done。CPU 忙 6/7 ≈ 85.71%。先发起 I/O 的进程先跑,I/O 和计算才能并行。
作业 4:同样是 1:0,4:100,但切换行为改成 SWITCH_ON_END。会怎样?
看答案
回到 11 拍。进程 0 在等 I/O 的第 2–6 拍,OS 不肯切换,CPU 空转;第 7 拍 io_done 后进程 0 结束,进程 1 才在第 8–11 拍运行。这正是"没有多道程序"的样子。
作业 6 / 7:3:0,5:100,5:100,5:100,SWITCH_ON_IO。比较 IO_RUN_LATER 与 IO_RUN_IMMEDIATE 的总时间和 CPU 利用率。为什么"刚完成 I/O 的进程立刻再运行"可能是好主意?
看答案
IO_RUN_LATER:31 拍,CPU 忙 21 拍(67.74%)——进程 0 的第一次 I/O 完成后一直排队,直到另外三个进程都跑完才轮到它;它后两次 I/O 期间已没有别的进程可以运行,CPU 空转。IO_RUN_IMMEDIATE:21 拍,CPU 100% 忙。I/O 密集型进程通常只跑一小会儿就又发起下一次 I/O,立刻运行它能让 I/O 设备尽早重新忙起来,I/O 与计算重叠得更充分。
作业 8:输入 3:50 和 3:50 两行,把随机种子分别设为 1、2、3。先根据下方"指令进度"条预测整张表,再逐拍验证;然后换成 IO_RUN_IMMEDIATE、SWITCH_ON_END 各看一次。
看答案
规律:只要有进程在等 I/O 而另一个进程就绪,SWITCH_ON_IO 就会重叠两者;SWITCH_ON_END 下总时间 ≈ 各进程独立运行时间之和。IO_RUN_IMMEDIATE 与 IO_RUN_LATER 只在"I/O 完成时另一个进程正在运行"的那一拍产生分歧。

9动手实验

在真实的 Linux 上看进程状态、地址空间布局和"PCB"。源码与说明见 labs/04-process/。

cd labs/04-process && make
./states     # fork 出 4 个子进程:计算中 / 睡眠 / 被暂停 / 已退出,用 ps 看 STAT 列
./layout     # 代码、静态数据、堆、栈的地址 + 懒加载前后的物理内存占用
./pcb        # 从 /proc/<pid>/status 读 Linux 的"PCB"字段和打开文件表

示例输出(你的机器上 PID、地址会不同):

$ ./states
$ ps -o pid,ppid,stat,cmd --ppid 343636
    PID    PPID STAT CMD
 343637  343636 R    ./states          ← 忙着计算:Running/Ready(Linux 统一记为 R)
 343638  343636 S    ./states          ← sleep(2):Blocked
 343639  343636 T    ./states          ← SIGSTOP:被挂起(其他控制)
 343640  343636 Z    [states] <defunct>  ← 已退出、未被 wait:僵尸
waitpid 回收了 343637,退出码 0
waitpid 回收了 343638,退出码 0
waitpid 回收了 343639,退出码 0
waitpid 回收了 343640,退出码 0

$ ./layout
代码   main()        @ 0x569bf8872273
静态   initialized   @ 0x569bf8875060
静态   big[] (.bss)  @ 0x569bf88750a0
堆     malloc()      @ 0x569c3333d2a0
栈     local         @ 0x7ffe2464fe44
栈     argv[0]="./layout" @ 0x7ffe24651034(OS 在栈上准备好的 argc/argv)

懒加载(lazy):
  访问 big[] 之前 VmRSS =   1408 KiB
  写满 big[] 之后 VmRSS =  67072 KiB

观察:① 代码与静态数据紧挨着(都来自可执行文件),堆在其上方,栈在很高的地址——与图 4.1 的布局一致;② 64 MiB 的数组在被写之前几乎不占物理内存,这就是懒加载;③ Linux 的 R 同时涵盖了原书的 Running 和 Ready。

10自测题

1. 下面哪一句最准确地描述了"进程"?
磁盘上的程序是"没有生命的"指令和数据;OS 把它加载并运行起来才是进程。任意时刻,进程可以用它的机器状态(内存、寄存器、I/O 信息)来概括。
2. 以下哪项不属于进程的机器状态?
机器状态是"这个程序运行时能读取或更新的东西"。别的程序的可执行文件与本进程的执行无关。
3. "现在应该让哪个进程运行?"这个问题由什么来回答?
策略回答 which,机制回答 how。上下文切换是"怎么换",调度策略决定"换成谁"。二者分离使得更换策略不必重写机制。
4. 一个阻塞(Blocked)的进程等待的 I/O 完成了,它接下来进入哪个状态?
原书:"at that point, the process moves to the ready state again (and potentially immediately to running again, if the OS so decides)"。图 4.4 中进程 0 在第 7 拍就处于 Ready,直到第 9 拍才再次运行。
5. process-run 中 4:100,1:0、SWITCH_ON_IO、I/O 时长 5,总共需要几拍?
进程 0 先跑 4 拍;进程 1 在第 5 拍发起 I/O,阻塞 5 拍(第 6–10 拍),第 11 拍执行 io_done。进程 1 排在后面,I/O 无法与计算重叠。
6. 现代操作系统通常如何把程序的代码和静态数据载入内存?
早期或简单的 OS 急切加载;现代 OS 借助分页和交换机制做懒加载(第 21 章)。
7. 为什么进程退出后要先停留在僵尸(zombie)状态?
最终状态让父进程得以查看子进程是否成功(返回 0 表示成功)。父进程调用 wait() 后,OS 才清理相关数据结构。
8. 磁盘空间被分成块、每块分配给某个文件,直到文件被删除。这是哪种共享方式?
资源在空间上被切分,块一旦分给文件,一般不会再分给别的文件——这是空分共享。CPU 则是典型的时分共享。
9. 在 3:0,5:100,5:100,5:100 这个负载下,从 IO_RUN_LATER 改为 IO_RUN_IMMEDIATE,结果如何?
立刻运行刚完成 I/O 的进程,它很快发起下一次 I/O,于是另外三个 CPU 密集进程正好填满 I/O 等待的空档。

11常见误区与本章小结

下一步:第 5 章看 UNIX 如何用 fork()、exec()、wait() 创建和控制进程;第 6 章揭示"停下一个进程、换上另一个"的底层机制——受限直接执行与上下文切换。