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)

两个标准层面的依据,值得知道名字以便查证:

所以流传最广的那个说法——「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 那条路径展开,看清后果为什么严重:

  1. 协议字节交错:PostgreSQL / MySQL 的协议是「请求-响应」严格配对的。两个进程往同一条连接各写各的查询,服务端把 A 的回包发给正在读的 B——报文错位,轻则解析错误,重则数据错乱。
  2. 事务串味:A 开了事务还没提交,B 在同一条连接上提交了它的事务——A 以为回滚的东西被一起提交了。这种 bug 的临床表现是「数据偶尔不对,无法复现」。
  3. 连接泄漏与枯竭:一边崩溃或关闭连接,另一边手里的 socket 变成悬空引用;更隐蔽的是两边都没关,fd 表慢慢涨满,最终 Too many open files。

三种死法共享一个工程特征:症状偶发、跟负载相关、报错信息指向协议层而不是进程层。排查时如果不知道 fork 继承规则,会永远在数据库参数和驱动版本里打转。

四、防御姿势:dispose + 懒加载

标准的防御写在 worker 的启动钩子里:

async def on_startup(_ctx):
    DatabaseManager.dispose_engines()      # 关掉(可能)继承来的同步引擎
    await PgPool.dispose_all()             # 关掉(可能)继承来的异步连接池

为什么这个写法值得推广——它不赌:

五、误判澄清

常见误解 核对 结论
「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 节那张表就从「背下来的」变成「亲眼看到的」。

七、练习

  1. 用 /proc/self/fd(或 lsof -p <pid>)验证:gunicorn preload_app=True 时,worker 进程里能看到 master 进程 preload 阶段打开的文件;preload_app=False 时看不到。解释差异。(答案:前者 fork,后者每个 worker 独立加载。)
  2. 你的服务要新增一个「定时 fork 子进程做快照」的功能,启动时父进程已建好 Redis 连接池。列出你会做的三件事。(参考:fork 前不建池 / fork 后子进程先 dispose / 或干脆改用 exec 型拉起。)
  3. 一句话回答:为什么「子进程里连接池懒加载」让防御代码变简单?

现在你能解释:fork 和 exec 各复制什么、PEP 446 和 close_fds 如何双保险、共享 socket 的三种死法、以及 dispose + 懒加载这对组合为什么成立。下一章把视野拉宽——除了进程边界,连接池还有另外两条同样致命的共享边界。

进入 keel 阅读