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)。
- 寄存器:很多指令直接读写寄存器。其中有几个特殊的:程序计数器(PC,program counter,也叫指令指针 IP)指明下一条要执行的指令;栈指针(stack pointer)和帧指针(frame pointer)管理存放函数参数、局部变量和返回地址的栈。
- I/O 信息:程序经常访问持久存储设备,比如进程当前打开的文件列表。
时分共享与空分共享
策略与机制分离(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 节)。用下面的模拟器逐步观察,并对比"一次全部加载"与"懒加载":
从磁盘到内存:加载一个程序
- 加载代码和静态数据(如已初始化的全局变量)到进程的地址空间。早期(或简单)的 OS 急切地(eagerly)一次全部加载;现代 OS 懒惰地(lazily)只在用到时才加载某一部分——要真正理解懒加载,需要后面虚拟内存部分的分页(paging)和交换(swapping)知识。
- 分配运行时栈:C 程序用栈存放局部变量、函数参数和返回地址。OS 还会用参数初始化栈——具体说,填好
main()的argc和argv。 - 分配堆(可能):C 程序用堆存放
malloc()显式申请、free()显式释放的动态数据(链表、哈希表、树……)。堆一开始很小,程序不断malloc()时 OS 可能介入分配更多内存。 - I/O 相关初始化:UNIX 中每个进程默认有三个打开的文件描述符(file descriptor):标准输入、标准输出、标准错误。
- 从入口点开始运行:跳到
main()(通过下一章讲的特殊机制),把 CPU 的控制权交给新进程。
6进程状态
在简化的视角下,进程在任意时刻处于三种状态之一:
- 运行(Running):正在某个处理器上执行指令。
- 就绪(Ready):已经准备好运行,但 OS 此刻选择不运行它。
- 阻塞(Blocked):执行了某种操作,在另一个事件发生之前不能运行。典型例子:向磁盘发起 I/O 请求后阻塞,于是别的进程可以使用处理器。
原书用两个例子追踪两个进程的状态。第一个只用 CPU、不做 I/O(图 4.3);第二个中进程 0 在运行 3 拍后发起 I/O(图 4.4):
| 时间 | 进程 0 | 进程 1 | 说明 |
|---|---|---|---|
| 1 | Running | Ready | |
| 2 | Running | Ready | |
| 3 | Running | Ready | |
| 4 | Running | Ready | 进程 0 完成 |
| 5 | – | Running | |
| 6 | – | Running | |
| 7 | – | Running | |
| 8 | – | Running | 进程 1 完成 |
图 4.3:仅 CPU
| 时间 | 进程 0 | 进程 1 | 说明 |
|---|---|---|---|
| 1 | Running | Ready | |
| 2 | Running | Ready | |
| 3 | Running | Ready | 进程 0 发起 I/O |
| 4 | Blocked | Running | 进程 0 阻塞, |
| 5 | Blocked | Running | 于是进程 1 运行 |
| 6 | Blocked | Running | |
| 7 | Ready | Running | I/O 完成 |
| 8 | Ready | Running | 进程 1 完成 |
| 9 | Running | – | |
| 10 | Running | – | 进程 0 完成 |
图 4.4:CPU 与 I/O
即使这么简单的例子,OS 也做了两个决策:① 进程 0 发起 I/O 时,决定运行进程 1——让 CPU 保持忙碌,提高了资源利用率;② 进程 0 的 I/O 完成时,决定不立刻切回进程 0。第二个决定好不好?原书把问题留给你——下面的模拟器可以让你亲手比较两种做法。这类决策由 OS 的调度器(scheduler)做出,是第 7 章起的主题。
进程状态追踪器(网页版 process-run.py)
写法: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 一致):
- 一条
io指令本身占 1 拍 CPU(发出请求),之后进程阻塞 I/O 时长 拍;I/O 完成的那一拍标*,进程回到 READY,之后还要执行一条io_done。 - SWITCH_ON_IO:进程发起 I/O 时立即切换到下一个就绪进程;SWITCH_ON_END:只有当前进程彻底结束才切换——等 I/O 时 CPU 就白白空着。
- IO_RUN_LATER:I/O 完成后,发起者只是回到就绪队列,当前进程继续跑;IO_RUN_IMMEDIATE:立刻切回发起者(当前进程被换下)。
- CPU 利用率 = CPU 忙的拍数 / 总拍数;I/O 利用率 = 至少有一个 I/O 在进行的拍数 / 总拍数。
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; // 当前中断的陷阱帧
};
- ①寄存器上下文(register context):进程被停下时,它的寄存器值保存到这里;把这些值放回物理寄存器,进程就能从断点继续。这就是第 6 章的上下文切换(context switch)。
- ②状态:除了运行(
RUNNING)、就绪(RUNNABLE)、阻塞(SLEEPING),还有UNUSED(空槽)、EMBRYO(正在创建的初始状态)和ZOMBIE(僵尸,已退出但尚未清理的最终状态)。 - ③
chan:阻塞的进程在等什么。I/O 完成时 OS 据此找到并唤醒正确的进程。 - ④
parent与ofile[]:父子关系和打开文件表,第 5 章的wait()和重定向都依赖它们。
chan 匹配的 SLEEPING 进程并把它改成 RUNNABLE。最终状态:僵尸(zombie)
进程退出后并不会立刻消失,而是进入最终状态:已经退出、但还没被清理——UNIX 称之为僵尸状态。这样做的用处是:让其他进程(通常是创建它的父进程)检查它的返回码,看它是否执行成功(UNIX 程序成功时通常返回 0,否则返回非零)。父进程最后调用 wait() 等待子进程完成,同时告诉 OS 可以清理与这个已消亡进程有关的数据结构了。
xv6 进程表:一个进程的一生
proc[] 表里每个槽位的状态如何在 UNUSED → EMBRYO → RUNNABLE ⇄ RUNNING ⇄ SLEEPING → ZOMBIE → UNUSED 之间流转。8先预测,再验证
以下题目改编自原书第 4 章作业。先在纸上写出你的预测,再到"进程状态追踪器"里载入对应示例验证(默认 I/O 时长为 5)。
5:100,5:100(都只用 CPU)。CPU 利用率是多少?为什么不用运行就能知道?看答案
4:100,1:0——进程 0 计算 4 拍,进程 1 只发起一次 I/O。两个进程总共需要多久完成?看答案
io;第 6–10 拍它阻塞、CPU 空闲;第 11 拍 I/O 完成(*),执行 io_done。CPU 忙 6 拍(54.55%),I/O 忙 5 拍(45.45%)。1:0,4:100。结果有变化吗?为什么顺序很重要?看答案
io_done。CPU 忙 6/7 ≈ 85.71%。先发起 I/O 的进程先跑,I/O 和计算才能并行。1:0,4:100,但切换行为改成 SWITCH_ON_END。会怎样?看答案
io_done 后进程 0 结束,进程 1 才在第 8–11 拍运行。这正是"没有多道程序"的样子。3:0,5:100,5:100,5:100,SWITCH_ON_IO。比较 IO_RUN_LATER 与 IO_RUN_IMMEDIATE 的总时间和 CPU 利用率。为什么"刚完成 I/O 的进程立刻再运行"可能是好主意?看答案
3:50 和 3:50 两行,把随机种子分别设为 1、2、3。先根据下方"指令进度"条预测整张表,再逐拍验证;然后换成 IO_RUN_IMMEDIATE、SWITCH_ON_END 各看一次。看答案
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自测题
4:100,1:0、SWITCH_ON_IO、I/O 时长 5,总共需要几拍?3:0,5:100,5:100,5:100 这个负载下,从 IO_RUN_LATER 改为 IO_RUN_IMMEDIATE,结果如何?11常见误区与本章小结
- "程序 = 进程":程序是静态的文件;进程是它运行起来的实例。同一个程序可以同时有多个进程(第 2 章的四个
cpu)。 - "I/O 完成后进程马上继续运行":它只是回到 Ready;何时运行是调度器的决定。
- "Blocked 和 Ready 差不多,都是没在跑":Ready 的进程随时可以被调度;Blocked 的进程即使 CPU 空闲也不能运行,必须等事件发生。
- "进程退出后就立刻从进程列表里消失":它会先成为僵尸,等父进程
wait()回收。 - "OS 在运行程序之前一定把整个程序读进内存":现代 OS 是懒加载的。
- 进程是 OS 对运行中程序的抽象;它由机器状态刻画:地址空间的内容、CPU 寄存器(含 PC 与栈指针)、I/O 信息(如打开的文件)。
- OS 用时分共享虚拟化 CPU,制造出"很多 CPU"的假象;底层靠机制(如上下文切换),上层靠策略(如调度)。
- 进程 API 至少包括:创建、销毁、等待、其他控制、状态查询。
- 创建进程:加载代码与静态数据(早期急切、现代懒惰)→ 分配并初始化栈(argc/argv)→ 分配堆 → 设置 fd 0/1/2 → 跳到
main()。 - 进程状态:Running、Ready、Blocked(外加初始状态和僵尸状态);调度/取消调度、发起/完成 I/O 触发状态转换。
- OS 用进程列表记录所有进程,每个进程的信息存放在 PCB(如 xv6 的
struct proc)中。
下一步:第 5 章看 UNIX 如何用 fork()、exec()、wait() 创建和控制进程;第 6 章揭示"停下一个进程、换上另一个"的底层机制——受限直接执行与上下文切换。