本篇位置 承接第 2 篇。第 2 篇说进程是什么,这一篇说它怎么被造出来
运行环境 容器里的 Linux(见总览页),Python 3

一、如果让你来设计,你会怎么写这个接口

任务:让操作系统给你开一个新进程去跑 /bin/ls

十个人里有九个会设计成这样:

新进程 = 创建进程("/bin/ls", ["-l"])

一步到位,参数清楚。Windows 的 CreateProcess 差不多就是这样(虽然它有十个参数)。

UNIX 不是。UNIX 拆成了两步,而且这两步单看每一步都很怪:

fork()   —— 复制一个我自己出来,一模一样的
exec()   —— 把我自己整个换成另一个程序

“复制一个我自己”?“把我自己换掉”?听起来完全不像是在创建进程。

这一篇要说服你:这个别扭的设计是对的。 但得先看清楚这两个调用各自在干什么。

二、fork:一次调用,两次返回

# forkret.py
import os

print("---- fork 之前,只有一个进程 ----", flush=True)
rc = os.fork()
print(f"fork 返回了 {rc}   (我的 pid 是 {os.getpid()})", flush=True)
if rc == 0:
    print("  我是子进程:fork 给我的返回值是 0", flush=True)
else:
    print(f"  我是父进程:fork 给我的返回值是子进程的 pid {rc}", flush=True)
    os.wait()
---- fork 之前,只有一个进程 ----
fork 返回了 10   (我的 pid 是 9)
  我是父进程:fork 给我的返回值是子进程的 pid 10
fork 返回了 0   (我的 pid 是 10)
  我是子进程:fork 给我的返回值是 0

第一行只出现了一次,第二行出现了两次。

因为 fork 之前只有一个进程,之后有两个。而这两个进程都从 fork 的下一行接着往下跑——子进程不是从 main 开头开始的,它是从"fork 刚刚返回"这个位置开始的。

⭐ 这就是那句有名的话:fork 调用一次,返回两次。 一次返回在父进程里(返回值是子进程的 pid),一次返回在子进程里(返回值是 0)。

子进程拿到的是父进程的一份副本:一样的地址空间内容、一样的寄存器、一样打开的文件。唯一不同的是 pid,和 fork 的返回值。

⚠️ 注意上面输出的顺序。父进程那两行先出来,子进程后出来。你在自己机器上跑,顺序可能反过来。你的代码里没有任何一行决定了这个顺序——决定它的是调度器。这是第 1 篇那个"漏点"的又一次现身,也是并发 bug 的第一个来源。

三、exec:把自己整个换掉

# exec_demo.py
import os, sys

rc = os.fork()
if rc == 0:
    print("子进程:马上要 exec 了", flush=True)
    os.execvp("echo", ["echo", "子进程:我现在是 echo,不是 python 了"])
    print("子进程:这一行永远不会被打印", flush=True)   # exec 成功就回不来了
else:
    os.wait()
    print("父进程:子进程结束了,我还是 python", flush=True)
子进程:马上要 exec 了
子进程:我现在是 echo,不是 python 了
父进程:子进程结束了,我还是 python

“这一行永远不会被打印”——它真的没被打印。

因为 exec 不创建新进程。它把当前进程的代码段、数据段、堆、栈全部扔掉,换成新程序的,然后从新程序的入口开始跑。

pid 没变,进程还是那个进程。但里面的东西全换了,所以没有"exec 之后"这回事——exec 成功了就再也回不来。它只在失败的时候才返回。

四、⭐ 那道缝,才是分家的理由

现在可以回答开头的问题了:为什么不做成一步?

因为一步的设计里,没有地方插手。

forkexec 之间有一小段代码是可以执行的。在那一小段里,你已经有了一个新进程,但它还没变成目标程序。这时候你对它做的任何改动,都会被新程序继承。

这道缝,就是 shell 实现重定向和管道的地方:

# redirect.py
import os, sys

rc = os.fork()
if rc == 0:
    # ↓↓↓ 这几行发生在 fork 之后、exec 之前 —— 那道"缝"
    fd = os.open("/tmp/out.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    os.dup2(fd, 1)          # 把 1 号(标准输出)换成这个文件
    os.close(fd)
    # ↑↑↑ 换完信箱,再让他出门
    os.execvp("ls", ["ls", "-1", "/etc/hostname", "/etc/hosts"])
else:
    os.wait()
    print("子进程跑完了。它的输出去哪了?")
    print("--- /tmp/out.txt 的内容 ---")
    sys.stdout.write(open("/tmp/out.txt").read())
子进程跑完了。它的输出去哪了?
--- /tmp/out.txt 的内容 ---
/etc/hostname
/etc/hosts

这就是 ls > out.txt 的全部实现。

注意 ls 这个程序完全不知道自己被重定向了。它照常往 1 号描述符写。是那道缝里的 dup2 把 1 号换掉了。

打个比方

fork + exec 就像找人替你出门办事

  • fork:先克隆一个你自己出来。他现在跟你一模一样——一样的记忆、一样的口袋、一样的门钥匙。
  • 那道缝:趁他还是"你"的时候,你可以改他身上的东西——把他的信箱地址换掉、把某把钥匙从他口袋里拿走。
  • exec:让他换身衣服、变成一个只会办某件事的人,出门去。

改信箱这一步,必须发生在他还是"你"的时候。 等他变成快递员出门了,你就够不着他了。这就是为什么必须分两步——一步到位的 CreateProcess 想做重定向,只能把这些需求全塞成参数(这也正是它有十个参数的原因)。

五、wait:收尸不是礼貌,是义务

进程结束的时候,它的退出码得有人来收。在没人收之前,内核不能把这个进程的记录删掉——万一父进程等会儿要问呢。

于是有了僵尸进程:程序已经跑完了,但记录还挂在那儿。

# zombie.py
import os, time

def state_of(pid):
    d = open(f"/proc/{pid}/stat").read()
    return d[d.rindex(")") + 2]

pid = os.fork()
if pid == 0:
    os._exit(42)

time.sleep(0.3)
print(f"子进程 {pid} 已经调用了 exit,但父进程还没 wait")
print(f"  /proc/{pid} 还在吗:{os.path.exists(f'/proc/{pid}')}")
print(f"  内核报告的状态:{state_of(pid)}")

_, status = os.wait()
print(f"父进程 wait 了,收到退出码 {os.WEXITSTATUS(status)}")
print(f"  /proc/{pid} 还在吗:{os.path.exists(f'/proc/{pid}')}")
子进程 12 已经调用了 exit,但父进程还没 wait
  /proc/12 还在吗:True
  内核报告的状态:Z
父进程 wait 了,收到退出码 42
  /proc/12 还在吗:False

看这三行证据:

  1. 子进程已经执行完了 os._exit(42),代码一行不剩。
  2. /proc/12 还在,内核报的状态是 Z(zombie)。
  3. 父进程 wait 之后拿到退出码 42,同一瞬间 /proc/12 消失了

⚠️ 僵尸不占 CPU,也不占内存——它只占一个 pid 和一小块 task_struct。听起来无害,但 pid 是有限的(/proc/sys/kernel/pid_max)。一个长期运行、不停 fork 又从不 wait 的服务,最后会把 pid 用光,然后整台机器谁也 fork 不了了。

那父进程要是自己先死了呢?子进程会被过继给 pid 1(init)。init 的一个主要职责,就是无限循环地 wait,替所有没人管的孤儿收尸。 你在容器里跑东西时 pid 1 是你的主进程——如果它不 wait,就没人替你收了。

六、信号:从外面拍一个进程的肩膀

到这里,这套接口还缺一块。

forkexecwait 全都是你对自己和自己孩子做的事。但有些事必须从外面发起:

  • 你按了 Ctrl-C,凭什么能停掉一个正在跑的程序?
  • kill 命令是怎么让一个进程退出的?
  • 更根本的——第 4 篇那个执行特权指令被 SIGILL 打死的程序、第 10 篇那个写只读内存收到 SIGSEGV 的程序,内核到底是怎么"告诉"它的

这三个问题是同一个答案:信号(signal)。

信号是内核送给一个进程的一条极短的通知——短到只有一个编号,没有任何附带数据。

它是这门课里唯一一条"从外面进来"的路。前面所有的控制流都是进程自己发起的(调用、陷入、返回),只有信号是别人塞给你的。

打个比方

前面几节里,进程一直是个闷头干活的人fork 是他自己分身,exec 是他自己换工作,wait 是他自己等孩子。

信号是有人从背后拍他肩膀。

  • 拍肩的可以是内核(“你刚才那条指令非法”),也可以是别的进程kill),还可以是你按的键(Ctrl-C)。
  • 拍完就走,不留话——你只知道"被拍了第 9 下"还是"被拍了第 2 下",没有更多信息。
  • 你可以事先说好被拍某一下时该干什么(装一个处理函数),也可以说"这一下我不理"(忽略)。
  • 但有两下是拍不得商量的:第 9 下(SIGKILL)和第 19 下(SIGSTOP)。内核不允许你对它们做任何安排。

跑一遍看看:能挡的和挡不住的

装一个 SIGTERM 的处理函数,让子进程"收到但不死",然后再发 SIGKILL

def on_term(signum, frame):
    os.write(1, "      [子进程] 我收到了 SIGTERM,但我选择不死\n".encode())
signal.signal(signal.SIGTERM, on_term)
  父进程发 SIGTERM(可以被捕获)
      [子进程] 我收到了 SIGTERM,但我选择不死
  发完 SIGTERM,子进程还活着吗:True

  父进程发 SIGKILL(内核不允许任何人捕获它)
  子进程还活着吗:False
  内核报告:被信号 9 杀死(SIGKILL = 9),shell 看到的退出码是 137

SIGTERMSIGKILL 的区别,全部在这里:一个是请求,一个是命令。

SIGTERM 是"请你退出"——进程可以捕获它,趁机把缓冲区刷干净、把连接关掉、把锁放开,再体面地退出。这叫优雅关闭SIGKILL 是"你现在就死"——进程连一行代码都不会再执行,是内核直接把它从进程表里摘掉的。

⚠️ 这有个很实际的后果:SIGKILL 杀掉的进程没有任何机会做清理。 没写完的文件不会被刷盘、临时文件不会被删、锁不会被放开。所以 docker stop 是先发 SIGTERM 等十秒、再发 SIGKILL,Kubernetes 也是同一套——先请求,超时了才动手。 你写服务时该处理 SIGTERM,原因就在这。

⭐ 顺带解释一个你早就见过的数字:137 = 128 + 9。第 4 篇那个被 SIGILL(4 号)打死的程序,退出状态是 132 = 128 + 4shell 用"128 加信号编号"来表示"这个进程是被信号杀死的,不是自己退出的"。

信号是什么时候被"送到"的

这是最容易搞错的一点。内核不会打断你去执行处理函数。

真实的流程是:

  1. 别人 kill 你 → 内核只是在你的 task_struct 里把对应的那一位置 1(“你有一个待处理的 2 号信号”)。到此为止,你还在正常跑。
  2. 等到你下一次从内核态返回用户态时——可能是一次系统调用结束,也可能是一次时钟中断处理完(第 4 篇那条隧道)——内核在往回走的路上顺手检查那些位
  3. 有待处理的信号,就不直接返回原来的地方,而是把你的用户栈改一改,让你"返回"到处理函数里去。处理函数跑完,再真正回到被打断的位置。

所以信号的投递,是搭第 4 篇那条隧道的顺风车。 它不需要任何新机制——内核本来每秒就要往用户态返回几百次(时钟中断),顺路捎一下而已。

⚠️ 这也解释了一件事:一个纯计算、从不进内核的死循环,理论上会晚一点才收到信号——得等下一次时钟中断把它拽进内核。而这也正是 Ctrl-C 有时候感觉"按了没反应"的原因之一。

回到拍肩膀:拍你的人根本没让你停下手上的活儿。 他只是在你桌上留了个记号就走了。你是在自己抬头的那一刻才发现被拍过的——而你抬头的时机,是你自己的节奏决定的,不是他的。

⭐ 跑一遍看看:信号不排队

现在看这一节最反直觉的性质。

你发 10 个信号,处理函数会跑几次?直觉说 10 次。 让内核自己回答——父进程连发 N 个 SIGUSR1,子进程数自己的处理函数跑了多少次:

static volatile sig_atomic_t got;
static void h(int s){ got++; }        /* 处理函数只做一件事:计数 */
...
for (int i = 0; i < N; i++) kill(p, SIGUSR1);
  父进程连发      1 个 SIGUSR1   →   子进程的处理函数实际跑了 1 次
  父进程连发     10 个 SIGUSR1   →   子进程的处理函数实际跑了 1 次
  父进程连发   1000 个 SIGUSR1   →   子进程的处理函数实际跑了 90 次
  父进程连发 100000 个 SIGUSR1   →   子进程的处理函数实际跑了 9248 次

发 10 个,只跑了 1 次。发 10 万个,跑了不到 1 万次。

因为第 1 步里那个"把对应的位置 1",就是字面意义上的一个二进制位。已经是 1 了再置一次,还是 1。在你抽空去处理它之前,后面来的同号信号全部被合并掉了,一个不剩

(中间那两行每次跑都不一样——1000 那一行我跑三遍得到 1、95、1。这取决于子进程在这期间被调度了几次,也就是它有几次机会走到"返回用户态"那一步。不变的只有一条:远少于你发的数量。

⚠️ 这条性质会坑死人,因为它把一个常见写法直接判了死刑:

“每收到一个信号,就处理一个任务。”

用信号做计数、做队列、做事件通知,丢的不是偶尔一个,是绝大多数。信号能可靠告诉你的只有一件事:“这类事情发生过至少一次。” 至于发生了几次,它不知道,也不打算知道。

⭐ 正确的用法是把信号当边沿触发的提醒:收到之后去查真正的状态(读队列、扫目录、waitpid(WNOHANG) 循环收所有僵尸),而不是假设"一个信号对应一件事"

还是那个拍肩膀:十个人趁你埋头干活时依次拍了你的肩,而你抬头时只看见"有人拍过我"这一件事——你没法从肩膀上数出到底来过几个人。所以抬头之后该做的不是"处理一个人的事",是回头把屋子扫一遍,看谁在等你

⚠️ 第五节那个收尸的例子正好踩这个坑:子进程退出会给父进程发 SIGCHLD。如果你在 SIGCHLD 的处理函数里只 wait 一次,同时退出的五个子进程只会给你一个 SIGCHLD,你收掉一个,剩下四个变成僵尸。 正确写法是在处理函数里 while (waitpid(-1, NULL, WNOHANG) > 0); 一直收到收不动为止。

⚠️ 两个必须知道的坑

坑一:信号会打断阻塞中的系统调用。

你的进程正阻塞在 read 上等数据,这时候来了个信号。处理函数跑完之后,那个 read 怎么办?

默认是:它直接返回 -1errno 设成 EINTR。数据一个字节都没读到。

一个进程阻塞在 read() 上,中途收到一个它已经装了处理函数的信号:

  不加 SA_RESTART:       read() 返回 -1,errno = Interrupted system call
  加了 SA_RESTART:       read() 正常返回 5 字节(信号没打断它)

⚠️ 所以在会收到信号的程序里,每一个阻塞调用都可能以 EINTR 失败,而这跟"出错了"完全是两回事。 要么用 SA_RESTART 让内核自动重启它(sigaction 的一个标志位),要么自己写重试循环。这是 C 里最常见的一类漏判——很多人一辈子没见过 EINTR,直到某天加了个信号处理函数,程序开始随机报错。

坑二:处理函数里能干的事极少。

处理函数是在你原来的执行流中间插进来的。如果你的主流程正卡在 malloc 里面、手上拿着分配器的锁,而处理函数也调了 malloc——它会去拿同一把锁,然后死锁。你的进程当场挂死,而且极难复现。

⭐ 所以处理函数里只允许调用异步信号安全的函数。这个名单很短,printfmalloc 都不在上面write 在,所以上面的演示用的是 os.write)。

实践中的标准写法是:处理函数只做一件事——把一个 volatile sig_atomic_t 的标志位置 1,然后立刻返回。 真正的活儿留给主循环去干。上面那个 got++ 就是这个形状。

⚠️ 这和第 21 篇要讲的并发 bug 是同一个问题的两个面:处理函数和主流程共享一切,却随时可能在任意一条指令中间插进来。 它比线程更难对付——线程至少不会在你自己的栈上执行。

七、代价与取舍

这套设计换来了什么: 一道能插手的缝。shell 的重定向、管道、改工作目录、降权限,全都靠它,而且不需要被 exec 的那个程序配合。UNIX 那句"小工具组合起来"的哲学,实现基础就在这里。

它花了什么:

  • fork 要复制整个地址空间。 一个占 4 GB 内存的进程 fork 一下,难道要复制 4 GB?现代内核用写时复制(copy-on-write)绕过去了——先只复制页表,等谁真写了才复制那一页。这是第 12 篇的内容,也是"加一层中间人"这一招的又一次出现。

    ⭐ 所以"克隆一个你自己"这个说法,在这里要打个折扣:内核并没有真的复制那个人,它只是发了一份一模一样的地图,两个人照着同一份地图走,谁真要动某一间屋子,才在那一刻单独给他复制那一间。克隆是假的,地图是真的。

  • fork 后紧接着 exec 是纯浪费。 刚复制的地址空间立刻被扔掉。所以后来有了 posix_spawnvforkclone——都是为了省掉这一趟。

它放弃了什么: fork 在多线程程序里是个雷。子进程只继承调用 fork 的那一个线程,别的线程正拿着的锁会以"已锁上但永远不会解锁"的状态被复制过去。这就是为什么 Python 3.12 起在多线程程序里 fork 会警告,也是为什么很多语言的运行时干脆禁止它。这个坑第 21 篇再拆。

八、小结

  • fork 一次调用两次返回:父进程拿到子 pid,子进程拿到 0。两边都从 fork 的下一行继续。
  • exec 不创建进程,它把当前进程的内容整个换掉。成功就回不来,只在失败时返回。
  • 分成两步的全部理由,是中间那道缝。 在那里你有一个新进程但它还没变成目标程序,可以随便改它——ls > out.txt 就是这么实现的,而 ls 自己毫不知情。
  • wait 是义务不是礼貌。 不 wait 就攒僵尸,攒到 pid 用光,整台机器 fork 不动。孤儿由 pid 1 接管。
  • 信号是这套接口里唯一一条"从外面进来"的路:一个编号,不带数据。⭐ SIGTERM 是请求(能捕获,可以做清理),SIGKILL 是命令(内核不许你安排,进程一行代码都不再执行)——docker stop 先发前者等十秒再发后者,就是这个道理。
  • 信号的投递搭的是第 4 篇那条隧道的顺风车kill 只是把 task_struct 里一个位置 1,等你下次从内核态返回用户态时才真正跳进处理函数。所以它不需要任何新机制。
  • 信号不排队。 实测:连发 10 个 SIGUSR1,处理函数只跑了 1 次;发 10 万个,跑了 9248 次。因为"待处理"就是一个二进制位,重复置 1 还是 1。⚠️ 所以绝不能用"一个信号 = 一件事"的写法——SIGCHLD 里必须 while (waitpid(..., WNOHANG) > 0); 收干净。
  • ⚠️ 两个坑:信号会让阻塞中的系统调用返回 EINTR(要么加 SA_RESTART,要么自己重试);处理函数里只能调异步信号安全的函数——printfmalloc 都不在名单上,标准写法是只置一个标志位就返回。
  • fork 的复制靠写时复制才不至于太贵;但它和多线程一起用是个已知的雷。

思考题

  1. 管道 A | B 怎么用 fork/exec 实现?(提示:os.pipe() 给你两个描述符,然后你需要两道缝。)
  2. 第五节说僵尸只占一个 pid。写一个会把 pid 耗光的程序需要几行?你觉得为什么内核不干脆自动回收无人 wait 的子进程?
  3. 如果 exec 失败了(比如程序不存在),子进程会怎样?它现在是个什么?你的代码该怎么处理?
  4. “克隆一个自己再换衣服"这个比方,在写时复制这件事上会失效——为什么?(提示:真克隆一个人是要花力气的,写时复制却几乎不花。)
  5. 第六节说信号不排队,因为"待处理"只是一个位。那么按这个说法,同时发一个 SIGUSR1 和一个 SIGUSR2,会不会互相合并掉?为什么?(提示:数一数 Linux 有多少个标准信号,再想想需要多少个位。)
  6. 处理函数里不能调 printf,因为主流程可能正拿着它内部的锁。那么,信号处理函数和第 16 篇要讲的线程,哪个更难写对?说一条信号独有的、线程没有的麻烦。
  7. 拍肩膀那个比方里,SIGKILL 是"拍不得商量的那一下”。可现实中你没法阻止别人拍你肩膀,却可以装作没感觉——为什么内核连"装作没感觉"都不允许?(提示:如果允许了,kill -9 还剩下什么用?)

延伸