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

Python multiprocessing fork 继承父进程 TLS 连接池:请求/响应错配 + DECRYPTION_FAILED_OR_BAD_RECORD_MAC(bad record mac)完整排查与修复

日期:2026-09-14 | 第 54 题 | 分支:2026-09-14-0000技术栈 / 关键词:Python、multiprocessing、fork、os.fork、连接池、连接复用、 TLS、SSL、ssl.SSLError、DECRYPTION_FAILED_OR_BAD_RECORD_MAC、bad record mac、 SSLEOFError、EOF occurred in violation of protocol、BrokenPipeError: [Errno 32] Broken pipe、 请求响应错配、response mismatch、串包、帧边界错乱、json.decoder.JSONDecodeError、 粘包、父进程预热 warmup、multiprocessing.set_start_method、spawn、forkserver、 ProcessPoolExecutor、initializer、长连接复用、fd 继承、created_pid != os.getpid环境:Docker / docker compose,python:3.11-slim × 2 容器,Linux,无需特权


一、问题背景

一个"工单同步"后台服务,为了省掉反复 TLS 握手,在父进程启动时先预热了一个内部 TLS 连接池(4 条长连接),然后用 multiprocessing.Process 起 4 个 worker 并发推单:

_POOL = None

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

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

上线后现象时好时坏:

  1. 数据错配:worker0 提交 w0-job1,日志里收到 w2-job0 的返回, 而且错配结果被当成成功写进了业务表;
  2. 一堆 SSL 报错: SSLError: [SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC] decryption failed or bad record mac、 SSLEOFError: EOF occurred in violation of protocol、 BrokenPipeError: [Errno 32] Broken pipe;
  3. 服务端偶发 json.decoder.JSONDecodeError,报错 payload 里粘着两个请求的 JSON;
  4. 两边账对不上:服务端日志明明 reply requestId=w1-job0,客户端却说 w1-job0 没有响应;
  5. 把 worker 数改成 1,全部现象消失;重启后能"好一会儿"。

二、复现步骤

最小复现(不依赖 docker,一条命令看到 bad record mac)

# fork_probe.py:父进程先建 4 条 TLS 长连接 → fork 8 个子进程复用 → 服务端解密失败
python3 experiments/fork_probe.py --phase=before

输出:

父进程 pid=10038 预热完成,连接 fd=[4, 7, 10, 12](子进程将继承它们)
[echo] !! 处理失败:SSLError: [SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC] decryption failed or bad record mac (_ssl.c:2546)
结果:4/8 个子进程请求成功
  - worker4: SSLError: [SSL: DECRYPTION_FAILED_OR_BAD_RECORD_MAC] ...

完整环境复现(docker compose)

cd scenario/2026-09-14-0000
bash run.sh 4 6 4        # = docker compose up -d --build + 跑一轮 + 打印服务端日志
[app] 父进程预热连接池:poolSize=4 warmup=20 …
[app] 连接池就绪(owner_pid=1):['pool-0', 'pool-1', 'pool-2', 'pool-3']
[app] 启动 4 个 worker(multiprocessing.Process,fork)…

订单总数 24  成功 1  (其中 0 条是重试后才成功)
响应错配 2 条   其他报错 23 条
[!!] 请求/响应错配(拿到的是别人的响应):
     自己 requestId=w0-job1  实际收到={'requestId': 'w1-job0', 'echo': 'w1-job0',
          'conn_state': 'INHERITED_BY_FORK', 'caller_pid': 7, 'conn_created_pid': 1}
[!!] 报错样例:
     w0-job1 @ pool-1: BrokenPipeError: [Errno 32] Broken pipe
     w1-job0 @ pool-1: SSLEOFError: EOF occurred in violation of protocol (_ssl.c:2437)

服务端侧("客户端说没响应、服务端说回复了"的自相矛盾现场):

[srv] -> reply requestId=w1-job0 echo=w1-job0 thread=...     ← 服务端确实处理并回复了
[srv] !! read error on 172.19.0.4:58732: [SSL] record layer failure (_ssl.c:2590)

验收基线:bash verify.sh → PASS=3 FAIL=4。


三、根本原因

3.1 一句话

连接池是"父进程的资产",fork 把它变成了"多个进程共享的可变状态", 而 TLS 恰恰是不能这样共享的。

3.2 拆开看

(1) fork 复制的是"副本",不是"新连接"

Linux 上 multiprocessing 默认 start method 是 fork。子进程拿到的是父进程内存的 副本:fd 表、已完成握手的 ssl.SSLSocket 对象、TLS 会话状态、 以及 SSL 层自己的读写缓冲区。但内核里只有一个 TCP 连接。

用 fromfd 还原后对比 getsockname() 可以证明父子拿到的是同一条内核连接:

父进程: fd=5 local=('127.0.0.1', 51844)
子进程: 继承 fd 编号=5; 还原后 local=('127.0.0.1', 51844)   ← 同一条

服务端只 accept() 到 1 条连接 —— 这就是"一个连接被多个进程同时读写"。

(2) TLS 是有状态协议,比裸 TCP 脆弱得多

TLS 的每条记录都带序号 + MAC。两个进程各自维护一份"序号/密钥/写缓冲"副本, 交错发送后序号对不上,对端按错误序号解密 → bad record mac。 客户端两个进程同时 recv 同一条流 → 响应先到谁手里是不确定的 → A 进程读到 B 的响应 → 请求/响应错配。

(3) 为什么裸 TCP 连接池被 fork 继承后通常"看起来没事"?

裸 TCP 只是无状态字节流,按到达顺序处理,单条请求/响应本身还是完整的 (最坏是响应被"另一个进程"读走)。TLS 上一层状态机立刻就对不上了。

(4) 帧边界被打乱 → 服务端 JSONDecodeError

长度前缀协议没有帧边界同步保护。被污染的流里,服务端会把两个请求的字节 当成一个帧读完 → json.loads 直接失败 → 断连 → 客户端 BrokenPipeError。

3.3 关键定位线索

错配记录里 conn_state=INHERITED_BY_FORK、且 conn_created_pid=1(父进程) 而 caller_pid=7/8/9/10(worker)—— 连接不是我这个进程建的。 把"跨进程共享连接"这个隐形事实变成一个日志字段,就能一眼定位。

3.4 为什么"给连接加线程锁"没用?

threading.Lock 是进程内同步原语,fork 后每个子进程各持有一份独立的锁对象副本:

4 个进程共耗时 0.30s(≈0.3s 说明是并行进入的)   ← 4 个进程同时"持有同一把锁"

即使换成 multiprocessing.Lock 做到跨进程互斥,"一条 TLS 连接被多个进程轮流用" 仍然要求 TLS 状态在进程间同步 —— 做不到。正确做法是每个进程用自己的连接,根本不需要跨进程锁。


四、解决办法

核心原则:连接的生命周期必须与进程边界对齐。

改动只在 app/ 侧(服务端实现是正确的,不要改它"让问题消失")。

4.1 TLSConnection:加归属校验(防御性)

def is_owned_by_current_process(self) -> bool:
    # fork 后子进程里 created_pid 仍是父进程的 pid,与 os.getpid() 不等
    return self.sock is not None and self.created_pid == os.getpid()

def call(self, payload):
    if not self.is_owned_by_current_process():
        raise RuntimeError(f"{self.name}: 检测到跨进程共享连接 "
                           f"(created_pid={self.created_pid}, current_pid={os.getpid()}),拒绝使用")
    ...

4.2 TLSConnectionPool.check_owner():fork 安全的惰性重置(核心)

def check_owner(self) -> bool:
    if self.owner_pid == os.getpid():
        return False                      # 本来就是本进程的池
    old_owner = self.owner_pid
    # ⚠️ 不要 close() 继承来的 socket!那些 fd 与父进程共享同一条 TCP 连接,
    #    在本进程 close() 会发 FIN/RST 干扰父进程。只丢引用,让 GC 回收副本。
    inherited = self.conns
    self.conns = []
    self.owner_pid = os.getpid()
    self._reset_count += 1
    self._connect_all()                   # ← 真正的 TLS 握手发生在本进程
    del inherited
    return True

get() / warmup() 都先调 check_owner()。

4.3 get_pool():进程内单例 + 继承池重建

def get_pool(cfg=None):
    global _POOL
    if _POOL is None or _POOL.owner_pid != os.getpid():
        ...                       # 丢弃继承池 → 用同样参数在本进程重建 → 再热一次
        _POOL.check_owner()
        _POOL.warmup(rounds=1, tag=f"pid{os.getpid()}-warmup")
    return _POOL

4.4 app.py:父进程照常预热,worker 传 cfg 进去自建

pool = init_pool(cfg)             # 父进程预热(保留长连接收益),但这是"父进程自己的池"
for i in range(cfg["workers"]):
    p = mp.Process(target=process_orders, args=(i, jobs, sleep_ms, cfg, warm_rounds))

修复后日志一眼可见归属正确:

[app] 父进程连接池就绪(owner_pid=1):['pool-0', 'pool-1', 'pool-2', 'pool-3'](仅父进程可用)
[pool] pid=7 检测到连接从 pid=1 继承而来,已丢弃副本并在本进程重构 4 条连接 (pid=7 是唯一主人)
      worker0: pid=7  pool_owner_pid=7  fork_reset_count=1
      worker1: pid=8  pool_owner_pid=8  fork_reset_count=1

4.5 为什么不用 set_start_method("spawn")?

spawn 确实也能避免继承连接,但要求参数可 pickle、启动更慢, 且万一有人在 fork 前又建了连接,问题会以同样形式复发。 核心不是"换启动方式",而是**"连接不能跨进程共享"这条不变式**。 所以本方案在应用层加归属校验,不依赖 start method(也顺带对手写 os.fork() 生效)。


五、验证

cd scenario/2026-09-14-0000
bash verify.sh
PASS: 服务端串行 10 次请求全部匹配(服务端实现正确,问题在 app 侧)
PASS: 无 RESPONSE_MISMATCH(每个请求都拿到自己的响应)
PASS: 客户端无 SSL / EOF / BrokenPipe 报错
PASS: 服务端无 JSON decode failed / 帧异常
PASS: 服务端回复覆盖全部 24 个业务请求(replied=24)
PASS: 8 worker × 12 job 全部成功且零错误(成功 96/96)
PASS: 仍保留「父进程预热连接池 + 长连接复用」形态
==================== 结果:PASS=7 FAIL=0 ====================

连跑两次均 PASS=7 FAIL=0(稳定)。

补充对照(全部零错配零报错):

bash run.sh 1 12 4     # 12/12
bash run.sh 4 6 1      # 24/24  ← 修复前 poolSize=1 报错最集中
bash run.sh 8 12 4     # 96/96
bash run.sh 16 8 2     # 128/128

服务端异常计数:

docker compose logs --no-log-prefix tls-api | grep -cE 'record layer failure|JSON decode failed|bad frame length'
# 0

六、经验与避坑

6.1 可复用的判断法则

凡是"在 fork 之前建立、在 fork 之后被多个子进程共用"的资源,都要问一句: 它是有状态的吗?跨进程共享安全吗?

绝不能跨 fork 共享:TLS/SSL 连接、HTTP 长连接(含 keep-alive 状态)、 数据库连接、带缓冲的文件/管道句柄、内存里的锁与计数器。

相对安全:只读的配置、不可变对象、独立的 fd(每个进程各自 open 的)。

6.2 排查这类问题的"证据链"套路

  1. 给资源打上"建设者身份":连接对象里记 created_pid, 使用前比对 os.getpid(),不一致就标记 INHERITED_BY_FORK。 这一个字段就能把"跨进程共享"从隐形变显形。
  2. 对比服务端与客户端日志:reply requestId=w1-job0 vs 客户端"没收到", 两边的账对不上 → 字节在客户端侧被"别人"读走了。
  3. 对比 worker 数:改成 1 就正常 → 与并发/fork 强相关,而不是参数写错。
  4. 证明内核层共享:fork 后用 socket.fromfd(dup(fd)) 还原再看 getsockname(), 父子一致 + 服务端只 accept 一次 = 同一条连接。
  5. 最小复现:把问题剥到"父进程建连接 → fork 子进程复用", 不依赖 docker/网络就能稳定复现 bad record mac。

6.3 修复时的踩坑(都是血泪)

  1. 归属校验别写在 __init__ 里。第一版写在 TLSConnection.__init__, 结果新建连接也抛 RuntimeError: 连接由 pid=9 创建,当前进程 pid=9 只是继承到了副本, 因为此时 self.sock 还是 None。校验要放在"用连接"的地方(call()), 不是"造连接"的地方。
  2. 不要 close() 继承来的 socket。父子共享同一个内核连接, 子进程 close() 会发 FIN/RST,把父进程/其他子进程正在用的连接一起搞死。 只丢引用,让 GC 回收副本。
  3. 别把连接池整个删掉来"消灾"。第一版让父进程不做 warmup,问题没了, 但验收脚本报 FAIL: 没有检测到父进程预热连接池 —— 因为题目要求的形态是 "保留预热 + 长连接复用"。正解是保留预热、只让连接与进程边界对齐。 更别为了让检查变绿去改验收脚本。
  4. threading.Lock 不是跨进程锁。fork 后每进程一份副本, 多进程可以同时"持有同一把锁"。要跨进程互斥得用 multiprocessing.Lock / flock, 但本问题的正解是根本不需要锁。
  5. ProcessPoolExecutor 同样有这个问题。只要 initializer 或模块顶层 在 fork 前建了连接,子进程就会继承。改成把连接建在 initializer 里(进程内),或加 owner_pid 校验。
  6. --workers 1 能"解决"是陷阱。它只是掩盖了问题,不是修复。

6.4 一句话总结

fork 给子进程复制的是内存副本,不是新的内核资源; TLS 这类有状态连接一旦跨进程共享,序号/缓冲区必然互相踩踏。 修复的落点永远是同一句话:让连接的生命周期与进程边界对齐 —— 谁要用,谁自己建。


附:一键复现命令汇总

cd scenario/2026-09-14-0000

docker compose up -d --build            # 起 tls-api + app
bash run.sh 4 6 4                       # 复现原始故障(修复前的代码)
python3 experiments/fork_probe.py --phase=before   # 最小复现,不依赖 docker

# 服务端视角证据
docker compose logs --no-log-prefix tls-api | grep -E '\[srv\]' | tail -30

# 修复后验收
bash verify.sh                          # 期望 PASS=7 FAIL=0
docker compose down

版权声明

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

分享这篇文章