KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · fork、exec 与文件描述符:子进程到底继承了什么 — keel 龙骨
「fork 之后连接池要重建」是后端圈流传最广的工程传说之一——它的麻烦在于一半是真的。这一章把继承规则摆到字节级:什么情况下 socket 会传给子进程、什么情况下默认就传不过去,以及防御性 dispose 为什么仍然值得写。
「fork 之后连接池要重建」是后端圈流传最广的工程传说之一——它的麻烦在于一半是真的。这一章把继承规则摆到字节级:什么情况下 socket 会传给子进程、什么情况下默认就传不过去,以及防御性
dispose为什么仍然值得写。
一、先搞懂三个词
进程:操作系统分配资源的基本单位。每个进程有一张独立的文件描述符(fd)表——打开的文件、网络 socket、管道,在进程内部都是一个整数编号。
fork 与 exec:Linux 上创建新进程的经典两步。fork() 把当前进程整个复制一份(地址空间、fd 表、运行状态,全部原样复制);exec() 再把复制出来的程序体换掉(加载新程序,运行状态清空,但fd 表保留——这就是为什么 shell 的重定向能工作:父进程在 fork 后、exec 前把标准输出指到文件)。两步合起来 = 「复制我,再让他去跑别的程序」。
Windows 没有 fork:CreateProcess 直接创建全新进程,不存在「复制父进程」这个动作。所以任何 fork 相关的坑,在 Windows 上都天然免疫——这也意味着本地 Windows 开发永远复现不了这类 bug。
二、继承的精确规则(背这张表)
| 拉起方式 | 发生了什么 | 父进程的 socket 会过去吗 |
|---|---|---|
os.fork() / multiprocessing(Linux 默认 fork 启动法) |
整个地址空间 + 整张 fd 表原样复制 | 会。fork 一律复制全部 fd,无视任何标志位 |
subprocess.Popen(POSIX,Python 3.4+ 默认参数) |
fork → close_fds=True 关掉 0/1/2 以外的 fd → exec | 默认不会 |
subprocess.Popen(POSIX,close_fds=False 或 pass_fds=...) |
同上但保留指定 fd | 会(传了才会) |
subprocess.Popen(Windows) |
CreateProcess,全新进程 | 不会(没有 fork) |
两个标准层面的依据,值得知道名字以便查证:
- PEP 446(Python 3.4 起):新创建的 fd 默认标记为不可继承(non-inheritable)。这条 PEP 的动机正是「防止 socket 在不经意间泄漏到子进程」。
subprocess默认close_fds=True(POSIX):fork 出来的子进程在 exec 前主动关掉 0/1/2 之外的所有 fd。双保险。
所以流传最广的那个说法——「subprocess.Popen 拉起的子进程会继承数据库连接」——在默认参数下是错的。这个坑的真实高发区是 fork 型 worker 模型:
flowchart TD
A["父进程建过 DB / Redis 连接池<br/>每个连接 = 一个 socket 文件描述符"] --> B{"子进程怎么拉起?"}
B -- "fork 型:gunicorn preload_app + fork /<br/>multiprocessing 进程池 / os.fork()" --> C["整张 fd 表复制<br/>子进程连着同一批 TCP 连接<br/>——同一条连接,不是副本"]
B -- "subprocess.Popen 默认参数" --> D["close_fds=True + PEP 446<br/>socket 不传过去"]
B -- "Popen 但 close_fds=False /<br/>第三方库自定义拉起" --> C
C --> E{"两边都用这条连接?"}
E -- "是" --> F["两个进程的协议字节在同一条 TCP 上交错<br/>随机报错 / 事务串味 / 连接泄漏"]
E -- "只有一边用" --> G["暂时无症状,但 fd 已泄漏<br/>连接数慢慢涨满"]
F --> H["修复:子进程入口无条件 dispose + 懒加载重建"]
G --> H
三、共享 socket 的三种死法
把第 2 节的 C→F 那条路径展开,看清后果为什么严重:
- 协议字节交错:PostgreSQL / MySQL 的协议是「请求-响应」严格配对的。两个进程往同一条连接各写各的查询,服务端把 A 的回包发给正在读的 B——报文错位,轻则解析错误,重则数据错乱。
- 事务串味:A 开了事务还没提交,B 在同一条连接上提交了它的事务——A 以为回滚的东西被一起提交了。这种 bug 的临床表现是「数据偶尔不对,无法复现」。
- 连接泄漏与枯竭:一边崩溃或关闭连接,另一边手里的 socket 变成悬空引用;更隐蔽的是两边都没关,fd 表慢慢涨满,最终
Too many open files。
三种死法共享一个工程特征:症状偶发、跟负载相关、报错信息指向协议层而不是进程层。排查时如果不知道 fork 继承规则,会永远在数据库参数和驱动版本里打转。
四、防御姿势:dispose + 懒加载
标准的防御写在 worker 的启动钩子里:
async def on_startup(_ctx):
DatabaseManager.dispose_engines() # 关掉(可能)继承来的同步引擎
await PgPool.dispose_all() # 关掉(可能)继承来的异步连接池
为什么这个写法值得推广——它不赌:
- 拉起方式可能随部署形态变化(今天
subprocess,明天换 gunicorn 托管;某个库内部改用 multiprocessing)。与其枚举「哪条路径会传 fd」,不如在唯一的入口无条件清一次。 dispose之后不需要显式重建:连接池都是懒加载的(第一次真正查询时才建引擎、开连接),扔掉后下次查询自动重建出一套专属本进程的干净池。懒加载在这里不是性能优化,是让防御代码变成一行的结构性前提。- 「没踩坑也要写」的成本是一行代码;「踩了坑再补」的成本是几周的偶发故障排查。这就是防御性写法和过度设计的分界线:赌错代价不对称时,防御就是对的。
五、误判澄清
| 常见误解 | 核对 | 结论 |
|---|---|---|
| 「subprocess 拉的子进程会继承数据库连接」 | Python 3.4+ 默认 close_fds=True + PEP 446 |
默认不继承。高发区是 fork 型模型 |
| 「Windows 上也要担心这个」 | CreateProcess 没有 fork | 天然免疫;本地复现不了不代表线上没有 |
| 「dispose 是性能优化,能省内存」 | dispose 只关不建 | 它是正确性防御,不是优化;重建由懒加载免费完成 |
| 「连接池对象跨进程共享前 deepcopy 一份就行」 | deepcopy 复制的是 Python 对象,socket 底下的 TCP 状态没有复制 | 没用。连接是 OS 资源,不是内存对象 |
六、破坏实验(可复现)
在有 multiprocessing 的 Linux/macOS 上可以安全复现:
from multiprocessing import Process
import socket
s = socket.socket() # 父进程建一个 socket
s.connect(("example.com", 80))
def child():
# fork 模型下这里能看到父进程的 socket(fd 被复制)
import os
print("child fds:", len(os.listdir("/proc/self/fd")))
p = Process(target=child); p.start(); p.join()
对照实验:改用 subprocess.Popen([sys.executable, "-c", "..."]) 打印 fd 数量,会看到子进程的 fd 表里没有父进程的 socket(默认 close_fds=True)。两个实验放在一起,第 2 节那张表就从「背下来的」变成「亲眼看到的」。
七、练习
- 用
/proc/self/fd(或lsof -p <pid>)验证:gunicornpreload_app=True时,worker 进程里能看到 master 进程 preload 阶段打开的文件;preload_app=False时看不到。解释差异。(答案:前者 fork,后者每个 worker 独立加载。) - 你的服务要新增一个「定时 fork 子进程做快照」的功能,启动时父进程已建好 Redis 连接池。列出你会做的三件事。(参考:fork 前不建池 / fork 后子进程先 dispose / 或干脆改用 exec 型拉起。)
- 一句话回答:为什么「子进程里连接池懒加载」让防御代码变简单?
现在你能解释:fork 和 exec 各复制什么、PEP 446 和 close_fds 如何双保险、共享 socket 的三种死法、以及 dispose + 懒加载这对组合为什么成立。下一章把视野拉宽——除了进程边界,连接池还有另外两条同样致命的共享边界。