操作系统学习笔记 · 第 7 课 · fork 与 exec——进程的诞生与变身
上一课结尾我留了个问题:你在 shell 里敲下ls,那个ls进程是怎么"变"出来的?答案是一对黄金搭档——fork 和 exec。一个负责"复制",一个负责"变身"。这一课,我们把这对搭档彻底拆开。
从"敲下 ls 的瞬间"说起
你敲下 ls -l,回车。屏幕上列出了一堆文件。
你有没有想过:ls 是一个躺在磁盘上的程序,它是怎么变成一个正在运行的进程的?难道 shell 自己"变身"成 ls 了吗?
不完全是。 真相是两步走:
- shell 先
fork——复制出一个和自己一模一样的子进程; - 这个子进程再
exec——变身成ls,从头执行ls的代码。
这就是贯穿整个 Unix 世界的黄金组合:fork + exec。这一课,我们把这两步掰开揉碎。
7.1 fork:复制出一个"双胞胎"
fork 这个词直译就是"分叉"。它的行为非常奇妙,一句话概括:
fork() 调用一次,却返回两次——在父进程里返回子进程的 PID,在子进程里返回 0。之后父子各走各的路。调用一次、返回两次,这在编程里几乎独一无二。它意味着:调用 fork() 之后,一个进程变成了两个,而且两个进程从"调用 fork() 的下一行"开始,各自继续往下执行。
怎么区分谁是父、谁是子?靠返回值:
pid_t pid = fork();
if (pid == 0) {
// 我是子进程(fork 在子进程里返回 0)
} else {
// 我是父进程(fork 在父进程里返回的是子进程的 PID)
}生活类比一下:你复印一份工作手册,然后两个人各拿一份,从同一页继续往下做,做各自不同的章节。 复印的那一刻就是 fork——材料一样,但之后分道扬镳。
7.2 exec:进程"变身"
fork 只是"复制",复制出来的子进程还和父进程一模一样(跑的是同一份代码)。那它怎么变成 ls 的呢?靠 exec。
一句话理解:exec 不新建进程,而是把当前进程的内存整个替换成另一个程序的代码,然后从头执行它。注意这个关键区别:
fork是"多一个进程"(一个变两个);exec是"换一个程序"(进程还是那个进程,但里面的代码全换了)。
exec 之后,原来进程里的代码就不再执行了(被覆盖了),除非 exec 调用本身失败。所以你看实验代码里那行:
perror("exec 失败"); // 只有 exec 失败才会走到这如果 exec 成功,这行永远不会执行——因为进程已经被 ls 的代码接管了。
那 shell 为什么不能"直接 exec 成 ls"?因为那样的话,shell 自己就没了(被 ls 覆盖了),ls 跑完,你的终端就关闭了。所以必须先 fork 出一个"替身",让替身去 exec,shell 自己还活着。
为什么 fork 和 exec 要分开?因为中间那段(fork 之后、exec 之前)是个宝贵的"窗口期":你可以在这个窗口里改 fd、改环境变量、改权限——这正是实现重定向和管道的时机。下一节就是证据。
7.3 fork + exec 实现重定向/管道
还记得第 6 课说的重定向本质吗——"> 是换掉 fd 1 的指向"。现在我们把 fork、exec、重定向串起来,看 ls > out.txt 到底是怎么做到的:
- shell
fork出一个子进程; - 子进程把 fd 1 关掉、重新指向
out.txt文件(换号码牌); - 子进程
exec成ls; ls老老实实往 fd 1 写,数据就进了out.txt。
整个过程,ls 一行代码都没改。 重定向的魔法,全靠"fork 之后的窗口期改 fd" + "exec 换程序"。
管道 ls | wc 也是同一套底座,只是更绕一点:
- shell
fork出两个子进程; - 子进程 A 把 fd 1 指向管道写端,
exec成ls; - 子进程 B 把 fd 0 指向管道读端,
exec成wc; - 于是
ls的输出,顺着管道流进了wc的输入。
一句话理解:ls | wc 的流程——shell fork 两次,改两个子进程的 fd,再各自 exec。fork+exec 是重定向和管道的共同底座。第 8 课讲管道时会再展开。7.4 写时复制(COW):fork 为什么这么快
你可能会有个直觉上的担忧:fork 要复制整个进程的内存,那 fork 一个占了几 GB 内存的大进程,岂不是慢死了?
答案是:Linux 用了一个聪明到极点的优化——写时复制(Copy-On-Write,COW)。
一句话理解:fork 后父子先共享同一份内存(不真的复制),谁要"写"(改),才给谁单独复制一份。省时又省内存。
具体是这样:
fork时,不复制任何页面,只是把内存页标记为"共享、只读";- 父子俩继续读——读的是同一份,没问题;
- 一旦某个进程要写某个页面,CPU 触发一个异常,内核才临时给这个进程单独复制一份,让它去改;
- 没被改的页面,继续共享。
这样一来,fork 一个超大进程,可能只要毫秒级——因为内存根本没真复制,只是打了个"共享、写时再分"的标记。
还记得第 3 课的"缺页异常"、第 4 课的"COW 伏笔"吗?到这儿就全串起来了:写时复制正是靠"异常"机制实现的——"你要写共享页了?好,触发异常,我来给你分一份。" 操作系统里,处处都是这样环环相扣的设计。
动手实验:C 版 + Python 对照
C 版:
// lesson07.c —— fork + exec 实现"运行一条命令"
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
// 子进程:变身成 ls 命令
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
perror("exec 失败"); // 只有 exec 失败才会走到这
return 1;
} else {
// 父进程:等子进程结束
wait(NULL);
printf("父进程:子进程跑完了\n");
}
return 0;
}编译运行:
gcc lesson07.c -o lesson07 && ./lesson07预期:先看到 ls -l 的输出(子进程 exec 成 ls 干出来的),最后打印"父进程:子进程跑完了"。
Python 对照版:
import os, subprocess
pid = os.fork()
if pid == 0:
# 子进程:替换成 ls
os.execvp("ls", ["ls", "-l"])
else:
os.wait()
print("父进程:子进程跑完了")
# 更常用:直接用 subprocess 封装(它内部就是 fork + exec)
subprocess.run(["ls", "-l"])Python 里 os.fork + os.execvp 和 C 版一一对应。不过日常你更常用的,是最后那行 subprocess.run(...)——它把"fork + exec + wait"整条链路打包好了。知道它底层是什么,你就不会把它当成"黑魔法"了。
深入点:收尸、以及 exec 家族
① 僵尸进程与 wait(正式点题)
第 4 课埋过"僵尸进程"的雷,这里正式解开。子进程结束之后,它的退出状态、运行统计这些"临终信息"会留在内核里,等父进程来领。父进程领取的动作,就是调用 wait。
- 如果父进程一直不
wait,子进程就卡在"僵尸(Z)"状态——不占 CPU、不占内存,但占着 PID; - 父进程调用
wait,就是"收尸"——把子进程的临终信息领走,僵尸才彻底消失。
所以我们的实验代码里,父进程那句 wait(NULL) 不是可有可无的——它是负责任的父母该做的事。
② exec 家族的区别
exec 其实是一大家子,常见的有 execl、execv、execvp、execle……后缀有讲究:
| 后缀 | 含义 |
|---|---|
l(list) | 参数用一个个单独传:execl("/bin/ls", "ls", "-l", NULL) |
v(vector) | 参数用数组传:execv("/bin/ls", argv) |
p(PATH) | 会自动去 PATH 里找程序,不用写全路径 |
e(env) | 可以额外指定环境变量 |
我们实验用的 execvp = v(数组传参)+ p(去 PATH 找)。记住"l 是列出来、v 是数组、p 是 PATH、e 是环境"就够用了。
小结与思考题
这一课,我们破解了"进程是怎么诞生和变身"的:
- fork:调用一次返回两次,一个进程变两个,靠返回值(子进程 0 / 父进程子 PID)区分;
- exec:不新建进程,而是把当前进程内存替换成新程序,从头执行;
- fork + exec 是 shell 跑命令的标准姿势,也是重定向/管道的底座——fork 之后的窗口期改 fd,正是实现重定向的时机;
- 写时复制(COW):fork 不真复制内存,谁写才给谁复制,让 fork 快得惊人。
留三个问题:
fork()返回几次?父子各自的返回值是多少?- 为什么 shell 执行命令要用 fork + exec,而不是直接 exec?
- 写时复制解决了什么性能问题?
下一课,我们要认识进程之间"打招呼"的方式——信号。你在终端按Ctrl+C中断程序、用kill -9强制杀进程,背后都是它在起作用。它和上一课那些"异常"又是什么关系?我们下回分解。