上一课结尾我留了个问题:你在 shell 里敲下 ls,那个 ls 进程是怎么"变"出来的?答案是一对黄金搭档——forkexec。一个负责"复制",一个负责"变身"。这一课,我们把这对搭档彻底拆开。

从"敲下 ls 的瞬间"说起

你敲下 ls -l,回车。屏幕上列出了一堆文件。

你有没有想过:ls 是一个躺在磁盘上的程序,它是怎么变成一个正在运行的进程的?难道 shell 自己"变身"成 ls 了吗?

不完全是。 真相是两步走:

  1. shell 先 fork——复制出一个和自己一模一样的子进程;
  2. 这个子进程再 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 为什么不能"直接 execls"?因为那样的话,shell 自己就没了(被 ls 覆盖了),ls 跑完,你的终端就关闭了。所以必须先 fork 出一个"替身",让替身去 exec,shell 自己还活着。

为什么 fork 和 exec 要分开?因为中间那段(fork 之后、exec 之前)是个宝贵的"窗口期":你可以在这个窗口里改 fd、改环境变量、改权限——这正是实现重定向和管道的时机。下一节就是证据。

7.3 fork + exec 实现重定向/管道

还记得第 6 课说的重定向本质吗——"> 是换掉 fd 1 的指向"。现在我们把 fork、exec、重定向串起来,看 ls > out.txt 到底是怎么做到的:

  1. shell fork 出一个子进程;
  2. 子进程把 fd 1 关掉、重新指向 out.txt 文件(换号码牌);
  3. 子进程 execls
  4. ls 老老实实往 fd 1 写,数据就进了 out.txt

整个过程,ls 一行代码都没改。 重定向的魔法,全靠"fork 之后的窗口期改 fd" + "exec 换程序"。

管道 ls | wc 也是同一套底座,只是更绕一点:

  • shell fork两个子进程;
  • 子进程 A 把 fd 1 指向管道写端,execls
  • 子进程 B 把 fd 0 指向管道读端,execwc
  • 于是 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 的输出(子进程 execls 干出来的),最后打印"父进程:子进程跑完了"。

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 其实是一大家子,常见的有 execlexecvexecvpexecle……后缀有讲究:

后缀含义
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 快得惊人。

留三个问题:

  1. fork() 返回几次?父子各自的返回值是多少?
  2. 为什么 shell 执行命令要用 fork + exec,而不是直接 exec?
  3. 写时复制解决了什么性能问题?
下一课,我们要认识进程之间"打招呼"的方式——信号。你在终端按 Ctrl+C 中断程序、用 kill -9 强制杀进程,背后都是它在起作用。它和上一课那些"异常"又是什么关系?我们下回分解。

标签: 计算机基础, 操作系统, Linux

添加新评论