跳转至

02 进程 — 第一个抽象,与一对古怪的创建接口

1. 这一章讲什么

程序是死的:躺在磁盘上的一堆字节。把它跑起来,操作系统要给它配上内存、 寄存器、打开的文件,这一整套运行中的家当才叫进程。本章前半讲这个抽象的 构成和三态生命周期;后半讲 UNIX 创建进程的接口——fork() 和 exec()—— 它故意设计得古怪,而这古怪正是 shell 全部威力的来源。

在全书链条里的位置:这是「虚拟化 CPU」的第一块基石。进程定义了 「被虚拟的那个东西」是什么;下一章讲怎么在它之间切换。

2. 顶层全景

  磁盘上的程序 ──加载──▶ 进程(内存里的代码+数据 + 栈 + 堆 + 寄存器 + 打开的文件)
                              │
                              ▼
                    三态:Running(在跑) ⇄ Ready(排队等跑)
                              │▲
                              ▼│ I/O 发起 / I/O 完成
                           Blocked(等事,不让跑)

  创建新进程:fork() ──复制自己──▶ 父子两个几乎一样的进程
                        │
                        └─▶ exec() ──换成另一个程序(成功就不返回)

图说:上图左半是进程的「生命状态机」,右半是 UNIX 的「复制再替换」式创建法。

3. 核心原理

3.1 主线例子:一次 fork(),两个进程,一个 PID 之差

书里用了一个十行的小程序 p1.c 做主线例子,我们照原样接住1:

int main(int argc, char *argv[]) {
    printf("hello (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {          // fork 失败
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {  // 子进程走这里
        printf("child (pid:%d)\n", (int) getpid());
    } else {               // 父进程走这里
        printf("parent of %d (pid:%d)\n", rc, (int) getpid());
    }
    return 0;
}

运行它,书里记录的真实输出(注意:两次运行的次序可能不同,这是本章的题眼):

prompt> ./p1
hello (pid:29146)
parent of 29147 (pid:29146)     ← 父进程打印,rc 是 29147
child (pid:29147)               ← 子进程打印

跟着这个输出走一遍 fork() 这件怪事:

  1. fork() 复制整个进程。子进程不是从 main() 重新起跑—— 「hello」只打了一次,说明子进程是从 fork() 返回处直接开始活的, 「仿佛它自己刚刚调用了 fork() 一样」2。此时父与子几乎一模一样: 各自的私有内存、各自的寄存器、各自的程序计数器。
  2. 父子靠返回值区分。这是复制留下的唯一记号:父进程收到的是 新子进程的 PID(29147),子进程收到的是 03。就靠这一个数的差别, 同一份代码走出两条分支。
  3. 次序不保证。两个进程谁先跑,由调度器决定——书里明说输出 「不是确定性的」4;上面的输出里父先子后,换个调度决定就变成 子先父后。记住这个「不确定」,它是全书并发部分的总引线。
  4. wait() 把次序钉死。父进程调 wait() 后,即使它先抢到 CPU, 也会一直等到子进程退出才返回5。所以加了 wait 的版本, 子一定先打印——确定性是被「等」回来的。

3.2 exec():不换进程,换程序

fork() 只能复制自己;要运行一个别的程序,得用 exec()。 它的行为配得上「古怪」二字:给它一个可执行文件名和参数, 它用新程序的代码覆盖当前进程的代码段,堆、栈重新初始化, 然后从这个新程序的起点开始跑——进程还是那个进程(PID 没变), 里面的程序已经换人了6。成功时它从不返回; exec() 后面那行打印「this shouldn't print out」的代码, 就是永远执行不到的死代码7。

主线例子的完整版(p3.c):子进程先打印自己的 pid,然后 execvp("wc", ["wc", "p3.c"])——瞬间,这个子进程不再是 p3, 变成了字数统计程序 wc,输出 29 107 1030 p3.c; 父进程 wait() 到 wc 结束,才打印最后一句8。

3.3 为什么非要拆成两个调用?

这是最要紧的一问。直接把「创建并运行新程序」做成一个调用不行吗? 书里给出的答案是:拆开来,才能在中间动手脚。shell 就是个 活生生的例子——它是个普通用户程序,你敲的一行命令, 它做的事是:fork() 出子进程 → 在子进程里、exec() 之前、 做点小动作 → exec() 运行你的命令 → 自己 wait() 等命令结束9。

「小动作」成就了 UNIX 的两大招牌:

  • 输出重定向(wc p3.c > newfile.txt):shell 在子进程里 先关掉标准输出,再打开 newfile.txt——UNIX 从 0 开始找空闲的 文件描述符,新打开的文件正好占了标准输出那个号,于是 wc 的 输出悄悄进了文件。exec() 会保留已打开的文件描述符,魔术成立10。
  • 管道(grep foo file | wc -l):一个进程的输出接进内核里的 一条队列(pipe),另一个进程的输入接同一条队列, 两个普通程序就此首尾相接11。

如果创建新程序是一整个原子调用,shell 就没地方塞这两段代码了。 书里把这归于 Lampson 定律——「先把事情做对」,并称 fork()+exec() 的组合是 UNIX 设计师「真的做对了」的一处12。

3.4 进程的三态与状态转移

一个进程任一时刻处于三种状态之一(多出的都是细节)13:

状态 含义 什么时候
Running 正在处理器上执行指令 被调度
Ready 能跑,但操作系统选了别人 被抢下(desceduled)
Blocked 在等某件事(I/O),不让跑 发起 I/O;I/O 完成后回 Ready

图说:I/O 是状态机的枢纽——进程一读磁盘就 Blocked, CPU 立刻转给别的进程,这正是「一个 CPU 假装很多个」的第一个具体机制。 书里用两个进程、九到十步的时序表走了一遍「纯 CPU 型」和「带 I/O 型」 两种交错轨迹,并点明:每一步「换不换人」都是调度器的决策—— 那是下一章之后几章(本库 04、05)的主角14。

3.5 进程的构成与操作系统手里的账本

书里给「进程是什么」开了一张清单,叫机器状态(machine state): 程序运行时能读能改的一切15——

  • 内存(它的地址空间:代码、静态数据、堆、栈都在里面);
  • 寄存器,特别是三个特殊的:程序计数器(PC,下一条要执行的指令)、 栈指针和基址指针(管函数调用);
  • 打开的文件列表。

操作系统自己也是个程序,它的账本(以 xv6 内核为例)是每个进程一个 proc 结构:保存哪些寄存器、当前状态、PID、父进程指针等16。 上下文切换就是把这份账本换上另一份——怎么换、换得多快、 换的时候怎么防程序捣乱,是下一章「受限直接执行」的全部内容。

4. 作者的判断与证据

书里给了证据的判断:

  • 三态模型来自真实历史系统([DV66, V+65]),不是教学简化—— 早期多道程序系统就是这么记账的。
  • fork/exec 的设计评价引 Lampson「Hints for Computer Systems Design」, 并给出具体机制证据:文件描述符跨 exec() 保留、从 0 开始分配, 重定向和管道由此成立10。

作者的判断(推测/口味):

  • 「fork() 是你见过的最古怪的例程」1——带玩笑,但点出一个真问题: 复制整个地址空间再丢弃,是现代系统上昂贵的做法(书里没展开, 我们把它放进 §5 边界)。
  • wait 让输出确定性的论证里,作者自己插话「 beware of any absolute and unqualified statements this book makes」17——这种对绝对化 表述的警惕是全书的一贯风格。

5. 边界与局限

  • fork 的「整份复制」在今天是惰性实现的(写时才真复制,本库第 07 章讲), 书中按语义模型讲,没讲实现开销——fork 在现代大内存进程上的代价 是真实存在的,这是「复制再替换」设计被后人批评最多的一点 (补充,不在书里:POSIX 的 posix_spawn、Linux 的 clone 都是回应)。
  • 本章的进程模型是单线程的;线程来了之后,进程内会有多个执行点, 状态机不变但「机器状态」的归属复杂了(本库 11 章)。
  • 书里对调度决策(例子 4.3/4.4 中「该不该切回 P0」)故意留为开放问题, 说「不清楚这是不是个好决定」——这诚实,但也意味着此处没有定论可背。

6. 可带走的

  1. 进程 = 运行中的程序 = 内存 + 寄存器(尤其 PC)+ 打开的文件。 判断「什么东西属于进程」就看断电重启后还在不在——在的是磁盘,不在的是进程。
  2. fork() 一次调用、两次返回:父得子 PID,子得 0;子进程从 fork 返回处起跑,不重新进 main。
  3. exec() 不换进程换程序,成功不返回;PID 不变、内容全换。
  4. fork 与 exec 分开,是为了让 shell 在中间塞代码——重定向、管道由此而来。 接口的古怪常常是为了给调用者留缝。
  5. 三态(Running/Ready/Blocked)是进程的全部生命;I/O 是从 Running 掉进 Blocked 的门票。
  6. 无 wait 时父子执行次序不确定——「不确定」三个字是全书并发部分的总开关。

7. 原文地图

主题 原书章 原文位置
进程的定义:运行中的程序 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:4(搜「a running program」)
机器状态:内存、寄存器、PC 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:62(搜「machine state」)与:73(搜「program counter」)
加载:从程序到进程 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:192(搜「jumping to the main」)
三态模型 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:201(搜「three states」)
带 I/O 的状态转移轨迹 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:256(搜「Process0 initiates」)
xv6 的进程结构 4. The Abstraction: The Process text/06-ch04-4-the-abstraction-the-process.txt:321(搜「struct proc」)
fork 复制调用进程 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:69(搜「exact copy」)
子进程从 fork 返回处开始 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:75(搜「as if it had called fork」)
父子凭返回值区分 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:105(搜「return code」)
输出不确定 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:108(搜「not deterministic」)
wait 钉死次序 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:141(搜「until the child」)
exec 覆盖代码段、成功不返回 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:235(搜「overwrites its current code」)与:242(搜「never returns」)
fork/exec 分离成就 shell 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:247(搜「separation of fork」)
Lampson 定律 5. Interlude: Process API text/07-ch05-5-interlude-process-api.txt:225(搜「GETTING IT RIGHT」)


  1. 出处:「5. Interlude: Process API」第 24 段(text/07-ch05-5-interlude-process-api.txt:24,搜「strangest routine」)。原书称 fork() 是「你调用过的最古怪的例程」;程序与输出见同章图 5.1 及第 53–59 段。 ↩↩

  2. 出处:「5. Interlude: Process API」第 75 段(text/07-ch05-5-interlude-process-api.txt:75,搜「as if it had called fork」)。 ↩

  3. 出处:「5. Interlude: Process API」第 105 段(text/07-ch05-5-interlude-process-api.txt:105,搜「return code」)。 ↩

  4. 出处:「5. Interlude: Process API」第 108 段(text/07-ch05-5-interlude-process-api.txt:108,搜「not deterministic」)。 ↩

  5. 出处:「5. Interlude: Process API」第 141 段(text/07-ch05-5-interlude-process-api.txt:141,搜「until the child」)。原文:wait() 会延迟父进程执行,直到子进程完成。 ↩

  6. 出处:「5. Interlude: Process API」第 235–240 段(text/07-ch05-5-interlude-process-api.txt:235,搜「overwrites its current code」)。 ↩

  7. 出处:「5. Interlude: Process API」第 242 段(text/07-ch05-5-interlude-process-api.txt:242,搜「never returns」)。 ↩

  8. 出处:「5. Interlude: Process API」第 211–216 段(text/07-ch05-5-interlude-process-api.txt:214,搜「29 107」)。 ↩

  9. 出处:「5. Interlude: Process API」第 252–260 段(text/07-ch05-5-interlude-process-api.txt:252,搜「just a user program」)。 ↩

  10. 出处:「5. Interlude: Process API」第 272–281 段(text/07-ch05-5-interlude-process-api.txt:274,搜「kept open across」)。 ↩↩

  11. 出处:「5. Interlude: Process API」第 296–302 段(text/07-ch05-5-interlude-process-api.txt:296,搜「implemented in a similar way」)。 ↩

  12. 出处:「5. Interlude: Process API」第 225–233 段(text/07-ch05-5-interlude-process-api.txt:227,搜「Neither abstraction」)。 ↩

  13. 出处:「4. The Abstraction: The Process」第 201 段(text/06-ch04-4-the-abstraction-the-process.txt:201,搜「three states」)。 ↩

  14. 出处:「4. The Abstraction: The Process」第 282 段(text/06-ch04-4-the-abstraction-the-process.txt:282,搜「OS scheduler」)。 ↩

  15. 出处:「4. The Abstraction: The Process」第 62 段(text/06-ch04-4-the-abstraction-the-process.txt:62,搜「machine state」)。 ↩

  16. 出处:「4. The Abstraction: The Process」第 321–330 段(text/06-ch04-4-the-abstraction-the-process.txt:321,搜「struct proc」)。 ↩

  17. 出处:「5. Interlude: Process API」第 166–169 段(text/07-ch05-5-interlude-process-api.txt:167,搜「beware」)。 ↩