🐧 第三章 进程管理
📄 一句话概括

fork 一次调用、两次返回:父进程拿到子进程的号,子进程拿到 0。它复制的是【家当】不是【内存内容】——写时复制让 fork 变得便宜,但也埋了一个坑:你以为复制了,其实还共用着。

📝 本节和前面几节的关系

3.1 说进程是复制出来的,这一节把【复制】这两个字拆开:复制了什么、没复制什么、什么时候才真的复制。

一、一次调用,两个返回值

pid = fork() 这行代码会回来两次:一次在父进程里,一次在子进程里。 父进程里 pid 是子进程的号(大于 0);子进程里 pid 是 0。 于是代码长成这样:

pid = fork();
if (pid == 0) { /* 我是子进程 */ }
else if (pid > 0) { /* 我是父进程,pid 是孩子号 */ }
else { /* 出错 */ }

if (pid == 0) 不是判断成没成功,是判断【我现在的身份】。这一点几乎所有教材都会强调,因为第一遍读极容易读成前者。

二、复制了什么,没复制什么

复制了:地址空间的内容(代码、数据、堆、栈)、打开的文件描述符表、信号处理设置、当前目录、环境变量、资源限制。 没复制:进程号是新的(子进程有自己的号)、父进程号指向父进程、累计运行时间从 0 开始、待处理信号清空。 最反直觉的一条:父子进程共享打开的文件——不是各有一份文件内容,是共用同一个文件偏移量。所以两边各写一个字节,会写在相邻位置,而不是互相覆盖。 这条的实际用处:ls > out.txt 之所以成立,就是 shell 打开文件之后fork + exec,那个 fd 跟着进了 ls 的肚子。

三、写时复制:fork 为什么没你想的那么贵

如果 fork 真的把几 GB 内存全抄一遍,起一个进程就要几秒钟。现实里 fork 是微秒级的——因为内核耍了个花招:写时复制(COW)。 做法:fork 时只复制页表,不复制页内容,父子两边都指向同一批物理页,并把这些页全部标成只读。 谁要写,谁就触发缺页异常:内核这时才给它复制一页、改成可写,然后让它继续写。没写的页永远不复制。 所以 fork 的成本 = 复制页表的成本,与进程多大关系不大;真正花钱的是之后【写】的那部分。 这也解释了为什么 fork 之后马上 exec 是最省的:exec 直接把地址空间扔掉,那些页根本没来得及被写,一页都不用复制。

四、fork 的三个坑

坑一:文件偏移量共享。父子各写一个字节,落在相邻位置。要各写各的,得先各自打开一次文件。 坑二:缓冲会被复制。如果父进程的 stdio 缓冲区里还攒着没落盘的内容,fork 出来的子进程会带着同一份缓冲,两边退出时各刷一次——输出就重复了。这就是很多程序 fork 之前要先 fflush 的原因。 坑三:多线程程序里 fork 很危险。fork 只复制调用它的那个线程,别的线程直接消失,而它们持有的锁还在锁着——子进程一碰就死锁。所以现代做法是 posix_spawn 或 clone。

五、动手 Demo:fork——一次调用,回来两次

沙箱是 Windows,没有 fork。所以这里用一个【语义模型】把它演一遍:拿到两份一模一样的副本,两边从同一点各自往下跑。 模型只做一件事:证明【两边都从同一点继续,但改的东西互不可见】。

def fork_model(state):
    a = dict(state)          # 父进程那份
    b = dict(state)          # 子进程那份(内容一样)
    a["身份"] = "父"
    b["身份"] = "子"
    b["进程号"] = state["进程号"] + 1   # 子进程拿到新号
    a["计数器"] += 1
    b["计数器"] += 100
    return a, b

# 真 Linux 上等价于:
# pid = os.fork()
# if pid == 0:  子进程分支
# else:         父进程分支

实跑输出:

--------------------------------------------------------------------
fork——一次调用,回来两次
--------------------------------------------------------------------
① fork 之后,两边各有一份

  分支       身份     计数器     进程号
  --------------------------------------
  父进程     父       1          1000
  子进程     子       100        1001

      父进程改完,子进程看见了吗    False
      子进程的进程号是新的吗        True

  **两边从同一点继续,但各走各的**:父进程加的 1,子进程看不见;
  子进程加的 100,父进程也看不见。**因为它们已经在两个地址空间里了。**

② 一次调用,两个返回值

  在哪个进程里     fork 返回什么                怎么判断自己是谁
  ---------------------------------------------------------------
  父进程           子进程的进程号(大于 0)     返回值 > 0
  子进程           0                            返回值 == 0
  出错             -1                           errno 说明原因

  **这是 fork 最反直觉的地方:调用一次,返回两次。**
  所以代码里那句 `if pid == 0:` 不是【判断有没有成功】,
  **是【判断我现在是爹还是儿子】。**

  📋 真 Linux 上跑一遍:`strace -f -e trace=clone,fork,vfork ./程序`,
  会看到一次调用在父子两个进程里各打印一行——现代 Linux 上 fork 底层是 `clone`。

父进程加的 1,子进程看不见;子进程加的 100,父进程也看不见。因为 fork 之后它们已经在两个地址空间里了。 注意模型里【子进程的号是新的】这一点必须保留——这是 fork 与【复制一个对象】最本质的区别:新进程有新的身份。 模型没有体现【写时复制】,因为它不真分配内存。但记住那条判断:复制页表很便宜,复制页内容才贵,而没写过的页永远不复制。

📋 在真 Linux 上跑真的 fork: python3 -c "import os; pid=os.fork(); print(('子' if pid==0 else '父'), os.getpid(), pid)" 这一行会打印两行——这就是【一次调用、两个返回值】的现场。

30 秒实验

  1. 真 Linux 上跑 python3 -c "import os,time; os.fork(); time.sleep(1)",同时 ps -ef | grep python 看两个进程。
  2. 跑上面那个一行 fork 的例子,看子进程打印的返回值是不是 0。
  3. strace -f -e trace=clone python3 -c "import os; os.fork()",看 fork 在系统调用层是什么(现代 glibc 用 clone)。

常见误区

  1. 【fork 会把内存全复制一遍,所以很慢】。有写时复制,复制的是页表,没写过的页一页都不复制。
  2. 【父子进程各有一份文件内容】。它们共享同一个打开的文件,连文件偏移量都是同一个——各写一个字节会落在相邻位置。
  3. 【多线程程序里 fork 跟单线程一样安全】。fork 只复制调用它的那个线程,别的线程的锁还锁着,子进程一碰就死锁。
自测三问(点开对答案) 1. fork 的返回值在父进程与子进程里分别是什么? —— 父进程:子进程的进程号(>0);子进程:0;出错:-1。**所以 `if (pid==0)` 判断的是身份,不是成败。** 2. 写时复制让 fork 便宜在哪? —— 只复制页表、不复制页内容,两边指向同一批只读页;谁写谁才触发复制。**成本与进程多大关系不大。** 3. 为什么 fork 之前常常要先 fflush? —— stdio 缓冲区里的内容会跟着被复制到子进程,两边退出时各刷一次,**输出重复**。

相关笔记