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 秒实验
- 真 Linux 上跑
python3 -c "import os,time; os.fork(); time.sleep(1)",同时ps -ef | grep python看两个进程。 - 跑上面那个一行 fork 的例子,看子进程打印的返回值是不是 0。
strace -f -e trace=clone python3 -c "import os; os.fork()",看 fork 在系统调用层是什么(现代 glibc 用clone)。
常见误区
- 【fork 会把内存全复制一遍,所以很慢】。有写时复制,复制的是页表,没写过的页一页都不复制。
- 【父子进程各有一份文件内容】。它们共享同一个打开的文件,连文件偏移量都是同一个——各写一个字节会落在相邻位置。
- 【多线程程序里 fork 跟单线程一样安全】。fork 只复制调用它的那个线程,别的线程的锁还锁着,子进程一碰就死锁。
自测三问(点开对答案)
1. fork 的返回值在父进程与子进程里分别是什么? —— 父进程:子进程的进程号(>0);子进程:0;出错:-1。**所以 `if (pid==0)` 判断的是身份,不是成败。** 2. 写时复制让 fork 便宜在哪? —— 只复制页表、不复制页内容,两边指向同一批只读页;谁写谁才触发复制。**成本与进程多大关系不大。** 3. 为什么 fork 之前常常要先 fflush? —— stdio 缓冲区里的内容会跟着被复制到子进程,两边退出时各刷一次,**输出重复**。- ☐ 3.3 execve()函数
相关笔记
- 创建进程 — 复制的来源
- execve()函数 — 复制完换内容
- 写时复制 — 内存章里再算一次这笔账
- 🐧 Linux是怎样工作的 · 索引