第8章:进程上下文——谁在运行你的代码
┌─────────────────────────────────────────────────────────────┐
│ 元数据卡 │
│ ├─ 主题:进程上下文 │
│ ├─ 难度:★★★★☆ │
│ ├─ 前置要求:第7章(异常与系统调用) │
│ ├─ 核心概念:进程生命周期、PCB、fork/exec/wait、COW、信号 │
│ └─ 预计阅读:40 min │
└─────────────────────────────────────────────────────────────┘你在哪
"穿过内核态的边界后,你发现 CPU 不是只运行你的程序。它同时运行着几十个进程,像时间魔术师一样在它们之间切来切去。你站在地心的调度中心,看着进程们在 CPU 上轮流登场。"
你写了一个简单的程序:
#include <stdio.h>
int main() {
printf("Hello, world!\n");
return 0;
}你双击它,就"运行"了。但你有没有想过——
"运行"到底是什么意思?
进程和程序是一回事吗?两个终端运行同一个可执行文件是同一个进程吗?打开 Chrome,打开了十几个"窗口",这些是一个进程还是几十个?为什么程序退出后PID还不消失——僵尸进程到底是什么?
这一章,我们来拆解进程这个现代操作系统最核心的抽象。
你的任务
本章分层
- 必读:程序 vs 进程的根本区别;进程的完整生命周期;PCB(进程控制块)的概念
- 选读:
fork()的写时复制(COW)原理;exec()族函数的作用- 深水区:僵尸进程与孤儿进程的成因及处理;
clone()系统调用 flags 本章不会要求你掌握clone()的所有 flags 细节- cgroup 和 namespace 的完整配置
- Linux 调度器(CFS)的具体实现
学完本章后,你应该能:
- 画出进程的完整生命周期(创建 → 就绪 → 运行 → 阻塞 → 终止)
- 说明
fork()的返回值为什么能区分父子进程 - 解释写时复制(COW)为什么能高效创建子进程
- 解释僵尸进程和孤儿进程的成因和解决方案
- 说明
exec()族函数做了什么——不创建新进程但替换了进程上下文 - 追踪环境变量的传递路径
遭遇战——程序 vs 进程
先搞清楚最基础的概念:
程序(Program) 进程(Process)
───────────── ──────────────
静态文件(磁盘上的二进制) 动态实体(内存中的执行环境)
不消耗CPU 消耗CPU + 内存
一个程序可以 → N个进程 一个进程只能对应 ← 一个程序
就像菜谱 就像一次烹饪操作你打开两个终端,都运行 ./hello:
# 终端1
$ ./hello &
[1] 12345
# 终端2
$ ./hello &
[2] 12346两个进程,同一个程序。 PID 不同,地址空间不同,PC 寄存器不同,栈不同。它们不知道彼此的存在。这就是进程作为"假 CPU"的魔力——每个进程都以为自己拥有了整个 CPU。
PCB——进程的身份证
操作系统用一个叫 进程控制块(Process Control Block, PCB) 的结构记录进程的一切。
在 Linux 中,PCB 就是 task_struct——一个超过 200 个字段的结构体,定义在 include/linux/sched.h:
// 简化版本——真实 task_struct 约 1000+ 行
struct task_struct {
/* 进程标识 */
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID
/* 状态 */
volatile long state; // TASK_RUNNING / TASK_INTERRUPTIBLE 等
/* 调度信息 */
int prio;
unsigned int time_slice;
/* 内存管理 */
struct mm_struct *mm; // 用户地址空间
struct mm_struct *active_mm; // 内核态使用的 mm
/* 文件系统 */
struct fs_struct *fs;
struct files_struct *files; // 打开的文件描述符表
/* 信号 */
struct signal_struct *signal;
/* 父子关系 */
struct task_struct *parent;
struct list_head children;
struct list_head sibling;
/* 等等超过 200 个字段... */
};💡 洞见: "进程"这个抽象的本质就是 PCB + 地址空间。没有 PCB,你的代码只是一堆躺磁盘上的字节。有了 PCB,操作系统才能追踪"谁在运行、谁在等什么、谁的资源是什么"。
你的任务(续)——进程状态机
进程在生命周期中会经历几种状态:
┌─────────────────────────────┐
│ 创建 (Created) │
│ fork() 返回的瞬间 │
└─────────────┬───────────────┘
│ 就绪队列入队
▼
┌─────────────────────────────┐
┌───│ 就绪 (Ready) │
│ │ 等待 CPU 调度 │
│ └─────────────┬───────────────┘
│ │ 调度器选中
│ ▼
│ ┌─────────────────────────────┐
│ │ 运行 (Running) │ ◄── 这是你代码在 CPU
│ │ 正在执行指令 │ 上真正执行的唯一状态
│ └──────┬──────────────┬───────┘
│ │ │
│ 时间片用完 等待I/O/事件
│ │ │
│ ▼ ▼
│ ┌──────────┐ ┌──────────────┐
└───│ Return │ │ 阻塞 (Blocked) │
│ │ │ 等磁盘/等网络 │
└──────────┘ └──────────────┘
│ I/O完成
▼
┌─────────────────────────────┐
│ 终止 (Terminated) │
│ 僵尸状态(等父进程收尸) │
└─────────────────────────────┘关键点:一次切换就是一个"上下文切换"——保存当前进程的寄存器、PC、栈指针、页表,加载下一个进程的。这就是 perf 里看到的 context-switches。
获得技能——fork() 的魅力
💡 操作系统视角:进程是现代操作系统最核心的抽象。以下用 Unix/Linux 的
fork/exec/wait模型来说明——这是理解进程最直观的路径。如果你熟悉 Windows 的CreateProcess或 Java 的ProcessBuilder,它们在概念上是等价的,只是 API 不同。
fork:一次调用,两次返回
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
/* 子进程 */
printf("[子] 我的 PID = %d,父 PID = %d\n",
getpid(), getppid());
} else if (pid > 0) {
/* 父进程 */
printf("[父] 子进程 PID = %d,我的 PID = %d\n",
pid, getpid());
} else {
perror("fork 失败");
}
return 0;
}编译运行:
[父] 子进程 PID = 28374,我的 PID = 28373
[子] 我的 PID = 28374,父 PID = 28373注意 fork() 只调用了一次,却返回了两次。这是怎么做到的?
fork 的内部机制
- 父进程调用
fork()——进入内核 - 内核执行
copy_process():- 分配新的 PID 和 PCB(
task_struct) - 复制父进程的地址空间——但这里用了一个极其精妙的优化(见下文)
- 复制打开的文件描述符、信号处理器等
- 初始化子进程的内核栈(包含父进程返回地址的副本)
- 分配新的 PID 和 PCB(
- 在子进程的内核栈上,把返回值 (
rax) 设为 0 - 在父进程的内核栈上,把返回值设为子进程的 PID
- 两个进程都返回到用户态
所以实际上,子进程和父进程回到的是同一个 printf 行——但子进程的 rax 里是 0,父进程的 rax 里是 PID。
写时复制(Copy-on-Write, COW)
传统做法是 fork() 直接复制整个地址空间。但大部分 fork() 后紧接着 exec()——旧地址空间全部作废。如果每次都完整复制,简直是浪费。
Linux 的做法:
fork()时,父进程的页表被复制给子进程,但所有页都标记为只读- 父子进程共享相同的物理页
- 当其中一方试图写入时,触发缺页故障
- 内核分配新物理页,复制内容,更新页表
- 然后重试写指令
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int *shared = malloc(sizeof(int));
*shared = 42;
pid_t pid = fork();
if (pid == 0) {
/* 子进程——此时 shared 指向同一物理页(只读) */
printf("[子] 修改前: %d\n", *shared);
*shared = 100; /* 💥 触发 COW!分配新页 */
printf("[子] 修改后: %d (子进程地址: %p)\n",
*shared, (void*)shared);
} else {
/* 父进程——shared 仍然指向原物理页(仍只读) */
wait(NULL);
printf("[父] 子修改后我看: %d (父进程地址: %p)\n",
*shared, (void*)shared);
}
free(shared);
return 0;
}典型输出:
[子] 修改前: 42
[子] 修改后: 100 (子进程地址: 0x5555555592a0)
[父] 子修改后我看: 42 (父进程地址: 0x5555555592a0)看!地址相同(虚拟地址),值不同(物理地址不同)。这就是 COW 的魔法——同一份虚拟地址映射到了不同的物理页。
💡 洞见: COW 的核心哲学是"拖延"——尽一切可能不复制,直到不得不写。这是操作系统设计中反复出现的模式:缺页时再加载、写时再复制、懒绑定。
常见陷阱——exec、wait、僵尸
💡 Unix 模型说明:
fork + exec是 Unix 经典范式。Windows 用CreateProcess一步创建新进程;Java 用ProcessBuilder.start()创建操作系统进程(底层依赖平台 API,Windows 上就是CreateProcess)。
exec()——不创建新进程,但替换一切
fork() 复制了一个几乎一样的进程。exec() 做的事情正好相反:它不创建新进程,但把当前进程的整个用户地址空间扔掉,加载一个新的程序进去。
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
/* 子进程 — 变成 "ls" */
execlp("ls", "ls", "-l", NULL);
/* 只有 execlp 失败才会走到这里 */
perror("exec 失败");
return 1;
}
/* 父进程等子进程结束 */
wait(NULL);
printf("子进程已结束\n");
return 0;
}运行:
$ ./a.out
total 32
-rwxr-xr-x 1 user user 16712 Jun 20 14:32 a.out
子进程已结束关键理解:
fork()后子进程的 PID 不变execlp()把子进程的地址空间、栈、堆全部清空,加载ls的代码和数据- PID 不变,PCB 仍然活着,因为打开的文件描述符、信号处理设置等可以有选择地继承
// exec 后 PID 保持不变
printf("PID 没变: %d\n", getpid()); // 跟 fork 之前一样僵尸进程——活着但死去的进程
一个进程终止后,内核不会立刻完全清理它的 PCB。因为父进程可能需要知道它的退出状态。
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("[子] 我马上退出\n");
return 42; /* 子进程退出码 */
}
/* 父进程——不调用 wait,直接去睡觉 */
printf("[父] 子 PID = %d,我睡 30 秒不收尸...\n", pid);
sleep(30);
/* 现在收尸 */
int status;
wait(&status);
printf("[父] 子进程退出码: %d\n", WEXITSTATUS(status));
return 0;
}在另一个终端上运行 ps:
# 在父进程 sleep 的 30 秒内
$ ps aux | grep a.out
steven 28373 0.0 0.0 4340 720 pts/0 S+ 14:35 0:00 ./a.out
steven 28374 0.0 0.0 0 0 pts/0 Z+ 14:35 0:00 [a.out] <defunct>看到 Z+ 和 <defunct> 了吗?这就是僵尸进程。
# 看 status 文件
$ cat /proc/28374/status
Name: a.out
State: Z (zombie)
Pid: 28374
...僵尸的本质:进程已终止(释放了所有内存、关闭了所有文件描述符),但 PCB 还留在内核里,等父进程来 wait() 读走退出状态。
孤儿进程——父进程先死了
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
/* 子进程 */
printf("[子] 父 PID = %d\n", getppid());
sleep(5);
printf("[子] 5秒后父 PID = %d\n", getppid());
/* 如果父进程已经在 5 秒内退出了... */
} else {
/* 父进程立即退出 */
printf("[父] 我先走了\n");
return 0;
}
return 0;
}运行:
[父] 我先走了
[子] 父 PID = 28373
[子] 5秒后父 PID = 1子进程的父 PID 变成了 1——它被 init 进程(systemd) 收养了。这就是孤儿进程的处理方式。
💡 实战规则:
- 创建子进程后,父进程必须
wait()/waitpid(),否则子进程变成僵尸- 如果父进程不关心退出状态,可以注册
SIGCHLD处理函数- 善用
signal(SIGCHLD, SIG_IGN)—— Linux 会自动清理僵尸- 或者更推荐的方式:
sigaction+SA_NOCLDWAIT
通关挑战——完整的多进程模式
组合 fork + exec + wait,写一个微型 shell:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#define MAX_CMD 256
int main() {
char cmd[MAX_CMD];
while (1) {
printf("$ ");
fflush(stdout);
if (!fgets(cmd, MAX_CMD, stdin))
break;
/* 去掉换行符 */
cmd[strcspn(cmd, "\n")] = '\0';
if (strcmp(cmd, "exit") == 0)
break;
pid_t pid = fork();
if (pid == 0) {
/* 子进程:执行命令 */
execlp("/bin/sh", "sh", "-c", cmd, NULL);
perror("exec 失败");
exit(1);
} else if (pid > 0) {
/* 父进程:等待子进程 */
int status;
wait(&status);
printf("退出码: %d\n", WEXITSTATUS(status));
} else {
perror("fork 失败");
}
}
return 0;
}运行测试:
$ echo "Hello from a subprocess"
Hello from a subprocess
退出码: 0
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
退出码: 2
$ exit这就是 Shell 最原始的原理。你每天打的每一条命令,都经历了一次 fork + exec + wait 的完整旅程。
验收标准
读完本章后,请确认你能回答:
- 程序和进程的核心区别是什么?
fork()为什么能返回两次?子进程的返回路径是什么?- 写时复制是在复制什么?什么时候才真的复制?
- 僵尸进程和孤儿进程分别是怎么产生的?怎么避免僵尸?
exec()不创建新进程到底是什么意思?创建了还是没创建?
常见卡点
| 卡点 | 真相 |
|---|---|
| "子进程从 main 开头执行" | 子进程从 fork() 返回处继续,和父进程的 PC 位置相同 |
| "fork 复制了一切" | 真实实现是 COW,虚拟地址空间共享物理页 |
| "exec 创建了新进程" | exec 只是替换内容,PID、文件描述符等会继承 |
| "僵尸进程很可怕" | 只是一个很小的 PCB 残留,但大量僵尸会导致 PID 耗尽 |
| "我可以不用管子进程" | 不管 = 僵尸。注册 SIG_IGN 或 wait |
现在不需要理解
- Linux 调度器(CFS)的具体实现 —— 第 10 章调度会深入
- cgroup 和 namespace 细节 —— 容器技术专题
clone()系统调用的所有 flags —— 线程实现时再讲procfs的完整文件系统结构 —— 需要时查手册
旅人笔记
你知道"进程"这个词让我困惑了很久吗?程序嘛,双点一下就能跑。但"进程"好像一个看不见的东西,教科书说是"运行中的程序实例"——但这到底是个什么玩意儿?
直到我开始学操作系统,才真正看到它的面目:进程就是操作系统把 CPU 和内存包装成的一个"盒子"。每个进程一个盒子,盒子里有自己的地址空间、自己的寄存器、自己的栈、自己的文件。盒子之间互不相见。
而
fork()是复制这个盒子——但不是真的全复制,而是先共享物理内存,谁写谁复制(COW)。
exec()是把盒子里的东西全倒掉,装进新的程序。
wait()是父进程等着收子进程的盒子,读完退出状态后,内核才把 PCB 彻底销毁。这就像一家餐厅接单——后厨(内核)收到一个新订单(fork),把菜单复制一份贴墙上(PCB),然后厨师开始照着新菜单做菜(exec)。做好了出餐(exit),服务员把订单勾掉(wait),残页扔掉(释放PCB)。
当你明白每一行
printf、每一次回车命令背后都跑过这么一圈——你就会对这个"莫名其妙就能跑"的世界,多一分敬意。
→ 下一站预告
第9章:线程与同步——分身与协作
进程切换太慢了——每次都要保存和加载整个地址空间。更轻量的选择出现了:线程。同一进程内的多个线程共享内存,协作完成任务。但共享带来了新的问题——锁、竞争、同步——下一章进入并发的世界。