Lecture 16: 进程与多任务 (Processes and Multitasking)
Lecture 16: 进程与多任务 (Processes and Multitasking)
讲义对应:CMU 15-213 Lecture 16 — Processes and Multitasking(素材:
F25-16-processes.txt;配套 recitation:F25-rec09_slides.txt) 教材对应:CS:APP3e 第 8 章 8.1–8.4(异常、进程、系统调用与进程控制) 关联 Lab:L6 Shell Lab(tsh)——本讲是 Shell Lab 的全部基础
16.1 概述
从 1957 年 NASA 的 IBM 704 一次只能跑一个批处理作业,到今天一台笔记本上同时有几百个”任务”,中间隔着一整套操作系统机制。本讲的核心问题是:一台只有有限 CPU 核的机器,如何让许多程序”同时”运行,并且彼此互不干扰?
答案是操作系统提供的两个虚假承诺:”每个程序独占 CPU”(由上下文切换实现)与”每个程序独占内存”(由第 11–12 讲的虚拟内存实现)。支撑它们的底层机制是异常控制流(Exceptional Control Flow, ECF)——程序计数器从 $a_k$ 突然跳到 $b_k$ 的那种”突变”。承接 Lecture 15 的代码优化,本讲把视角从”一条指令怎么快”提升到”整个系统怎么调度”;通向 Lecture 17 的信号与非局部跳转,并直接为 L6 Shell Lab 铺路。
16.2 核心概念与底层机制图解
16.2.1 异常控制流(Exceptional Control Flow, ECF)
- 定义与目的:ECF 是”处理器状态发生变化时,控制权从当前指令流转移到内核”的机制,存在于计算机系统的所有层次:低层是硬件 + 操作系统共同实现的异常(exception);高层依次是进程上下文切换(OS + 硬件定时器)、信号(OS 软件)、非局部跳转
setjmp/longjmp(C 运行时库)。本讲聚焦前两者,信号留到 Lecture 17。 - 直观解释(”它是什么?”):普通控制流像一列按时刻表行驶的火车,只能沿轨道一站一站走(
jmp/call/ret还在轨道内)。ECF 则像车上的紧急制动 + 调度中心:定时器一跳闸(中断),火车必须立刻靠边,由调度中心决定下一趟走哪辆车。程序员看不到这个切换,但它的后果(并发、竞态)无处不在。 - 底层机制图解:
用户代码 (user code) 内核代码 (kernel code)
+---------------------------+ +---------------------------+
| I_current | | |
| ... | | 异常处理程序 k |
| I_next <-- 返回目标 | | (exception handler k) |
+---------------------------+ +---------------------------+
| ^
| ① 事件发生 (event) | ③ 处理
v |
[ 处理器状态变化 ] ──② 陷入(trap/中断)──>+
^
| ④ 三种返回方式之一:
| · 返回到 I_current (fault 重执行当前指令)
| · 返回到 I_next (interrupt / trap)
| · 不返回,终止进程 (abort)
异常表 Exception Table (又称中断向量 interrupt vector)
+---------------------+ 异常号 k → 异常表中的下标
| 0 handler 0 代码 | (每个事件类型有唯一的 exception number k)
| 1 handler 1 代码 |
| 2 handler 2 代码 |
| ... |
| n-1 handler n-1 |
+---------------------+
- 与机器码/硬件的对应:异常号 $k$ 加在异常表基址寄存器上,得到处理程序入口。用户程序请求内核服务时执行
syscall指令(历史上是int 0x80):系统调用号放在%rax,参数依次放%rdi, %rsi, %rdx, %r10, %r8, %r9,返回值放%rax。真实的open封装:
00000000000e5d70 <__open>:
...
e5d79: b8 02 00 00 00 mov $0x2,%eax # open 是 2 号系统调用
e5d7e: 0f 05 syscall # 返回值在 %rax
e5d80: 48 3d 01 f0 ff ff cmp $0xfffffffffffff001,%rax
...
e5dfa: c3 retq
它”几乎像一次函数调用”:同样有控制转移、同样用调用约定传参、同样在 %rax 拿结果,返回后执行下一条指令。但有一处根本不同——它由内核以更高特权执行,且失败时返回一个 $-1$ 到 $-4095$ 之间的负值(对应 errno 的负值)。
16.2.2 四类异常(Interrupt / Trap / Fault / Abort)
- 定义与目的:按”事件来自处理器外部还是当前指令”以及”返回行为”划分,硬件 ECF 分成四类。异步异常(asynchronous exception) 由处理器外部事件引起,通过置位处理器的中断引脚(interrupt pin) 通知 CPU;同步异常(synchronous exception) 由正在执行的指令直接引起。
- 直观解释:异步就像手机来电——不管你正在做什么都会打断你;同步就像你写字时笔断了——是你手上这个动作直接导致的后果。
- 底层机制图解(对照表):
+----------+--------+----------+---------------------+---------------------------+
| 类别 | 原因 | 异步/同步 | 返回行为 | 典型例子 |
+----------+--------+----------+---------------------+---------------------------+
| 中断 | 来自处理器| | 返回到 I_next | 定时器中断(每几毫秒)、 |
| Interrupt| 外部 | 异步 | (下一条指令) | I/O 中断、Ctrl-C、网络包到达|
+----------+--------+----------+---------------------+---------------------------+
| 陷阱 | 有意为之 | | 返回到 I_next | 系统调用(syscall)、 |
| Trap | 的指令 | 同步 | (下一条指令) | gdb 断点 |
+----------+--------+----------+---------------------+---------------------------+
| 故障 | 指令引起的| | 重执行 I_current | 缺页(page fault, 可恢复)、 |
| Fault | 意外 | 同步 | 或终止进程 | 保护故障、浮点异常 |
+----------+--------+----------+---------------------+---------------------------+
| 终止 | 意外且 | | 直接终止当前程序 | 非法指令、奇偶校验错、 |
| Abort | 不可恢复 | 同步 | (不返回) | 机器检查(machine check) |
+----------+--------+----------+---------------------+---------------------------+
- 与机器码/硬件的对应:x86-64 常见异常号(补充说明,取自教材 Figure 8.9):0 = 除法错误,13 = 一般保护故障,14 = 缺页,18 = 机器检查,32–255 由操作系统自定义(Linux 用 128 号即
0x80)。缺页最能说明 fault 的语义:
80483b7: c7 05 10 9d 04 08 0d movl $0xd,0x8049d10 # a[500] = 13;
这条指令触发缺页 → 内核把页从磁盘调入内存 → 重新执行同一条 movl,这次命中。若访问的是非法地址(如 a[5000]),内核无法恢复,改向进程发送 SIGSEGV,进程以 “segmentation fault” 退出——同一个 fault 机制,两种结局。
16.2.3 用户模式与内核模式(User Mode / Kernel Mode)
- 定义与目的:处理器用模式位(mode bit) 标记当前特权级。置位(内核模式)时进程可执行特权指令(privileged instructions)、访问全部内存与 I/O 端口;清零(用户模式)时受限。为什么需要它:若用户程序能随意改写页表、关中断、直接操作磁盘控制器,任何一个 bug 或恶意程序都能毁掉整台机器。模式位是”内核受保护、进程之间相互隔离”的最后一道物理防线。
- 直观解释(”它是什么?”):普通员工(用户模式)只能进自己办公室和公共走廊;物业管理员(内核模式)持万能钥匙,能进配电间。唯一合法的”召唤管理员”方式,就是按下墙上的对讲机——这正好就是
syscall指令所做的事。 - 底层机制图解:
用户模式 (mode bit = 0) 内核模式 (mode bit = 1)
+------------------------+ +--------------------------+
| 自己的代码/数据 | | 页表、中断描述符表 |
| 自己的栈、堆 | syscall | 设备寄存器、I/O 端口 |
| 只能通过 /proc /sys 观察 | ─────────> | 特权指令 (cli/hlt/lgdt…) |
| 内核的"只读窗口" | <───────── | 全部物理内存 |
+------------------------+ ret +--------------------------+
/proc/PID/maps, /proc/PID/status, /sys/... 是用户态读内核状态的接口
- 与机器码/硬件的对应:
syscall之外的任何”越权”动作都会产生一般保护故障(异常号 13),内核据此终止进程。Linux 把/proc与/sys实现为伪文件系统:读/proc/self/maps(虚拟内存各区间映射)、/proc/self/status(任务状态)时,内核在read系统调用里现算现返回,磁盘上并不存在这些数据。
16.2.4 进程与它的两个关键抽象
- 定义与目的:进程(process)是一个执行中程序的实例。注意它与”程序”(磁盘上的静态文件)和”处理器”(执行单元)都不同。进程提供两个关键抽象:① 私有地址空间(private address space)——每个程序看起来独占主存,由虚拟内存实现;② 逻辑控制流(logical control flow)——每个程序看起来独占 CPU,由上下文切换实现。
- 直观解释:进程就像独立公寓:墙(地址空间隔离)让你看不到也碰不到邻居的家具(其他进程的内存),水电闸(CPU、寄存器)也都是”你一个人的”——实际上整栋楼是分时共享的。
- 底层机制图解(进程地址空间与页表):
进程 A 的虚拟地址空间 (每个进程一份,独立) 0x00007fffffffffff
+-------------------------------+ ^ +--------------------+
| 内核虚拟内存(用户不可访问) | | | 页表 (page table) |
+-------------------------------+ | | 由内核为每个进程维护 |
| 用户栈 <- %rsp | | +--------------------+
| ... | | 高地址 VPN (虚拟页号) PPN (物理页号)
| 共享库映射区 (libc.so .text) | | 0x7ffd... --> 0x0031a
| ... | | 0x00400... --> 0x008f2
| 运行时堆 (malloc 分配) | | ...
+-------------------------------+ |
| 未初始化数据 .bss (demand-zero) | |
| 已初始化数据 .data(文件后备) | |
| 程序代码 .text(文件后备,只读) | v
+-------------------------------+ 0x0000000000400000
| 未使用 |
+-------------------------------+ 0
↑ 每个进程都有独立的一套页表,把同一批虚拟地址
映射到不同的物理页 —— 这就是"私有地址空间"的全部秘密
- 与机器码/硬件的对应:每次上下文切换都要换页表基址寄存器(x86-64 的
%cr3),这使得 TLB 失效、地址翻译重新开始——这也是上下文切换比普通函数调用昂贵得多的原因之一。切换的触发者可以是syscall(进程主动陷入内核,如read阻塞),也可以是定时器中断(内核借此夺回控制权,实现抢占式调度)。内核不是独立进程,它”跑在某个既有进程的上下文里”。
16.2.5 并发流、多任务与上下文切换
- 定义与目的:让单个 CPU 核交错执行多个进程称为多任务(multitasking),分配的时间段称为时间片(time slice)。如果两个进程的执行在时间上重叠,则称它们是并发(concurrent)的;否则是顺序(sequential)的。保存当前进程寄存器、恢复下一个进程寄存器并切换地址空间的过程,就是上下文切换(context switch)。
- 关键区分:并发只要求时间上重叠,并行(parallel) 要求物理上真正同时执行。单核上 A、B 只能并发(交错)而非并行;多核上才可能并行。多核共享主存(以及部分缓存),每个核可执行不同进程,把处理器调度到核上由内核完成。
- 底层机制图解(时间轴:父/子进程的逻辑控制流):
时间 ──────────────────────────────────────────────────────────────>
进程 A ┌────┐ ┌────┐ ┌──────┐
(父) │用户 │ │用户 │ │用户 │
└─┬──┘ └─┬──┘ └──┬───┘
│ syscall │ │
v v v
内核 ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 调度/切换 │ │ 调度/切换 │ │ 调度/切换 │
└────┬────┘ └────┬────┘ └────┬────┘
v v v
进程 B ──────┴──┐ ┌────┴───┐ ┌─────┴────┐
(子) │用户 │ │用户 │ 用户 │
└─────┘ └──────────────┘
↑ A 与 B 的执行区间在时间上重叠 => A 与 B 并发
但在单核上任一时刻只有一个在真正执行 => 不是并行
对比:B 完全在 A 结束之后才开始 => B 与 A 顺序(sequential)
- 与机器码/硬件的对应:上下文切换保存的是寄存器状态 + 地址空间标识;单核实现”保存当前寄存器到内存 → 调度下一个进程 → 装载被保存的寄存器并切换地址空间”三步。
16.2.6 进程组与作业(Process Group / Job)
- 定义与目的:进程组(process group) 是一组进程的集合,由正整数 PGID 标识;
setpgid(pid, pgid)设置、getpgrp()查询。作业(job) 是一次命令行解析所产生的全部子进程的总称。Shell 用进程组组织作业,从而支持作业控制(job control)。 - 直观解释:进程组就像”一个施工队”,队长的 PID 就是队号。要通知整个队停工,不必挨个打电话——冲着队号喊即可:
kill(-pgid, sig)中的负号正是”发给整组”。 - 底层机制图解:
终端 (terminal driver)
│ 用户按 Ctrl-C / Ctrl-Z
│ 终端驱动【不知道】shell 的作业表,它只认一件事:
v 把信号发给【前台进程组】(foreground process group)
+-----------------------------------------------------+
| 前台进程组 PGID = 100 |
| [100] tsh 的子进程 (job 1) ← 收到 SIGINT/SIGTSTP |
+-----------------------------------------------------+
| 后台进程组 PGID = 105 (job 2) —— 收不到,继续跑 |
| 后台进程组 PGID = 110 (job 3) —— 收不到,继续跑 |
+-----------------------------------------------------+
关键推论:子进程 fork 后必须 setpgid(0, 0) 把自己放进【新】进程组,
否则它会留在 shell 的前台进程组里,Ctrl-C 会连带打死 shell 自己。
- 与机器码/硬件的对应:
kill系统调用(编号 62)的pid参数为负时表示”向 PGID = |pid| 的进程组发送信号”。这正是 Shell Lab 里sigint_handler/sigtstp_handler必须写kill(-pid, SIGINT)的原因——sdriver.pl会专门测试这个错误。
16.3 代码示例与底层机制分析
16.3.1 示例一:fork —— 调用一次,返回两次
代码 (C):
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int x = 1;
pid_t pid;
pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0) {
/* 子进程:fork 在这里返回 0;它拥有 x 的独立副本 */
x++; /* 只影响子进程的 x */
printf("[child ] pid=%d ppid=%d | x=%d\n",
(int)getpid(), (int)getppid(), x);
return 0; /* 或者 exit(0); 二者等价 */
}
/* 父进程:fork 在这里返回子进程 PID;它拥有 x 的另一份副本 */
x--; /* 只影响父进程的 x */
printf("[parent] pid=%d ppid=%d | x=%d\n",
(int)getpid(), (int)getppid(), x);
waitpid(pid, NULL, 0); /* 回收子进程,避免僵尸 */
return 0;
}
【代码做什么?】 ① 主进程调用 fork();② 内核复制出一个几乎相同的进程,把同一个 fork() 调用点变成两个返回点——父进程拿到子进程 PID,子进程拿到 0;③ 父子各自独立修改自己的 x(两边初值都是 1,之后互不影响);④ 父进程 waitpid 回收子进程。
【底层机制透视】 fork 的概念模型是”完整复制执行状态:寄存器、内存、被保存的寄存器,然后指定一个为父、一个为子”。逐字节复制内存极其昂贵,实现上采用写时复制(copy-on-write):父子先共享同一批只读物理页,任一进程试图写入时触发保护故障,内核才复制那一页。子进程还继承父进程的文件描述符(stdin/stdout/stderr 指向同一批打开文件),所以父子输出会交错到同一终端上。
【内存布局 / 数据结构图解】 关键点是”同一个虚拟地址,两份不同的物理内容”:
父进程虚拟地址空间 子进程虚拟地址空间
+------------------+ +------------------+
| ... | | ... | 虚拟地址相同:
| x @ 0x7ffd1234 | | x @ 0x7ffd1234 | x 都在 0x7ffd1234
| 值 = 0 | | 值 = 2 | 物理页不同!
| ... | | ... |
+------------------+ +------------------+
父进程 PID = 3575538 子进程 PID = 3575540
父进程 PPID = 2126633 (shell) 子进程 PPID = 3575538 (父进程)
【与汇编 / 硬件的对应】 栈上的 x 由 -4(%rbp) 之类的地址访问。fork 返回后,glibc 通过比较 %rax(系统调用返回值)区分父子:testq %rax,%rax; je .Lchild。x86-64 上 fork 实际由 clone(CLONE_CHILD_CLEARTID\|CLONE_CHILD_SETTID\|SIGCHLD) 实现。
【实测验证】 真实运行(gcc -g -Wall -std=c11 forkdemo.c -o forkdemo,本机 Linux x86-64):
$ ./forkdemo
[child ] pid=3575540 ppid=3575538 | x=2
[parent] pid=3575538 ppid=2126633 | x=0
无论跑多少次,每一行的内容都是确定的(子必打印 x=2 且 ppid 等于父的 pid,父必打印 x=0),但两行的先后顺序不确定——讲义给出的三次运行结果中就有一次是子在前。strace -f -e trace=clone,execve,wait4 ./forkdemo 证实了这条链路:
execve("./forkdemo", ["./forkdemo"], ...) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...) = 3578767
[pid 3578763] wait4(3578767, <unfinished ...>
[pid 3578767] exit_group(0) = ?
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=3578767, si_status=0} ---
<... wait4 resumed>NULL, 0, NULL) = 3578767
16.3.2 示例二:真实的 fork 陷阱——输出重复
代码 (C)(⚠️ 仅供演示,请勿模仿):
/* forktrap_bad.c —— 故意制造"同一行打印两遍" */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("BUFFERED-LINE"); /* 无换行、无 fflush:仍在用户态缓冲区 */
if (fork() == 0) {
exit(0); /* 子进程用 exit(),会冲刷 stdio 缓冲区 */
}
exit(0); /* 父进程也冲刷一次 */
}
【代码做什么?】 printf 的内容因为没有换行且 stdout 被重定向(全缓冲模式),停留在 glibc 用户态的 FILE 缓冲区里,一个字节都没写到内核。随后 fork() 复制整个地址空间——包括这个缓冲区。父子各自 exit() 时各冲刷一次,同一行于是被写两遍。
【底层机制透视】 这是”fork 复制地址空间”的直接后果,也是讲义”Shared open files”那句话的反面:打开文件表共享,stdio 缓冲区私有。修复只需两步(fork 前 fflush,子进程用 _exit):
/* forktrap_good.c —— 正确做法 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("BUFFERED-LINE");
fflush(stdout); /* 关键:fork 前把用户态缓冲区排空 */
if (fork() == 0) {
_exit(0); /* 关键:_exit 不触碰 stdio 缓冲区 */
}
_exit(0);
}
【实测验证】 真实运行结果对比(两者都重定向到文件,即触发全缓冲):
$ ./forktrap_bad > bad.out ; wc -c bad.out
26 bad.out
$ cat -A bad.out
BUFFERED-LINEBUFFERED-LINE <- 同一行出现两次(26 = 13 × 2 字节)
$ ./forktrap_good > good.out ; wc -c good.out
13 good.out
$ cat good.out
BUFFERED-LINE <- 只出现一次(13 字节)
在终端上直接运行 forktrap_bad 却只能看到一行——stdout 连着 tty 时是行缓冲,而缓冲区里没有换行符。所以这个 bug 只在”输出重定向 + 未 fflush”时暴露,这也正是 Shell Lab 里到处要写 fflush(stdout) 的原因。
16.3.3 示例三:waitpid 与 status 解析
代码 (C):
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
static pid_t spawn(int code, int sig, const char *tag)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0) {
if (sig != 0) {
signal(sig, SIG_DFL); /* 恢复默认动作,再自杀 */
raise(sig);
_exit(99); /* 若信号被忽略才会到这里 */
}
_exit(code & 0xff); /* 退出码只保留低 8 位 */
}
printf("spawn %-10s -> pid=%d\n", tag, (int)pid);
return pid;
}
static void report(pid_t pid)
{
int status = -1;
pid_t w = waitpid(pid, &status, 0);
if (w < 0) { perror("waitpid"); return; }
printf(" waitpid returned %d ; raw status = 0x%04x\n", (int)w, status);
if (WIFEXITED(status))
printf(" WIFEXITED=1 WEXITSTATUS=%d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf(" WIFSIGNALED=1 WTERMSIG=%d (%s) WCOREDUMP=%d\n",
WTERMSIG(status), strsignal(WTERMSIG(status)), WCOREDUMP(status));
else if (WIFSTOPPED(status))
printf(" WIFSTOPPED=1 WSTOPSIG=%d\n", WSTOPSIG(status));
printf("\n");
}
int main(void)
{
printf("=== (a) 正常 exit(42) ===\n"); report(spawn(42, 0, "exit(42)"));
printf("=== (b) 正常 exit(0) ===\n"); report(spawn(0, 0, "exit(0)"));
printf("=== (c) 段错误 -> SIGSEGV(11) ===\n");
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0) {
volatile int *p = (int *)0; /* ⚠️ 仅供演示:故意解引用空指针 */
*p = 13;
_exit(1);
}
printf("spawn %-10s -> pid=%d\n", "segv", (int)pid);
report(pid);
}
printf("=== (d) 被 SIGINT(2) 杀死 ===\n"); report(spawn(0, SIGINT, "SIGINT"));
printf("=== (e) exit(0x213) 只保留低 8 位 ===\n"); report(spawn(0x213, 0, "exit(0x213)"));
return 0;
}
【实测验证】 真实运行输出:
$ ./waitsdemo
=== (a) 子进程正常 exit(42) ===
spawn exit(42) -> pid=3576150
waitpid returned 3576150 ; raw status = 0x2a00
WIFEXITED=1 WEXITSTATUS=42
=== (b) 子进程正常 exit(0) ===
waitpid returned 3576151 ; raw status = 0x0000
WIFEXITED=1 WEXITSTATUS=0
=== (c) 子进程段错误 -> SIGSEGV(11) ===
waitpid returned 3576152 ; raw status = 0x008b
WIFSIGNALED=1 WTERMSIG=11 (Segmentation fault) WCOREDUMP=128
=== (d) 子进程被 SIGINT(2) 杀死 ===
waitpid returned 3576229 ; raw status = 0x0002
WIFSIGNALED=1 WTERMSIG=2 (Interrupt) WCOREDUMP=0
=== (e) 退出码 0x213 只保留低 8 位 ===
waitpid returned 3576230 ; raw status = 0x1300
WIFEXITED=1 WEXITSTATUS=19
【底层机制透视】 关键数字:0x2a00 = 42 « 8,说明正常退出的状态码放在 status 高字节;0x8b = 139 = 128(core dump 标志)+ 11(SIGSEGV),说明信号号在低 7 位,第 8 位是 core dump 标志。recitation 的 exit(0x213) 得到 0x1300,因为 WEXITSTATUS 只返回 1 个字节——$0x213 \bmod 256 = 0x13 = 19$。
【速查表:waitpid 选项与 status 宏】
pid_t waitpid(pid_t pid, int *status, int options)
┌──── pid 取值 ────────┬──────────────────────────────────────────────┐
│ pid > 0 │ 只等 PID == pid 的那一个子进程 │
│ pid == -1 │ 等【任意】一个子进程(等价于 wait) │
│ pid == 0 │ 等与调用者同一进程组中的任意子进程 │
│ pid < -1 │ 等进程组 |pid| 中的任意子进程 │
└──────────────────────┴──────────────────────────────────────────────┘
┌──── options 位 ──────┬──────────────────────────────────────────────┐
│ 0 │ 阻塞直到目标子进程终止/停止 │
│ WNOHANG │ 非阻塞:无子进程可回收时【立即】返回 0 │
│ WUNTRACED │ 除终止外,也报告【被停止】的子进程 │
│ WCONTINUED │ 也报告收到 SIGCONT 而恢复运行的子进程 │
└──────────────────────┴──────────────────────────────────────────────┘
返回值:> 0 = 被回收子进程的 PID ;0 = 仅 WNOHANG 且有子进程但都未终止 ;
-1 = 出错(无子进程时 errno == ECHILD)
┌──── status 位域 ────────────────────────────────────────────────────┐
│ bit15..8 │ bit7 │ bit6..0 │
│ 退出状态 (exit code)│ coredump│ 信号号 或 停止信号号 或 0x7f(SIGCONT) │
└────────────────────────────────────────────────────────────────────┘
┌──── 判定宏 ──────────┬──── 取值宏 ──────┬────────────────────────────┐
│ WIFEXITED(st) │ WEXITSTATUS(st) │ 正常 exit / main 返回 │
│ WIFSIGNALED(st) │ WTERMSIG(st) │ 被未捕获信号杀死 │
│ │ WCOREDUMP(st) │ 是否产生了 core 文件 │
│ WIFSTOPPED(st) │ WSTOPSIG(st) │ 被信号停止(需 WUNTRACED) │
│ WIFCONTINUED(st) │ — │ 被 SIGCONT 恢复(需 WCONTINUED)│
└──────────────────────┴──────────────────┴────────────────────────────┘
铁律:这些宏【必须】配合使用——先 WIFEXITED 判断,再取 WEXITSTATUS。
直接读 WEXITSTATUS 一个被信号杀死的子进程,读出的是垃圾。
16.3.4 示例四:fork + execve 迷你 shell
代码 (C):
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define MAXLINE 1024
#define MAXARGS 64
int main(void)
{
char buf[MAXLINE];
char *argv[MAXARGS];
extern char **environ;
while (1) {
printf("minish> ");
fflush(stdout);
if (fgets(buf, MAXLINE, stdin) == NULL)
break; /* Ctrl-D / EOF */
int argc = 0;
char *tok = strtok(buf, " \t\n");
while (tok != NULL && argc < MAXARGS - 1) {
argv[argc++] = tok;
tok = strtok(NULL, " \t\n");
}
argv[argc] = NULL;
if (argc == 0) continue; /* 空行忽略 */
if (strcmp(argv[0], "quit") == 0) /* 内建命令 */
break;
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0) {
execve(argv[0], argv, environ); /* 成功则永不返回 */
fprintf(stderr, "minish: %s: %s\n", argv[0], strerror(errno));
_exit(127);
}
int status;
if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); exit(1); }
if (WIFEXITED(status))
printf("[exit status %d]\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("[killed by signal %d]\n", WTERMSIG(status));
}
printf("bye\n");
return 0;
}
【代码做什么?】 读一行 → 按空白切词成 argv → 内建命令就地执行 → 否则 fork,子进程 execve 加载目标程序,父进程 waitpid 等前台作业结束并报告死因。这就是讲义 shellex.c 的 eval 主干。
【底层机制透视】 execve(filename, argv, envp) 做四件事:① 用 filename 的内容覆盖当前进程的代码、数据、栈;② 按 argv 布置新用户栈(含 argc、argv[]、envp[]、environ);③ 保留 PID、已打开的文件描述符、信号上下文;④ 把 PC 设为 .text 入口点。Linux 的实现是:释放旧 vm_area_struct 与页表 → 为新区域建 vm_area_struct 与页表(代码/已初始化数据由目标文件后备,.bss 与栈由匿名文件后备)→ 设置 PC;代码页与数据页随后由缺页故障按需调入。”调用一次,永不返回”——除非出错(此时返回 $-1$ 并设 errno)。
【内存布局图解:新程序启动时的栈】
高地址 (Bottom of stack)
+--------------------------------+
| 环境变量字符串 "USER=..." |
| ... |
| 命令行参数字符串 "/bin/ls","-lt" |
+--------------------------------+
| envp[n] == NULL | <- %rdx 指向 envp[]
| ... |
| envp[0] --+ |
| argv[argc] = NULL | <- %rsi 指向 argv[]
| ... | |
| argv[0] --|--------------------+---> "/bin/ls"
+--------------------------------+
| 未来 main 的栈帧 |
+--------------------------------+
| libc_start_main 的栈帧 | <- argc 在 %rdi
低地址 (Top of stack)
【实测验证】 真实的交互与输出(命令通过管道喂入):
$ printf '/bin/echo hello from execve\n/bin/ls -d /tmp\n/tmp/c16/selfsig 2\n/tmp/c16/nosuchprog\n/bin/sleep 2\nquit\n' | ./minish
minish> hello from execve
[exit status 0]
minish> /tmp
[exit status 0]
minish> selfsig: pid=3578370 raising signal 2
[killed by signal 2]
minish> minish: /tmp/c16/nosuchprog: No such file or directory
[exit status 127]
minish> [exit status 0]
minish> bye
三种结局都覆盖到了:/bin/echo 正常退出(status 0)、selfsig 2 被 SIGINT 杀死(WIFSIGNALED 路径)、不存在的程序走 execve 失败路径(127,与真实 shell 的”命令未找到”约定一致)。注意:execve 不搜索 PATH,必须给出完整路径——这也解释了讲义示例里”必须写 /bin/ls 而不是 ls“。若要搜索 PATH,应使用包装函数 execl/execv/execvp(execvp 才会搜索 PATH)。
16.3.5 示例五:SIGCHLD 回收循环与竞态防护
代码 (C)(节选,Shell Lab 的核心骨架):
/* 信号处理程序:一次 waitpid 调用只能回收一个孩子,必须循环 */
static void sigchld_handler(int sig)
{
int olderrno = errno; /* 保存/恢复 errno */
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
if (WIFEXITED(status)) {
deletejob(pid); /* 正常终止:出队 */
} else if (WIFSIGNALED(status)) {
deletejob(pid); /* 被信号杀死:出队 */
} else if (WIFSTOPPED(status)) {
getjob(pid)->state = ST; /* 被停止:【留在】作业表中 */
}
}
errno = olderrno;
}
/* eval 中的经典竞态防护:先阻塞 SIGCHLD,fork,addjob,再解阻 */
sigset_t mask, prev;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev); /* ① 阻塞 */
pid_t pid = fork();
if (pid == 0) {
sigprocmask(SIG_SETMASK, &prev, NULL); /* 子进程必须解除继承的屏蔽 */
execve(argv[0], argv, environ);
_exit(127);
}
addjob(jobs, pid, bg ? BG : FG, cmdline); /* ② 登记 */
sigprocmask(SIG_SETMASK, &prev, NULL); /* ③ 解阻 */
if (!bg) waitfg(pid); /* ④ 前台作业:等它结束 */
else printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
【代码做什么?】 ① 阻塞 SIGCHLD;② fork;③ 父进程 addjob 登记;④ 解阻。此后每当有子进程终止或停止,内核递达 SIGCHLD,处理程序用 while ((pid = waitpid(-1, &status, WNOHANG\|WUNTRACED)) > 0) 循环回收所有已终止/停止的子进程——因为信号不排队,多个孩子同时死掉只会递达一次 SIGCHLD,只调一次 waitpid 会漏掉其余的僵尸。
【底层机制透视:为什么必须先阻塞】 若不加阻塞,存在这样一个窗口:
父进程 子进程
fork() ────────────────────────────────> 创建
│ ← 此处被内核抢占,父进程还没跑 addjob
│ exit(0) → 变成僵尸
│ 内核向父进程递达 SIGCHLD
│ sigchld_handler 运行:
│ waitpid(-1, ...) 回收了子进程
│ deletejob(pid) —— 但作业表里【根本没有这个 pid】!
│ (deletejob 找不到,静默失败)
│ addjob(pid, ...) ← 迟到的登记,把一个【已死】的 pid 写进作业表
v
结果:作业表里留下一个永远无法回收的幽灵条目,jobs 命令谎报"运行中"。
阻塞 SIGCHLD 让”创建-登记”对信号处理程序而言成为原子段,从而封死这个窗口。另两个细节同样关键:子进程要解除继承的屏蔽(否则 exec 后的程序收不到 SIGCHLD),处理程序要保存并恢复 errno。
【实测验证】 用三个”fork 完立刻退出”的子进程 + 一个自我停止的子进程验证,输出为:
$ ./reapdemo
[handler] pid 3576245 正常退出, exit status 10
[handler] pid 3576246 正常退出, exit status 11
[handler] pid 3576247 正常退出, exit status 12
[main] 等待中... nreaped=3
[handler] pid 3576248 被信号 20 停止(作业仍在表中)
[main] 全部回收完毕,nreaped=4
while 循环在一次 SIGCHLD 里连续回收了 3 个已终止的子进程;WUNTRACED 则让第 4 个被 SIGTSTP(20)停止的子进程也被报告出来——这正是 tsh 需要区分 ST(停止)与 BG(后台运行)状态的原因。
16.4 实验关联
L6 Shell Lab(tsh) 是本讲的直接落地。任务是在 tsh.c 中补全七个函数:eval(解析并执行命令行,约 70 行)、builtin_cmd(识别 quit/jobs/bg/fg,25 行)、do_bgfg(50 行)、waitfg(等前台作业完成,20 行)、sigchld_handler(约 80 行)、sigint_handler、sigtstp_handler(各 15 行)。评分:16 个 trace × 5 分 = 80 分正确性 + 10 分风格(注释 5 分,检查每一个系统调用的返回值 5 分)。
必须记住的三个实验要点:① sigint_handler/sigtstp_handler 里用 kill(-pid, sig) 发给整个前台进程组,sdriver.pl 专门测试这个错误;② waitfg 用忙等循环(while (fgpid(jobs) == pid) sleep(...)),所有回收集中在 sigchld_handler,不要两边都调 waitpid;③ 子进程在 fork 之后、execve 之前调用 setpgid(0, 0) 自立进程组,否则 Ctrl-C 会连同 shell 一起打死。讲义还提醒:不要在 tsh 里运行 more/less/vi/emacs,它们会乱改终端设置;请用 /bin/ls、/bin/ps、/bin/echo 测试。背景知识按顺序读 CS:APP 第 8 章即可,本章已覆盖 8.1–8.4,信号部分见 Lecture 17。
16.5 常见错误与调试技巧
- 僵尸进程堆积(zombie / defunct):子进程已终止但父进程未
wait,ps中显示<defunct>,占用 PID 与内核表项。调试:ps -o pid,ppid,stat,cmd -e \| awk '$3 ~ /Z/';修法是父进程显式回收(shell/服务器这类长命进程必须回收)。 fork后忘记exit,子进程继续跑父进程代码:同一段逻辑执行两遍、输出翻倍,甚至递归 fork 出进程树。调试:pstree -p <pid>、strace -f -e trace=clone。- 输出重复(stdio 缓冲区被 fork 复制):见 16.3.2。调试:把 stdout 重定向复现;
strace -f -e trace=write数 write 次数;修法是fflush+_exit。 execve不搜索PATH:写ls而不是/bin/ls会得到No such file or directory。调试:用execvp;或printenv PATH确认目录列表。- 只调一次
waitpid就以为收完了:信号不排队,多个子进程同时终止只会得到一次SIGCHLD。调试:处理程序里打日志计数,对照ps --ppid <shell_pid>剩余僵尸数;必须用while (...) > 0。 status未解码:直接读status得到0x2a00而不是 42。调试:始终用WIFEXITED/WEXITSTATUS;gdb 里p/x status看原始位。- Ctrl-C 连 shell 一起打死:子进程没
setpgid(0, 0),留在 shell 的前台进程组里。调试:ps -o pid,pgid,cmd -e \| grep tsh对比 PGID;handler 必须用kill(-pid, sig),并保存/恢复errno。 - Ctrl-C 连 shell 一起打死:子进程没
setpgid(0, 0),留在 shell 的前台进程组里。调试:ps -o pid,pgid,cmd -e \| grep tsh对比 PGID;handler 里必须用kill(-pid, sig)。
16.6 关键要点
- 进程 = 执行中程序的实例,它靠两个虚构抽象活得好:私有地址空间(虚拟内存)与逻辑控制流(上下文切换)。
- 异常是”控制流的突变”,四类各有返回纪律:中断与陷阱返回到 $I_{next}$,故障重执行 $I_{current}$ 或终止,终止永不返回。
syscall是”有意的陷阱”,是用户程序接触外部世界的唯一合法通道。 - 充分的隔离需要硬件特权级:模式位把用户进程挡在页表、设备与特权指令之外,
/proc与/sys是留给用户态的只读窗口。 fork复制地址空间(写时复制),execve覆盖地址空间(保留 PID)。二者组合是所有 shell 的全部秘密:fork+execve+waitpid。- “创建资源 + 登记资源”的两步操作都要考虑信号竞态:阻塞信号 →
fork→addjob→ 解阻,顺序不能颠倒。同一模式在并发编程里会以”锁的顺序”再次出现(见 Lecture 21–22)。 - 长命进程必须回收子进程,否则僵尸会把 PID 耗尽——shell 与服务器是仅有的两类”必须显式回收”的程序。
16.7 思考题(带答案)
题 1(推演题) 下面是 recitation 中的一段代码。请写出所有可能的输出行数与内容,并指出哪些输出顺序是确定的:
int main(void) {
int status;
if (fork() == 0) {
pid_t pid = fork();
printf("Child: %d\n", getpid());
if (pid == 0) exit(0);
}
pid_t pid = wait(&status);
printf("Parent: %d\n", pid);
exit(0);
}
答:进程图有三个叶子(原始父 P、子 C、孙 G)。printf("Child") 会被 C 和 G 各执行一次,printf("Parent") 会被 P 和 C 各执行一次——因为 C 在 fork 之后没有 exit,会继续落到下面的 wait/printf。所以恰好 4 行输出:两行 Child:、两行 Parent:。确定的约束是:① Child: <C的pid> 一定在 C 的 Parent: <G的pid> 之前(C 先 printf 再 wait);② P 的 Parent: <C的pid> 一定在 Child: <C的pid> 之后(P 通过 wait 等到 C 终止);③ 两个 Child 行之间、两个 Parent 行之间的相对次序都自由。两种可行交错是:Child(C), Child(G), Parent(C), Parent(P) 与 Child(C), Parent(C), Child(G), Parent(P);而 Child(G) 出现在 Child(C) 之前不可行(recitation 的答案正是这两种)。
题 2 小明说:”我按 Ctrl-C 之后 shell 也退出了,所以 tsh 应该把 SIGINT 的默认动作设成 SIG_IGN 来避免被打死。” 这个想法错在哪?
答:错在根因判断。默认情况下 shell 创建的子进程与 shell 同属一个前台进程组,Ctrl-C 由终端驱动发给整个前台进程组,因此 shell 与子进程都被打到——问题出在进程组划分,不在信号处理。正确做法是子进程在 fork 之后、execve 之前 setpgid(0, 0) 自立门户,shell 自己安装 handler 捕获 SIGINT,再 kill(-pid, SIGINT) 转发给前台作业。若改成 SIG_IGN,shell 确实不死了,但 Ctrl-C 会彻底失效,且 SIG_IGN 会被 execve 后的程序继承,行为完全失控。
题 3(计算题) 某长命服务器每秒 fork 一个子进程,子进程 10 ms 后退出,而服务器从不调用 wait。假设 PID 空间为 $32768$ 个且被僵尸占满后无法再创建新进程。问到系统无法 fork 需要多久?(忽略其他进程)
答:每秒产生一个僵尸且永不复用,僵尸数随时间线性增长。需要 $32768$ 个僵尸,即约 $32768$ 秒 ≈ 9.1 小时。注意它不取决于子进程的执行时间(10 ms),只取决于创建速率与 PID 空间大小;实际 Linux 的 pid_max 默认是 $4194304$,对应约 48.5 天——但只要有别的进程也在消耗 PID,耗尽会快得多。结论:长命进程必须回收子进程。