朕喜技术(ZhenxiJS)
2026.09.14
1 分钟阅读

Python 连接池父进程预热 + multiprocessing fork:多进程抢读同一条 TCP 连接导致缓存「张冠李戴」静默数据污染(明文 TCP 零报错)完整排查与修复

日期:2026-09-14 | 第 55 题 | 分支:2026-09-14-0800技术栈 / 关键词:Python、multiprocessing、fork、os.fork、文件描述符表继承、fd 继承、 连接池、连接复用、长连接、父进程预热、warmup、多进程抢读同一 socket、 请求响应错配、response mismatch、串包、静默数据污染、缓存串号、缓存张冠李戴、 TimeoutError: timed out、json.decoder.JSONDecodeError、Expecting value: line 1 column 1 (char 0)、 ConnectionError: gateway closed connection、created_pid != os.getpid、 os.register_at_fork、set_start_method("spawn")、threading.Lock 无法跨进程、 Redis 网关、一问一答、长度前缀分帧、明文 TCP 无记录层 MAC 环境:Docker / docker compose,3 容器(redis / gateway / app),纯 Python 标准库,无需特权


一、问题现象(一句话)

缓存预热时每个 worker 都"成功"拿到值、全程零报错,但读到的值是别人的; worker 越多越容易复现,--workers 1 单进程则一切正常。

典型现场(4 worker × 6 job):

预热请求总数 24  拿到值 17  空值 0  抢到别人响应 4
SUMMARY_JSON {"total": 24, "ok": 17, "empty": 0, "mismatch": 4, "errors": 7}
     w1-job2 @ pool-3: RESPONSE_MISMATCH  got={'requestId': 'w0-job3', 'cacheHit': 'v-w0',
                                   'reader_pid': 8, 'conn_state': 'INHERITED_BY_FORK'}
     w0-job1 @ pool-1: TimeoutError: timed out
     w0-job3 @ pool-3: JSONDecodeError: Expecting value: line 1 column 1 (char 0)

[app] 数据正确性自检(读到的值 vs 本 worker 的期望值):
      worker1 期望值(应为 'v-w1') 读到=['v-w2','v-w1',...]  → 污染
[app] ❌ 3/4 个 worker 读到了别人的值:缓存被静默污染(注意:全程没有任何报错)

最要命的是第 1 条:这是一次静默的数据污染,不是一次宕机。 上层只判断"有没有拿到值",于是把错误数据当正常缓存写进了下游。


二、故障成因(根因)

问题代码的写法(线上很常见):

_POOL = None

def init_pool(cfg):
    global _POOL
    _POOL = GatewayPool(cfg["gwHost"], cfg["gwPort"], cfg["poolSize"])
    _POOL.warmup(...)                       # ← 在【父进程】里建好 4 条长连接
    return _POOL

init_pool(cfg)                              # 父进程预热
for i in range(cfg["workers"]):
    p = mp.Process(target=process_orders, args=(i, jobs, cfg))   # Linux 默认 fork
    p.start()

Linux 上 multiprocessing 默认 fork。fork 会把父进程的:

  • 文件描述符表(父进程那几条 socket fd 被原样继承)
  • 用户态池对象 / 连接对象(连同 Python 层接收缓冲区)

一起复制给每个子进程。于是:

  • 内核里其实只有 poolSize 条到网关的 TCP 连接;
  • 却是 poolSize × workers 个"进程"以为自己独占其中某一条;
  • 谁先 recv 谁先拿到数据,同一条连接上的数据被多个进程轮流抢读。

因为每个 worker 发出去的 requestId 长得都一样(w0-job0 / w1-job0 …), 抢到的响应"格式正确",没有任何报错,但值不是自己那份。

为什么本题没有报错?和 TLS 场景的区别

第 54 题(TLS)第 55 题(明文 TCP)
记录层序号 / MAC有,会被校验没有
抢读后果DECRYPTION_FAILED_OR_BAD_RECORD_MAC、SSLEOFError静默,值被别人换掉
能否靠日志发现能(一堆 SSL 报错)不能,必须核对"值对不对"

所以第 55 题比第 54 题更隐蔽:它没有任何异常日志,只有脏数据。


三、如何定位

排查顺序(每步都能缩小范围):

  1. 看数值正确性,而不是看有没有报错。 本例 SUMMARY_JSON 里 ok 看着不低,但 mismatch > 0 —— 这才是真问题。
  2. 单进程对照:--workers 1 全对 → 说明网关 / Redis 本身没问题,问题在 app 侧的进程边界。
  3. 打印连接视图:每个 worker 的池都是 pool-0 fd=3 created_pid=<父进程pid>,而 worker 自己的 pid 是别的值 → 子进程在用父进程建的连接。
  4. 看内核事实:ls -l /proc/<worker_pid>/fd 里那几条 fd, 指向的内核 socket 和父进程是同一个(fd 编号相同、连接相同)。
  5. 一条判据定案:conn_state == "INHERITED_BY_FORK", 即读数据的 pid ≠ 建连接的 pid。

关键记忆点:threading.Lock 防不住 fork。 fork 之后每个子进程各拿一份锁副本,多个子进程可以同时"持有锁"。 所以本题不能靠加锁解决,必须让连接的生命周期跟在进程边界内。


四、修复方案

核心原则:谁用谁建(连接生命周期跟随进程边界)

class GatewayConnection:
    def __init__(self, host, port, timeout=10.0, name=""):
        ...
        self.created_pid = os.getpid()          # 记录"出生 pid"

    def is_owned_by_current_process(self) -> bool:
        return self.sock is not None and self.created_pid == os.getpid()

    def discard_inherited_copy(self) -> None:
        """fork 后丢弃继承副本:只丢引用,绝不能 close()。"""
        self.sock = None
        self._lock = threading.Lock()

class GatewayPool:
    def ensure_owner_process(self) -> None:
        pid = os.getpid()
        if pid == self.owner_pid:
            return                              # 本进程自己的池,直接复用
        for conn in self.conns:
            conn.discard_inherited_copy()       # 1) 丢弃父进程副本
        self.conns = []
        self._connect_all()                     # 2) 在本进程内重建
        self.owner_pid = pid

    def get(self, idx: int) -> GatewayConnection:
        self.ensure_owner_process()             # ✅ 取连接前先做 fork 自查
        return self.conns[idx % len(self.conns)]

三个容易踩错的细节

  1. 绝不能 close() 继承来的 fd。 子进程 close() 关的是同一张 fd 表里的同一编号,会把父进程(及兄弟进程) 持有的那条共享连接一起废掉,反而制造新的 EOF: gateway closed connection。 正确做法是只清 Python 引用,让 GC 回收副本。
  2. fork 自查要放在"取连接"入口,而不是模块 import 时。 子进程 import 时父进程的池已经存在;只有真正要发请求时才知道"当前是谁在用"。
  3. 不要把 threading.Lock 当解法。 锁也随 fork 复制,跨进程形同虚设(同样踩坑的还有第 25 / 30 题)。

五、验证结果

# 修复前(真实 app 代码,4 worker × 6 job)
SUMMARY_JSON {"total": 24, "ok": 7, "empty": 0, "mismatch": 6, "errors": 17}
[app] ❌ 4/4 个 worker 读到了别人的值

# 修复后
#   单进程 1 × 10 :{"total": 10, "ok": 10, "empty": 0, "mismatch": 0, "errors": 0}
#   4 worker × 6  :{"total": 24, "ok": 24, "empty": 0, "mismatch": 0, "errors": 0}
#   8 worker × 12 :{"total": 96, "ok": 96, "empty": 0, "mismatch": 0, "errors": 0}
      worker0: pid=1705 池created_pid=1705   ← 每个 worker 的连接都是自己建的
      worker1: pid=1712 池created_pid=1712
[app] ✅ 全部 4 个 worker 读到的都是自己的值

最小对照实验:

python3 experiments/fork_probe.py --phase=both
# before 出现 TimeoutError / JSONDecodeError,reader_pid=子进程 而 conn_created_pid=父进程
# after  合计 OK=16/16 错=0(本次实跑 before 12/16、after 16/16)

六、方案对比

方案做法评价
pid 自查 + 子进程重建(推荐)取连接前比对 pid,不符就丢副本重建改动小、保留长连接复用、fork / spawn 都成立
os.register_at_fork(after_in_child=...)fork 回调里重置池可行;但隐性依赖 fork,spawn 下不触发
set_start_method("spawn")子进程不继承 fd能解决,但改变进程模型、启动变慢,属"绕开"
initializer= 里各建各的池每个 worker 启动时 init_pool等价;注意别复用父进程那份
加锁——❌ 无效,锁随 fork 复制

七、复现与验收命令速查

git checkout 2026-09-14-0800
cd scenario/2026-09-14-0800

# 有 Docker
bash run.sh                    # 一键复现问题现象
bash verify.sh                 # 验收:修复前 PASS=3 FAIL=4 → 修复后 PASS=7 FAIL=0
                               # verify.sh 共 7 处 ok() 判定,全通过即 PASS=7 FAIL=0,与 README/Issue 要求一致

# 无 Docker(本轮 CNB pipeline 环境采用)
python3 experiments/fork_probe.py --phase=both      # 最小对照实验,不需 docker
# 进阶:直接用真实 app.py 配本地网关/RESP 服务跑一轮,见 SOLUTION.md「本次修复环境的说明」

八、真实 Docker 环境复核(补充验证)

本节由 CodeBuddy-Solver 追加。 原始验证环境没有 docker CLI,用的是「真实 gateway + RESP 子集桩 + 真实 app」等价方式。 本次复核环境有完整 Docker,于是用 3 容器真实 docker compose 栈 + 原版 verify.sh 重跑了一遍,结论一致,存档如下。

docker version                  # Docker 29.6.2 / Compose v5.3.1,可用
cd scenario/2026-09-14-0800
docker compose up -d --build    # redis Healthy → gateway Healthy → app Started
bash verify.sh

真实 Docker 下的结果:

状态verify.sh 结果
修复前(代码回到 8d3b2d1)PASS=3 FAIL=4(8×12 压测仅 44/96 对)
修复后PASS=7 FAIL=0(8×12 压测 96/96 全对)

关键判据(docker compose exec -T app python3 /srv/app.py --workers 4 --jobs 6):

[worker0] pid=13 有值=6/6 空值=0 抢到别人响应=0 池created_pid=13
[worker1] pid=14 有值=6/6 空值=0 抢到别人响应=0 池created_pid=14
[worker2] pid=15 有值=6/6 空值=0 抢到别人响应=0 池created_pid=15
[worker3] pid=16 有值=6/6 空值=0 抢到别人响应=0 池created_pid=16
[app] ✅ 全部 4 个 worker 读到的都是自己的值

池created_pid == 自身 pid,不再是父进程的 7。

⚠️ 复核踩到的一个易混点:修复后各 worker 的 fd 依然显示 3/4/5/6。 这不是没修好 —— fd 是每进程独立的编号空间,每个进程的 fd=3 指向 各自不同的内核 TCP 连接。判据只能是 created_pid,不能是 fd 数字。 我第一眼看到 fd 一样还愣了一下,特此记录。

服务端佐证("我回对了"的矛盾依然可复现):

docker compose logs gateway | grep '\[gw\] -> reply' | tail
# [gw] -> reply requestId=w0-job0 task=cache:order:1001:w0 cacheHit='v-w0' ...

无 Docker 最小复现(注意 before 每轮错数会波动 —— 这正是"时好时坏"的来源):

python3 experiments/fork_probe.py --phase=both
# before: 合计 OK=8/16  错=2
# after : 合计 OK=16/16 错=0

收尾:docker compose down -v


九、一句话总结

fork 复制的是 fd,不是连接。 连接池一旦在父进程预热,就必须在 "取连接"时校验 created_pid == os.getpid(),让每个进程用自己建的连接 —— 否则多个进程会抢读同一条内核 TCP 流,在明文 TCP 上表现为完全静默的数据污染。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-14-0800-forktcp

分享这篇文章