第04章 初始化程序(4):pid = fork() —— 操作系统中的“一生二”与程序分身术

📅 2026/8/7 11:56:16
第04章 初始化程序(4):pid = fork() —— 操作系统中的“一生二”与程序分身术
引言从“单机”到“多机”的飞跃在计算机科学的世界里有一个非常著名的思想实验如果你有一台完美的复印机并且这台复印机不仅能复印纸张还能复印出一个和你一模一样的人同时赋予这个“复制品”独立的意识和执行能力。你会怎么做在现实世界这可能只是科幻但在 Linux 内核中这个魔法每天都在发生它就是fork()。在 Linux 0.11 的main.c初始化函数中仅仅一行if (!fork())的代码就触发了操作系统的“分身术”让一个孤单的“进程0idle”瞬间创造出了人类历史上无数进程的祖先——“进程1init”。今天我们就来深度解开这个“分身术”背后的秘密。一、 不是“生”而是“复制”fork()的核心哲学在大多数人的直觉里创建一个新程序应该像是“新建一个空白文档”或者从硬盘上读取一个新的exe文件加载到内存里。然而UNIX/Linux 系统的哲学完全出乎意料创建一个新的进程几乎等同于“克隆Clone一个现有的进程”。fork()就是执行这个“克隆”动作的系统调用。原进程被称为父进程Parent Process。新进程被称为子进程Child Process。正如您提供的图片中所描述的那样“该调用复制当前进程并在进程表中创建一个与原进程几乎完全一样的新表项并执行同样的代码但该新进程拥有自己的数据空间和环境参数。”为什么设计成“复制”而不是“创建”呢因为从零创建一个进程的“上下文”包括环境变量、文件描述符、信号处理等是极其耗时且复杂的。而通过复制父进程子进程一出生就继承了父亲的所有“家产”环境、打开的文件句柄等这不仅极大地提高了创建进程的速度也方便了父进程向子进程传递初始状态。技术内幕深入内核在内核层fork()会去查看内核维护的进程表Process Table。父进程在进程表中有一个条目称为进程控制块PCBLinux 中表现为task_struct结构体。fork()的核心工作就是申请一个空闲的 PCB 条目然后把父进程 PCB 里的绝大部分内容原封不动地“复制”到这个新的 PCB 中。二、 奇迹般的“一次调用两次返回”这是fork()最令人惊叹、也是让初学者最容易迷惑的地方。我们来看看您提供的图片原书图4-3中的流程图。父进程子进程内核空间原进程父进程调用系统调用 fork()复制进程表项创建子进程子进程获得返回值父进程获得返回值pid 0pid 子进程PID (0)子进程继续执行父进程继续执行让我们来详细拆解这个神奇的流程图指令交汇点当 CPU 执行到pid fork()这个程序语句时这一刻只有一个进程在执行。操作系统内核接手进行进程克隆。瞬间分裂当内核完成了进程表复制、内存空间复制Linux 0.11 后续采用了写时复制 Copy on Write 技术来延迟真正的内存复制后系统有了两个完全一样的执行流。命运的分岔口注意关键点系统调用fork()在父进程和子进程中各返回了一次。但返回的数值完全不同。这就是“一次调用两次返回”的魔法在父进程中fork()的返回值是子进程的进程 IDPID。这是一个大于 0 的整数。在子进程中fork()的返回值是0。为什么返回值是 0因为子进程不需要用 PID 来标记自己它刚刚诞生还没有后代。而父进程需要拥有子进程的 PID以便将来可以通过wait()来“收尸”回收子进程占用的资源。代码中的分叉Linux 0.11 的main.c和init()函数正是利用这个特性让父子进程走向完全不同的命运。我们在代码中看到if(!fork()){init();// 子进程执行的代码}对于子进程fork()返回 0!0也就是 1真因此它会进入if内部执行init()。对于父进程fork()返回大于 0 的 PID!PID也就是 0假因此它跳过if内部继续往后执行循环。三、 复制之后呢真正的使命是exec()书上清晰地指出了fork()的核心用途“创建新进程的主要用途在于在新进程中使用exec()簇函数去执行其他不同的程序。”通俗理解如果把fork()比作“复制了一个空壳或者是拥有模版内容的身子”那么exec()就是将这个“空壳”里面的脑子“彻底换掉”。当子进程执行了execve(/bin/sh, ...)之后内核会清除掉子进程原先从父进程复制过来的代码段和数据段然后从磁盘上把/bin/sh这个二进制文件加载进内存替换掉子进程的内容。子进程从这一刻起就不再是它父亲的克隆体了它摇身一变成为了全新的 shell 程序。这就是经典的 UNIX 启动模式fork()后紧跟exec()。四、进程的生命周期“祖先”与“后裔”原进程是“祖先”。哪怕子进程最后通过exec()执行了别的程序但它的“血缘”父进程 PID、进程组、会话 ID 等依然存在。错误处理书上提到“如果fork()调用失败则会返回小于 0 的值。”在真实操作系统中如果进程表已经满了比如系统有了 64 个进程对于 Linux 0.11 来说已经达到NR_TASKS的上限或者内存耗尽无法分配新的 PCBfork()就会返回 -1。所以在实际健壮的代码里我们必须判断if (pid 0)。分头行动图中那条黄色的原进程线和绿色的新进程线是拥有各自独立的 CPU 执行流道的。在fork()之后父进程和子进程会并行运行当然在单核 CPU 上是分时调度但在宏观上它们是并行的。父进程原本可以使用wait()等待子进程结束或者继续去做别的事。五、 Linux 0.11 中的世界级实例init进程的诞生结合我们阅读的init/main.c我们再来看fork()在这个特殊场景下的意义。main()函数一路跑到最后执行了move_to_user_mode()把目前运行的内核态代码降级为了用户态下的“进程0”。紧接着系统执行了fork()。这是系统第一次在用户态特权级3下创建进程。父进程进程0获得子进程 PID 1。它随后来到一个for(;;) pause();的死循环中。当没有其他进程可运行时调度器就会调度这唯一的“空闲”进程运行。子进程进程1在if (!fork())分支中它获得返回值 0。它立刻去执行init()函数。进程1随后会在init()中再次调用fork()创建出新的子进程进程2。这就是整个 Linux 大家族开枝散叶的过程。可以说如果没有fork()这个神奇的“分身术”系统调用Linux 的多任务环境将无法从“进程0”这个唯一的起点启动。下篇预告进程在 Linux 中不仅要学会“诞生”和“执行”还要学会“得体地死去”。在init()循环中我们看到了wait(i)和exit()这背后隐藏着子进程如何向父进程“报丧”、以及父进程如何“收尸”清理僵尸进程的底层逻辑。下一篇我们将聚焦于进程的终止、等待与回收机制为您解析 Linux 的操作系统伦理。敬请期待