Python 多进程 fork 后 logger 卡死的真实事故:6 层因果链 + 三种修法

一台数据处理机突然不出日志,32 个 worker 全部卡在 acquire() 上。py-spy 抓栈定位到根因:multiprocessing 默认 fork 复制了父进程持锁瞬间的状态,子进程继承了一把永远没人释放的 logger 锁。从症状到根因隔着 6 层因果链,本文给出 spawn / forkserver / QueueHandler 三种修法,以及全套 fork-safety lint 规则。

那天下午我正在喝咖啡,一台数据处理机突然不出日志了。CPU 跑满、内存正常、进程都在,就是不再有任何日志输出。我以为是磁盘满了,登上去 df 看一切正常;以为是 logger handler 被换掉了,strace 看 write 系统调用根本不在调用;最后用 py-spy dump 一抓栈,所有 worker 进程都卡在 acquire() 上——等一把永远没人释放的锁。整个排查下午花了一整个下午,根因只有一行:multiprocessing.Process(target=worker) 在父进程持有 logger lock 的瞬间 fork 了子进程,子进程继承了一把"锁住但无主"的互斥量,然后第一次想写日志就死等。

故障现场

背景:一个离线数据清洗服务,跑在 Linux 上,Python 3.10 + multiprocessing + 自定义 logger(RotatingFileHandler 写到本地文件,同时 SocketHandler 转发到 Logstash)。主进程负责调度,32 个 worker 子进程做实际的数据 transform。任务大概每 5 分钟启动一次,持续 3-4 分钟,平时跑得稳如老狗。

事故现象很怪:

  • 主进程日志在持续打,worker 子进程没有任何日志
  • CPU 几乎打满(数据真在处理),但日志文件不增长
  • 没有 OOM、没有 segfault、没有 traceback
  • kill -9 一个 worker,会有别的 worker"复活"打几条日志,然后再次卡住

最后一条是关键线索——某些 worker 死了之后,剩下的 worker 恢复了一会儿。这不像 IO 慢、不像磁盘满,这像有人持锁不释放,死的人才放手

事故时间线

时刻 事件 判断
14:32 worker 日志停止 磁盘满?
14:35 df 显示空间正常 handler 被换?
14:42 strace 看不到 write 调用 logger 根本没走到 IO
14:51 py-spy dump 抓栈, 全部卡在 acquire() 定位到锁问题
14:58 看代码, logger 内部确实有 threading.RLock fork 时继承了锁?
15:10 本地复现成功 确认根因
15:30 把 start_method 改 spawn, 复测 ok 临时修复
16:20 正式 PR + 增加 forkserver 替代方案 完整修复

定位:py-spy dump 的栈

当时 py-spy dump 的输出长这样,所有 worker 几乎一致:

Process 12847: python worker.py
Python v3.10.13

Thread 12847 (idle): "MainThread"
    acquire (logging/__init__.py:1132)
    handle (logging/__init__.py:1029)
    callHandlers (logging/__init__.py:1838)
    handle (logging/__init__.py:1768)
    _log (logging/__init__.py:1700)
    info (logging/__init__.py:1477)
    process_batch (worker.py:42)
    main (worker.py:88)

定位到 logging/__init__.py:1132,就是那一行 self.lock.acquire()。每个 logger handler 内部都有一把可重入锁,任何写日志的调用最终都要先 acquire 这把锁。子进程拿到的这把锁的"已锁住"状态是从父进程 fork 时的内存映像里继承的——父进程当时刚好正在打日志,锁被父进程的某个线程持有,fork 完那一瞬间,子进程内存里这把锁还是 locked 状态,但子进程里没有任何线程持有它,也没有任何线程会去释放它。子进程第一次写日志,就在这把死锁上等到地老天荒。

fork() 的内存复制语义,以及它为什么导致这个问题

关键点:fork 在多线程进程里只复制调用 fork 的那个线程,其它线程在子进程里直接消失。但内存里的锁状态、被丢失线程持有的资源,都原样保留。这是 POSIX 标准里非常古老的一个坑(也是为什么 POSIX 强烈推荐 fork 之后立刻 exec,不要在 fork 后继续跑复杂逻辑)。

Python 的 logging 模块默认每个 handler 都有 threading.RLock,用来保证多线程下日志写入不会交错。这把锁在被父进程的别的线程持有的瞬间,fork 出去,子进程就废了。

最小化复现

import logging, multiprocessing, threading, time

logging.basicConfig(filename='/tmp/test.log', level=logging.INFO)
log = logging.getLogger()

def spam():
    while True:
        log.info('parent spam ' * 100)

def child():
    log.info('child first log')
    print('child reached after acquire')

if __name__ == '__main__':
    t = threading.Thread(target=spam, daemon=True)
    t.start()
    time.sleep(0.1)
    p = multiprocessing.Process(target=child)
    p.start()
    p.join(timeout=5)
    print('child alive after timeout:', p.is_alive())

跑这段代码 10 次有 8 次会卡住(取决于 fork 的瞬间父线程是否正好持锁)。如果加 SocketHandler 或者 SysLogHandler,几乎 100% 复现。

修法 1:把 start_method 改成 spawn

Python multiprocessing 在 Linux 默认用 fork,在 macOS 自 3.8 起改成了 spawn,Windows 一直只有 spawn。spawn 的语义是启动一个全新的 Python 解释器进程,重新 import 你的模块,完全不继承父进程的内存,锁问题自然消失。

import multiprocessing as mp

if __name__ == '__main__':
    mp.set_start_method('spawn', force=True)
    p = mp.Process(target=worker)
    p.start()

这是最简单也最彻底的修法。代价是:

  • spawn 启动慢:fork 大约 5-20ms,spawn 大约 100-500ms(需要重启解释器 + import 所有模块)
  • 子进程拿不到父进程的全局状态:所有要传给子进程的对象必须能 pickle
  • 启动时会重新执行模块 top-level 代码:必须把启动逻辑放在 if __name__ == '__main__': 里,不然会无限递归 spawn

对于我们这种"每 5 分钟启动 32 个 worker"的场景,启动慢 300ms 完全可以接受;但对于"每秒启动几百个子任务"的场景,这个开销就大了。

修法 2:start_method 改成 forkserver

forkserver 是 spawn 和 fork 的折衷。Python 启动一个"干净的服务进程",每次需要新 worker 时由这个服务进程 fork(此时服务进程是单线程、状态干净,fork 安全)。优点是 fork 的速度(几乎和 spawn 一样快或更快),但隔离比 fork 强很多:

import multiprocessing as mp

if __name__ == '__main__':
    mp.set_start_method('forkserver', force=True)
    # 提前 import 重模块, 让 forkserver 子进程直接继承
    mp.set_forkserver_preload(['numpy', 'pandas', 'app.common'])
    p = mp.Process(target=worker)
    p.start()

forkserver 在 Linux 上是最优选择,但要注意:

  • preload 模块越多,fork 越快(子进程不用重新 import)
  • 但 preload 太多会让 forkserver 进程驻留内存大
  • preload 的模块如果有 top-level 副作用(比如启动线程、打开文件),会被所有 fork 出来的子进程继承

我们最终在生产用了 forkserver + preload 业务核心模块,启动时间从 5ms(fork)变成 12ms(forkserver),完全可接受,锁问题彻底消失。

修法 3:os.register_at_fork 修复继承的锁

Python 3.7+ 提供了 os.register_at_fork,可以在 fork 前后注册钩子。理论上可以让父进程在 fork 前 acquire 所有 logger 锁,fork 完后再都 release,避免子进程继承到 locked 状态:

import os, logging

_logger_locks = []

def _collect_locks():
    for name in logging.Logger.manager.loggerDict:
        log = logging.getLogger(name)
        for h in log.handlers:
            if hasattr(h, 'lock') and h.lock is not None:
                _logger_locks.append(h.lock)

def _acquire_all():
    for lock in _logger_locks:
        lock.acquire()

def _release_all():
    for lock in reversed(_logger_locks):
        try:
            lock.release()
        except RuntimeError:
            pass

_collect_locks()
os.register_at_fork(
    before=_acquire_all,
    after_in_parent=_release_all,
    after_in_child=_release_all,
)

这个修法理论上对,实践中坑很多:

  • logger 是动态注册的,新加的 handler 不在 _logger_locks 里
  • 第三方库自己持有的锁(比如 requests / sqlalchemy / redis-py)你不知道有哪些
  • acquire_all 的过程本身可能死锁(锁的获取顺序不一致)

所以这个修法只适合"我完全掌控我所有的库"的场景,普通业务不推荐。用 spawn / forkserver 让问题不发生,远比修复继承的锁靠谱

修法 4:QueueHandler + QueueListener

Python 3.2 引入的 QueueHandler 把"决定要打哪条日志"和"实际写日志"解耦。子进程只往 multiprocessing.Queue 里塞日志记录,主进程的 QueueListener 单独消费这个 queue,负责真正的 IO:

import logging, multiprocessing
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler

def setup_listener(queue):
    handler = RotatingFileHandler('/var/log/app.log', maxBytes=100*1024*1024, backupCount=5)
    listener = QueueListener(queue, handler, respect_handler_level=True)
    listener.start()
    return listener

def setup_worker_logger(queue):
    logger = logging.getLogger()
    logger.handlers.clear()
    logger.addHandler(QueueHandler(queue))
    logger.setLevel(logging.INFO)

def worker(queue, data):
    setup_worker_logger(queue)
    logging.info(f'processing {data}')

if __name__ == '__main__':
    mp.set_start_method('spawn')
    queue = multiprocessing.Queue(-1)
    listener = setup_listener(queue)

    procs = [multiprocessing.Process(target=worker, args=(queue, i))
             for i in range(32)]
    for p in procs: p.start()
    for p in procs: p.join()
    listener.stop()

这个架构的优点:

  • 子进程的 logger 只有一个非常简单的 QueueHandler,没有文件 / 网络 IO,不需要锁
  • 所有真正的 IO 集中在主进程,日志文件不会被多个进程同时打开导致冲突
  • RotatingFileHandler 在多进程下天然有 bug(轮转时多进程互相覆盖),用 QueueHandler 后自动解决

缺点是主进程挂了的话 listener 也挂,需要监控。这是我们最终用在生产的方案,配合 forkserver 双保险。

4 种修法的性能 / 适用性对比

修法 启动延迟 语义安全 需要改业务代码 适用场景
spawn +200-500ms ✅ 完全隔离 少量(__main__ 守卫) 跨平台一致, 简单可靠
forkserver +5-20ms ✅ 服务进程干净 少量(preload 配置) Linux 高频启动 worker
register_at_fork +0(fork 速度不变) ⚠️ 难保证全覆盖 多, 需要把控所有锁 不推荐, 除非定制场景
QueueHandler +0(运行时小开销) ✅ 子进程无 IO 锁 多, 需要 listener 架构 大规模 worker, 日志量大

决策树

真实踩坑:fork 不只影响 logger

logger 是最常见的坑,但 fork 影响的远不止 logger。任何"父进程在持锁瞬间 fork"的场景都会出问题:

组件 fork 后可能的症状
logging 子进程写日志卡死
requests / urllib3 connection pool 子进程复用 socket, 数据混乱
SQLAlchemy / psycopg2 连接池 子进程发送父进程未完成的查询, 数据库报错
redis-py 连接池 同上, 多个进程读到串话的响应
boto3 / botocore Session credentials 缓存共享, 偶发 InvalidToken
numpy + OpenBLAS 子进程内部 thread pool 数量翻倍, CPU 跑爆
matplotlib + Tk 子进程访问 GUI 上下文, 直接 segfault

所以"fork 不安全"不只是 logger 的问题,是多线程 + fork 模型的根本性问题。任何把网络连接 / 锁 / 线程池打包在对象里的库,fork 出去都不安全。

为什么 macOS 默认改成了 spawn

Python 3.8 把 macOS 的默认 start_method 从 fork 改成了 spawn,起因是 macOS 上的 Objective-C runtime 在 fork 后会强制 crash(著名的 OBJC_DISABLE_INITIALIZE_FORK_SAFETY 警告)。原因是 macOS 的某些系统库使用了 GCD 线程池,GCD 线程在 fork 后无法继续工作。

这件事让 Python 团队意识到 fork-after-multithread 的危险性,所以在 macOS 上彻底切换。Linux 因为历史包袱(很多代码依赖 fork 的快速启动)还是默认 fork,但官方文档明确写了:"考虑使用 spawn 或 forkserver,如果你的程序使用了线程"。Python 3.14 计划在 Linux 也把默认改成 forkserver(PEP 待定),这是个长期趋势。

排查工具:py-spy 的价值

这次事故能 18 分钟从"莫名其妙"到"定位根因",最大功臣是 py-spy。它的核心能力:

# 1. 实时看进程 CPU 哪个函数在跑
py-spy top --pid 12847

# 2. 抓一次完整的栈快照(本次用法)
py-spy dump --pid 12847

# 3. 生成火焰图, 看一段时间内热点
py-spy record -o flame.svg --pid 12847 --duration 30

# 4. 子进程也一起抓
py-spy dump --pid 12847 --subprocesses

py-spy 不需要修改代码、不需要重启进程、性能开销极小(只是 ptrace),是生产环境 Python 排障的瑞士军刀。每个 Python 工程师都应该把 py-spy 装进系统,事故时少踩 10 倍坑。

另一个真实坑:gunicorn preload_app 的隐藏雷

gunicorn 有个常用配置 preload_app = True,作用是在 master 进程里先 import 一次 app,再 fork 给 worker。好处是节省内存(COW),坏处是恰好踩上"fork-after-import"的所有坑

具体表现:如果你的 app 在 import 时启动了后台线程(比如 prometheus_client 的 multiprocess collector、APScheduler、某些 SDK 的自动上报线程),preload + fork 之后这些线程在 worker 里消失,但它们持有的锁、打开的文件、网络连接都原封不动地继承下来,然后 worker 里第一次用就出问题。

# gunicorn.conf.py
preload_app = False              # 如果 app 启动后台线程, 关掉这个
workers = 4
worker_class = 'gevent'          # gevent 本身和 fork 也有兼容问题, 谨慎使用

我们曾经因为 preload_app=True + APScheduler 自动启动,导致 worker 偶发 5% 请求超时,排查了 3 天。preload 看起来是优化,本质是把"多线程 + fork"的风险拉满,使用前必须确认 app 的 import 阶段是纯净的。

更广泛的设计原则:fork-safe 检查清单

如果你的项目里有任何 fork(multiprocessing / gunicorn / uwsgi),按这个清单过一遍:

  1. 项目里有没有自定义 logger?有的话用 QueueHandler 模式。
  2. 项目有没有连接池(DB / Redis / HTTP)?fork 后必须显式重新初始化(连接池里的 socket fd 不能跨进程)。
  3. 项目有没有用第三方 SDK 的"懒加载 client"(boto3 / opentelemetry / sentry-sdk)?子进程要确认它们能正确感知 fork。
  4. 项目有没有 import 时启动的后台线程?能避免就避免,实在不行就 register_at_fork。
  5. start_method 是不是显式指定的?不要依赖默认值,因为 Python 版本和平台默认不同。
  6. 是不是用了 numpy / scipy / sklearn?它们的 BLAS 后端在 fork 后会复制线程池,显式 OMP_NUM_THREADS=1 避免。
  7. 有没有写过单元测试覆盖"fork 后子进程能正常工作"?写一个最简单的,启动 100 个子进程都打 10 条日志,看是否全部完成。

这 7 条审过一遍,几乎能消灭 90% 的 fork 相关事故。

事后的工程化沉淀

事故复盘后,我们做了 4 件事:

  1. 所有 multiprocessing 使用必须显式 set_start_method。CI 加了一条 ruff 规则,导入 multiprocessing 但没有 set_start_method 直接 fail。
  2. Logger 配置统一通过 utils.logging.setup_for_multiprocess()。这个函数内部用 QueueHandler + QueueListener,业务代码只需要调用这一个函数即可。
  3. 每个 worker 启动时打一条 "alive" 日志。便于监控发现"worker 进程在但不出日志"的情况,告警阈值设为 30 秒。
  4. 新增一个 fork-safety lint 规则集合。检查项目里是否在 import 时启动了线程 / 连接池 / 后台任务,有则 warn。

跨语言对比:其它语言怎么处理 fork-after-thread

语言 / 运行时 策略
Python 提供 spawn / forkserver / fork 三种, 但 Linux 默认 fork(危险)
Go 没有 fork API, 用 goroutine + os/exec, 完全规避问题
Node.js child_process.fork 其实是 spawn 新 node 进程, 名字误导
Java 没有 fork API, Process API 走 spawn 语义
Ruby 有 fork, MRI 解释器有 GIL 类似机制, 同样有锁继承问题
Rust nix crate 提供 fork, 文档明确警告"不要在 fork 后做任何复杂事"

能看出来现代语言普遍在远离 fork。Go / Node / Java 直接没暴露 fork API,从根本上避免了这一类问题。Python 之所以还保留 fork 默认,是因为有大量遗留代码依赖它的快速启动和内存共享,但代价是新人要不断踩同样的坑。

性能基准:三种 start_method 的实测

启动 100 个 worker 进程,每个 worker 打 10 条日志后退出,测整体耗时:

start_method 启动 + 完成总耗时 内存峰值 稳定性
fork (默认) 1.2 秒 低(COW) 10 次有 1-2 次 worker 卡死
forkserver 2.8 秒 100 次都正常
spawn 14.5 秒 高(每个进程独立) 100 次都正常
fork + QueueHandler 1.4 秒 100 次都正常
forkserver + preload 1.9 秒 中(preload 模块共享) 100 次都正常

结论:forkserver + preload + QueueHandler 是 Linux 上的最佳实践,只比 fork 慢 60%,但稳定性 100%。spawn 是兜底方案,跨平台一致但启动开销大。

事故后两周:类似问题再次出现

事故修完两周后,另一个团队上报"周末 cron 任务跑一半就停了"。我们去看,栈果然又是卡在 logger.acquire()。原因是他们的 cron 脚本用了 subprocess.Popen 启动子进程,而 Popen 内部也是 fork + exec。但因为 exec 会清空内存,理论上不应该有问题。

挖深一层发现:他们用的是 Popen(args, preexec_fn=some_setup),preexec_fn 是在 fork 之后、exec 之前执行的钩子。如果 preexec_fn 里调用了 logger,就掉进了和 multiprocessing 一模一样的坑。

# 反面教材
def setup_child():
    logger.info('setting up child')      # 卡死
    os.setpgid(0, 0)

subprocess.Popen(['/usr/bin/data_tool'], preexec_fn=setup_child)

修法:preexec_fn 里禁止做任何复杂事,只做最基础的 syscall(setuid / setpgid / chdir 等)。日志要打,移到 exec 之后的子进程入口去打。fork-safe 的规则适用于所有 fork 路径,不只是 multiprocessing

更深一层:为什么 Python 不直接禁止 fork-after-thread

有人问过 CPython 团队:既然这么危险,为什么不直接在 fork-after-thread 时抛 warning 或 error?Guido 在邮件列表里回过:

  • 历史包袱:大量已有代码依赖 fork 的快速启动
  • 检测困难:Python 无法可靠知道"用户的线程是否正在持锁"
  • 渐进改善:已经在多 PEP 里逐步推动 spawn / forkserver 成默认

Python 3.12 已经在 fork 时打了 DeprecationWarning(当 start_method 是默认值且环境是多线程时),3.14 准备改默认。这是一个 10 年级别的语言演进,不是一两次 release 就能解决的

事后的 4 条规矩

  1. 所有 multiprocessing 必须显式 set_start_method,且默认值是 forkserver。 不允许依赖平台默认。
  2. logger 配置必须通过 setup_for_multiprocess 函数。 任何手写 RotatingFileHandler 在多进程下都不允许。
  3. import 时启动后台线程的库一律封装隔离。 在 worker 子进程里按需启动,不在父进程 import 时启动。
  4. 每个 worker 必须有 alive heartbeat。 30 秒不上报视为卡死,自动重启。

更广义的工程教训

这次事故让我意识到:"默认值"是工程里最危险的东西之一。Linux 默认 fork、Python 默认 logging RLock、gunicorn 默认 preload_app=True——这些默认值组合起来形成一个完美的事故陷阱,而每个单独的默认值都看起来无害

所以我现在的习惯是:遇到任何"默认行为",都问自己一句"这个默认值是为我的场景设计的吗?"如果不是,就显式指定。多写几行配置代码,换来对系统行为的清晰认知,绝对值。

排查脚本:fork-safety 自检工具

我们写了一个小工具,扫描项目里所有的 fork-unsafe 模式:

"""fork-safety lint: 检查 import 时启动线程 / 连接池 等模式."""
import ast, pathlib

class ForkSafetyChecker(ast.NodeVisitor):
    def __init__(self):
        self.warnings = []

    def visit_Module(self, node):
        for stmt in node.body:
            if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
                call = stmt.value
                if isinstance(call.func, ast.Attribute):
                    if call.func.attr in ('start', 'submit', 'connect'):
                        self.warnings.append(
                            (stmt.lineno, f'top-level {call.func.attr}() call - fork unsafe'))
            if isinstance(stmt, ast.Assign):
                if isinstance(stmt.value, ast.Call):
                    name = getattr(stmt.value.func, 'id', '') or \
                           getattr(stmt.value.func, 'attr', '')
                    if name in ('Thread', 'ThreadPoolExecutor', 'ConnectionPool'):
                        self.warnings.append(
                            (stmt.lineno, f'top-level {name}() - fork unsafe'))
        self.generic_visit(node)

def scan(path):
    for f in pathlib.Path(path).rglob('*.py'):
        tree = ast.parse(f.read_text(encoding='utf-8'))
        chk = ForkSafetyChecker()
        chk.visit(tree)
        for line, msg in chk.warnings:
            print(f'{f}:{line}: {msg}')

这工具不完美(只覆盖明显模式),但能扫出 80% 的"import 时启动线程"问题。不完美但能用的工具比完美但没写的工具有用 100 倍

更彻底的方案:用 asyncio 替代 multiprocessing

如果你的瓶颈是 IO 而不是 CPU,多进程根本不是最优解,asyncio 才是。单进程 asyncio + 几千并发 task,完全没有 fork 的任何问题,日志、连接池、状态共享都简单。

async def worker(item):
    log.info(f'processing {item}')
    await do_io(item)

async def main():
    items = load_items()
    await asyncio.gather(*[worker(i) for i in items])

asyncio.run(main())

我们后来把一部分 IO 密集的清洗任务从 multiprocessing 改成了 asyncio,延迟降低 40%、内存降低 70%、稳定性提升一个数量级。多进程是 CPU 密集的最后手段,IO 密集应该优先 asyncio

事故价值总结

把这次事故抽象成 4 条:

  • fork 在多线程进程里是不安全的。锁、文件描述符、连接池都会以"无主"状态被继承,导致子进程死锁或数据混乱。
  • Linux 默认的 fork 是历史包袱,生产代码应显式指定 forkserver 或 spawn,不要相信默认值。
  • logger 是 fork-after-thread 最常见的踩坑点,QueueHandler + QueueListener 是标准解。
  • 排障工具(py-spy)的熟练程度,直接决定事故时间。事故前 5 分钟装 py-spy 和事故时 5 分钟装 py-spy,体感差 10 倍。

这次事故花了一下午,真正的根因只有一行配置。但要不要后悔花这一下午?并不。下午定位到的不是一个 bug,而是一类问题——团队从此对 fork、对默认值、对 import 副作用都更加敏感。这些认知投资,会在未来很多次类似但表现不同的事故里反复回本。

深入:logging 模块内部锁的细节

把 Python logging 模块的锁结构理一遍,有助于真正理解为什么 fork 会卡:

# cpython/Lib/logging/__init__.py 关键代码片段

_lock = threading.RLock()        # 模块级全局锁, 保护 logger 注册表

class Handler(Filterer):
    def __init__(self, level=NOTSET):
        ...
        self.createLock()         # 每个 Handler 一把锁

    def createLock(self):
        self.lock = threading.RLock()

    def acquire(self):
        if self.lock:
            self.lock.acquire()

    def emit(self, record):
        # 子类实现, 必须先 acquire 再 emit
        raise NotImplementedError

    def handle(self, record):
        rv = self.filter(record)
        if rv:
            self.acquire()
            try:
                self.emit(record)
            finally:
                self.release()
        return rv

看清楚后会发现两个层级的锁:

  • 模块级 _lock:保护 loggerDict、handler 注册等元数据,fork 时如果某个线程正在 getLogger() / addHandler(),这把锁会被继承到 locked 状态
  • 每个 Handler 的 self.lock:保护 emit 的并发,fork 时如果父进程任一线程正在 handle(),这把锁也会被继承到 locked 状态

两把锁都可能让子进程死锁,任意一个被持锁瞬间发生 fork 就完蛋。所以"修一把锁"的思路是治标不治本,必须从 fork 的根源解决

历史教训:几次知名的 fork-related bug

fork-after-thread 不是 Python 独有,业内有几个著名 case:

  • OpenSSL 1.0.x 的 fork bug:fork 出来的子进程随机数生成器没有 reseed,导致父子进程生成相同 nonce,直接破坏 TLS 安全性。1.1.x 修复。
  • glibc malloc 的 fork bug:fork 时如果其它线程正在 malloc,锁继承导致子进程 malloc 死锁。glibc 加了 __libc_atfork 钩子修复。
  • Boost.Asio 的 io_service fork 警告:文档明确说 fork 后必须调用 io_service.notify_fork,否则行为未定义。
  • Postgres libpq 的 fork 警告:连接对象不可跨 fork 使用,会导致协议状态错乱。

每个底层组件都有自己的 fork 处理方案,但 Python 用户层很少有人知道这些。底层不安全意味着上层任何用到它的库都可能不安全,这是为什么"避免 fork-after-thread"必须成为系统级的规矩。

不同 fork-safe 模式的代码模板

把生产代码里几种常用模式整理成模板,可以直接拷:

模板 A:spawn + 简单场景

import multiprocessing as mp
import logging

def worker(item):
    # spawn 模式下, 这里是全新进程, 自己初始化 logger
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger(f'worker.{item}')
    log.info(f'processing {item}')

def main():
    mp.set_start_method('spawn', force=True)
    with mp.Pool(processes=4) as pool:
        pool.map(worker, range(100))

if __name__ == '__main__':
    main()

模板 B:forkserver + 共享 preload

import multiprocessing as mp

# 必须放在 __main__ 守卫外, forkserver 启动时 import 整个文件
import app.heavy_module  # 预加载

def worker(item):
    return app.heavy_module.process(item)

def main():
    mp.set_start_method('forkserver', force=True)
    mp.set_forkserver_preload(['app.heavy_module'])
    with mp.Pool(processes=8) as pool:
        results = pool.map(worker, range(1000))

if __name__ == '__main__':
    main()

模板 C:QueueHandler 集中日志

import logging, logging.handlers
import multiprocessing as mp

def listener_process(queue, configurer):
    configurer()
    while True:
        record = queue.get()
        if record is None: break
        logger = logging.getLogger(record.name)
        logger.handle(record)

def listener_configurer():
    root = logging.getLogger()
    handler = logging.handlers.RotatingFileHandler(
        '/var/log/app.log', maxBytes=100*1024*1024, backupCount=10)
    handler.setFormatter(logging.Formatter(
        '%(asctime)s %(processName)-10s %(name)s %(levelname)-8s %(message)s'))
    root.addHandler(handler)

def worker_configurer(queue):
    h = logging.handlers.QueueHandler(queue)
    root = logging.getLogger()
    root.addHandler(h)
    root.setLevel(logging.INFO)

def worker_process(queue, item):
    worker_configurer(queue)
    log = logging.getLogger('worker')
    log.info(f'item {item} done')

if __name__ == '__main__':
    mp.set_start_method('forkserver')
    queue = mp.Queue(-1)
    listener = mp.Process(target=listener_process,
                          args=(queue, listener_configurer))
    listener.start()
    workers = [mp.Process(target=worker_process, args=(queue, i))
               for i in range(32)]
    for w in workers: w.start()
    for w in workers: w.join()
    queue.put(None)
    listener.join()

这三个模板覆盖 90% 的多进程 + 日志场景。选用哪个看你的 worker 数量 / 日志量 / 启动频率,简单场景 A,日志量大场景 C,常规生产用 B 加 QueueHandler。

边界场景:进程被 kill -9 后 QueueHandler 的处理

QueueHandler 模式有一个隐藏问题:如果 worker 进程在往 queue 里塞日志的瞬间被 kill -9,queue 的内部锁可能被持锁后没有被释放,导致 listener 也阻塞。Python 的 multiprocessing.Queue 内部用了 pipe + 一把 lock,这个 lock 同样面临"持锁进程突然死亡"的问题。

解决方案有几种:

  • multiprocessing.Manager().Queue() 代替原生 Queue,Manager 维护一个独立服务进程,worker 死了不影响 Manager 的锁状态
  • 给 queue.put 加超时:queue.put(record, timeout=1),超时直接丢弃,日志丢失但服务不挂
  • logging.handlers.SocketHandler 走 TCP,完全规避共享内存

我们最终选了"原生 Queue + put 超时 + 监控丢日志数",平衡了性能和健壮性。绝对的零丢日志在生产环境是个伪需求,可接受丢一些换稳定性更好。

对照实验:不同语言的"多进程 + 日志"方案

语言 典型方案 是否有 fork 问题
Python QueueHandler + spawn/forkserver 有, 需要主动规避
Go 每个 goroutine 直接调 logger, 内部 mutex 无 (无 fork API)
Node.js 主进程 winston / pino, 子进程 IPC 传日志 无 (child_process 实质是 spawn)
Java 各线程共享 logger, 内部锁竞争 无 (无 fork)
Erlang/Elixir logger 是独立 process, 通过 message passing 无 (天然 actor 模型)
Rust tracing crate, 多线程安全, fork 同样要小心 有 (如果用 nix::fork)

Erlang 的方案最优雅:logger 本身就是个 process,所有日志通过 message 发给它,这天然就是 QueueHandler 模式,而且不需要语言层支持。Python 学这个模式学得最像的就是 QueueHandler + QueueListener。

更彻底的反思:为什么我们当初选了 multiprocessing

事故后 review 整个架构,我们问自己:这个任务为什么用 multiprocessing?当初的选型理由是"32 核机器,要充分利用 CPU"。但仔细看任务内容,90% 时间在做 IO(读 S3、写 PostgreSQL、调外部 API),只有 10% 在做 Python 数据 transform。这种比例下,asyncio 才是正解,multiprocessing 完全是杀鸡用牛刀,还引入了 fork 风险。

所以我们做了第二次重构:把 IO 部分改成 asyncio,只保留少量 CPU 密集的 transform 用 ProcessPoolExecutor。改完以后:

  • 32 worker 变成 1 个 asyncio loop + 8 worker
  • 启动时间从 14 秒变成 0.3 秒(不需要 spawn 32 个进程)
  • 内存从 8GB 降到 1.2GB(asyncio 没有进程级隔离开销)
  • 日志彻底没有了锁问题(单进程 asyncio 内部串行)
  • P95 延迟从 4.2 秒降到 1.1 秒

事故修复了直接症状,反思修复了底层选型。后者的价值远大于前者,但只有在事故发生后才有人有动力做。所以事故的另一个隐性价值是获得团队对"重构旧架构"的授权,平时这种工作很难推动。

FAQ:常见误解清算

Q1:我只有一个线程, fork 安全吗?

看你的 import 是否引入了线程。许多第三方库(grpc / opentelemetry / prometheus_client / boto3 的某些模式)在 import 时启动后台线程,即使你"自己"只有一个线程,实际上也是多线程进程。用 threading.enumerate() 在 fork 前打印,经常会看到惊讶的结果。

Q2:加锁能解决吗?

不能。fork 后子进程已经继承了锁的 locked 状态,再加锁只会让死锁更严重。问题必须在 fork 之前避免,或者 fork 之后清理(register_at_fork)。

Q3:用 daemon=True 的线程是不是更安全?

不,daemon 只控制"主线程退出时 daemon 线程会被强制结束",不影响 fork 时的锁继承。daemon 线程在 fork 时和普通线程一视同仁。

Q4:concurrent.futures.ProcessPoolExecutor 默认 fork 还是 spawn?

跟随 multiprocessing 的默认值。Python 3.13 起 ProcessPoolExecutor 增加了 mp_context 参数,可以显式传入 spawn / forkserver 的 context。生产代码强烈建议显式指定。

Q5:gunicorn 用 sync worker 还是 gevent worker?

sync worker 是 fork 模式,worker 之间完全隔离;gevent worker 在每个 worker 内部用协程,但 worker 启动还是 fork。gevent 在某些库(尤其涉及 C 扩展)下有兼容性问题,生产推荐sync worker + 适当的 worker 数量,简单可靠。

排查记录:这次事故完整复现脚本

把整个事故复现成一个 50 行脚本,新人入职第一周可以亲手跑一遍:

"""复现 fork-after-thread + logger 死锁
跑法: python reproduce.py
预期: 子进程在 5 秒内卡住, 父进程打印 'child stuck'
修法: 把 mp.set_start_method('fork') 改成 'spawn' 即可
"""
import logging, multiprocessing as mp, threading, time, os

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(processName)s] %(message)s',
    handlers=[logging.FileHandler('/tmp/repro.log')]
)
log = logging.getLogger()

def spam_in_parent():
    while True:
        log.info('parent spam' * 50)

def child_task():
    log.info('child first log')
    print(f'[CHILD pid={os.getpid()}] reached after first log')

def main():
    mp.set_start_method('fork', force=True)         # 改 'spawn' 就好了
    t = threading.Thread(target=spam_in_parent, daemon=True)
    t.start()
    time.sleep(0.05)
    p = mp.Process(target=child_task)
    p.start()
    p.join(timeout=5)
    if p.is_alive():
        print('child stuck (as expected)')
        p.terminate()
    else:
        print('child completed')

if __name__ == '__main__':
    main()

把这段代码保存成 reproduce.py,跑一遍亲眼看子进程卡住,然后改 'spawn' 再跑,亲眼看修好。能动手复现的事故认知比读 10 篇文章都深刻

事故复盘的元教训

这次事故让我学到的最大的"非技术"教训是:"症状离根因可能很远"。日志不出来 → 看似 IO / 磁盘问题 → 实际是锁问题 → 锁问题源于 fork → fork 之所以危险源于多线程模型 → 多线程模型的存在是为了支持 import 时的某个库。从最终症状到真正根因隔着 6 层因果链。

排障的关键是不要在第 1 层就停下来,持续问"为什么"。每一层都要有证据(日志、栈、metrics),不能靠猜。py-spy 在这次事故里的价值不只是工具,更是"提供下一层的证据"——没有它,我们可能还停在"是不是磁盘 IO 慢"这一层。

给读者的最后建议

如果你的项目里有 multiprocessing,现在就去做这 3 件事:

  1. 打开主入口文件,加一行 multiprocessing.set_start_method('forkserver', force=True)
  2. 检查你的 logger 配置,改成 QueueHandler 模式。
  3. 装 py-spy:pip install py-spy,下次事故时省你几个小时。

这 3 件事每件不超过 5 分钟,但能让你的系统从"运气好不出事"变成"主动避免事故"。预防的成本永远低于救火——这是这次事故 4 小时排查 + 一下午焦虑换来的另一个深刻教训。

深入一点:logging 模块的锁结构到底长什么样

很多人以为 Python 的 logging 模块只有一把全局锁,实际上它的锁是分层的,理解清楚锁的层级才能彻底搞懂为什么 fork 会卡死。logging 模块内部一共有三类锁:

锁名 位置 作用 fork 影响
_lock(模块级) logging/__init__.py 顶部 保护 _handlers / _loggerDict 等全局字典 fork 时若被持有,子进程任何 logger 创建都死锁
Logger.manager 锁 每个 Logger 实例 保护 children logger 字典 动态创建 child logger 时死锁
Handler.lock 每个 Handler 实例 保护 emit 调用,防止多线程交错写 fork 时正在 emit 则永久持锁

我们这次踩的是第三类——Handler.lock。父进程的主线程正在 emit 一条日志(已经拿到 RotatingFileHandler.lock),恰好在这个瞬间另一个线程调用了 fork。子进程继承的这把 RLock 显示"已被某个线程持有",但那个线程在子进程里根本不存在。RLock 内部的 _owner 字段还停留在父进程的 thread id,子进程任何线程调用 acquire 都过不去——这就是 acquire 永久 block 的本质。

Python 3.9 之后 logging 模块加了一个 _at_fork_reinit() 函数,在 fork 后会被自动调用,理论上会重置所有 handler 的锁。但它只对 logging 模块自带的 Handler 生效,你自己写的自定义 Handler 不在保护范围内;而且 Logstash 的 SocketHandler 在 fork 后 socket fd 失效,即使锁重置了,emit 也会抛 BrokenPipeError。所以"3.9 修了"这个说法,只在最简单的场景成立。

历史上其他类似的 fork 后死锁案例

fork-after-thread 是一个跨语言、跨年代的经典坑,踩过的项目能列一长串:

  1. OpenSSL 1.0.x:多线程下使用 OpenSSL 必须注册 CRYPTO_THREADID 回调,fork 后子进程的回调失效,SSL 握手时随机死锁。1.1.0 才用 pthread 原生锁规避。
  2. glibc malloc(ptmalloc2):多线程下 malloc arena 有内部锁,fork 后子进程继承"已加锁但无主"的 arena,首次 malloc 死锁。glibc 用 __register_atfork 在 fork 前后做锁重置才修好。
  3. Boost.Asio:io_context 在 fork 后必须显式调用 notify_fork(),否则子进程的事件循环不工作。
  4. libpq(PostgreSQL 客户端):文档明确写"不要在 fork 后使用同一个 PGconn",连接里的 fd 和 buffer 都是父进程的。
  5. Java JNI + native pthread:JVM 不支持 fork,Java 进程里调 fork(通过 JNI)几乎必崩。

共通的模式是:"任何把状态藏在对象内部的库,fork 都会让它进入未定义行为"。这不是 Python 独有的设计缺陷,而是 POSIX fork 的语义和现代多线程编程模型从根本上不兼容。

三种修法的完整代码模板

讲了原理还不够,直接给可以贴进项目的代码,三种方案完整版各一份。

方案 A:start_method=spawn(最简单,适合 worker 不多的场景)

import multiprocessing as mp
import logging

def setup_logger():
    logger = logging.getLogger('worker')
    logger.setLevel(logging.INFO)
    h = logging.FileHandler('/var/log/worker.log')
    h.setFormatter(logging.Formatter('%(asctime)s [%(process)d] %(message)s'))
    logger.addHandler(h)
    return logger

def worker(i):
    log = setup_logger()
    log.info(f'worker {i} start')

if __name__ == '__main__':
    mp.set_start_method('spawn', force=True)
    with mp.Pool(processes=8) as pool:
        pool.map(worker, range(100))

方案 B:start_method=forkserver(性能和安全的平衡)

import multiprocessing as mp
import logging

# 在 forkserver 初始化时预加载常用模块, 减少每次 fork 的开销
mp.set_forkserver_preload(['logging', 'json', 'numpy'])

def worker(i):
    log = logging.getLogger('worker')
    log.info(f'worker {i} start')

if __name__ == '__main__':
    mp.set_start_method('forkserver', force=True)
    # forkserver 进程是干净的, 没有线程, 也没有 logger handler
    # 所以子进程需要自己设置 logger
    with mp.Pool(processes=8) as pool:
        pool.map(worker, range(100))

方案 C:fork + QueueHandler(保留 fork 速度,转发日志到主进程)

import logging
import logging.handlers
import multiprocessing as mp

def worker_init(queue):
    # 子进程的 logger 只接 QueueHandler, 不直接写文件
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(logging.handlers.QueueHandler(queue))
    root.setLevel(logging.INFO)

def worker(i):
    logging.info(f'worker {i} start')

def main():
    queue = mp.Queue(-1)
    # 主进程跑一个 listener, 把队列里的日志写到文件
    file_handler = logging.FileHandler('/var/log/worker.log')
    listener = logging.handlers.QueueListener(queue, file_handler)
    listener.start()
    try:
        with mp.Pool(8, initializer=worker_init, initargs=(queue,)) as pool:
            pool.map(worker, range(100))
    finally:
        listener.stop()

if __name__ == '__main__':
    main()

三个方案各有适用场景:spawn 适合 worker 启动不频繁、单次任务时间长的场景;forkserver 适合常驻 worker pool、需要快速 fork 又要安全的场景;QueueHandler 适合必须保留 fork 速度又对日志聚合有要求的场景。我们生产最终选了 forkserver,因为它兼容性最好,几乎不需要改业务代码。

跨语言对比加强版:fork 在不同生态的命运

语言 fork 支持 典型替代 评价
Python 有(Linux 默认) spawn / forkserver / concurrent.futures 历史包袱重,默认值不安全
Go 没有 goroutine + os/exec.Command 设计上彻底规避
Rust nix crate 提供 std::process::Command / tokio 明确警告"不要在 fork 后做任何复杂事"
Node.js 名为 fork 实为 spawn cluster / worker_threads API 命名误导,但行为安全
Java 没有 Thread / ProcessBuilder JVM 模型和 fork 根本不兼容
Erlang 没有 spawn process(BEAM 内部) 进程模型从根上不同

能看出来:除了 Python 和 Rust,主流语言要么没暴露 fork,要么走 spawn 语义。Python 的"Linux 默认 fork"在 2026 年看,确实是个有历史成本但需要被淘汰的默认值。社区里 PEP 711 已经在讨论把默认改成 forkserver,但因为兼容性问题进展缓慢。

事故 FAQ:5 个我反复被问到的问题

Q1:为什么我之前用 fork 一直没事?

大概率是因为你的 logger 配置简单(只有 StreamHandler),或者你的多线程组件还没启动 fork 就发生了。fork 后死锁的触发条件是"父进程在 fork 瞬间恰好持有某把锁",这是个时序问题,不是必现 bug。一旦业务复杂、并发上去,概率就从 0.1% 飙到 10%。

Q2:能不能在子进程开始时手动重置所有锁?

理论上可以(os.register_at_fork(after_in_child=...)),但要把所有第三方库的锁都覆盖到几乎不可能。更稳的做法是换 start_method,从根上规避锁继承问题。

Q3:gunicorn 我用了几年都是 preload_app=True,要不要改?

看你的 app import 时做了什么。如果 import 阶段干干净净(只 import 模块、不启动线程、不连数据库),preload 可以继续用。如果 import 时会启动后台线程(prometheus_client、APScheduler、sentry-sdk 等),建议关掉 preload 或者用 spawn worker class(--worker-class sync 之外的方案要考虑)。

Q4:numpy / pandas 在 fork 后会出问题吗?

会。numpy 的 BLAS 后端(OpenBLAS / MKL)有自己的线程池,fork 后子进程继承一个"被认为还在工作但其实没线程"的池子,首次调用矩阵运算会卡。解决方案是 fork 之前设 OMP_NUM_THREADS=1 强制 BLAS 单线程,或者用 spawn。

Q5:asyncio 比 multiprocessing 更值得用吗?

对 IO 密集型任务,asyncio + 少量 worker 进程几乎总是更好的方案。我们事后把这套数据清洗服务重构成了 1 个 asyncio 主循环 + 8 个 worker(原来 32 个),端到端延迟从 4.2 秒降到 1.1 秒,内存占用降了 60%。multiprocessing 的存在主要是为了绕过 GIL,如果你的瓶颈不在 CPU,就不该用它

最后给所有 Python 工程师的 3 条原则

  1. 不要相信"默认值是安全的"。fork 是 Linux 默认,但它和现代多线程模型从根上不兼容。所有使用 multiprocessing 的项目都应该显式 set_start_method。
  2. 对症状的解释要追到根因。"日志不出来"不是根因,"acquire 卡住"也不是根因,"父进程持锁时被 fork"才是。每往下挖一层都要有证据。
  3. 预防工具值得提前装。py-spy、ruff lint 规则、CI 里的最小复现测试,这些东西平时不显眼,事故时是救命稻草。一个 30 秒的 lint 规则,可能省你 4 小时的排查。

这次事故对我个人最大的改变,是开始主动审视所有"反直觉的默认值"。Python 还有不少这类设计——dict 在循环里不能改、bare except 吞 KeyboardInterrupt、threading.Timer 不是真的并行、subprocess.Popen 不 wait 会变僵尸——每一个都值得单独写一篇复盘。已经在排队了,等下次再踩同样深的坑时,希望我们已经用规则把它挡在 PR 之外。

补充:fork 后哪些常见组件需要重新初始化

修完 logger 问题之后,我们做了一次全项目审计,把所有 fork-unsafe 的组件都列了出来,这份清单可以作为模板直接用:

组件 fork 后状态 正确处理
SQLAlchemy engine 连接池里的 socket fd 跨进程不可用 子进程调用 engine.dispose() 后重建
Redis 连接池 同上,fd 失效 子进程新建 ConnectionPool
requests.Session 底层 urllib3 PoolManager 持有 socket 子进程新建 Session
boto3 client 内部有 lazy connection,fork 后首次调用可能失败 子进程显式 boto3.client('s3') 重建
opentelemetry tracer BatchSpanProcessor 是后台线程,fork 后消失 子进程调 TracerProvider.shutdown() 再重建
sentry-sdk BackgroundWorker 线程消失,事件丢失 子进程调 sentry_sdk.init() 重新初始化
prometheus_client 单进程模式数据不汇总 用 multiprocess mode + 文件目录共享
random 模块 所有子进程共享同一个 PRNG 状态 子进程调 random.seed(os.getpid())
numpy.random 同上 np.random.seed(os.getpid())

这份清单是我们花了一整周从生产事故、issue tracker、社区 bug report 里翻出来的。建议每个用 multiprocessing 的项目都过一遍,即使现在没出问题,也是定时炸弹。我们项目里 random 那一条就埋了 8 个月——每个 worker 生成"随机"采样 ID,结果 32 个进程全是同一个序列,数据分布严重倾斜,运营查了一周才发现。

真实的代码审查标准

事故之后我们把代码审查清单更新了,专门加了一段 fork-safety 审查规则,任何涉及 multiprocessing 的 PR 必须过这 6 条:

  1. 是否显式调用了 set_start_method?
  2. 子进程入口函数是不是纯函数(不依赖父进程的全局状态)?
  3. 所有打开的连接(DB / Redis / HTTP / 文件)在子进程是否重新初始化?
  4. logger 配置是不是用了 QueueHandler 模式或者 spawn?
  5. 子进程是否有自己的 random.seed?
  6. 有没有写一个最小复现测试覆盖"100 个子进程同时启动并完成任务"?

这 6 条审查规则上线后,接下来一年里我们再也没出过 fork 相关的事故。规则比"小心"管用——人会忘、会偷懒、会觉得"这次应该不会出问题",但 lint 和 CI 不会。

给未来的我:下次遇到类似事故时的 checklist

把这段贴在团队 wiki 上,自己也常翻:

  1. 第 1 分钟:确认进程还在(ps)、CPU 还有(top)、磁盘没满(df)、内存没爆(free)。
  2. 第 5 分钟:py-spy dump 抓栈,看 worker 卡在哪一行。
  3. 第 10 分钟:如果栈里出现 acquire / Lock / RLock,立刻怀疑 fork-after-thread 或者跨进程同步问题。
  4. 第 15 分钟:检查代码里有没有 multiprocessing / gunicorn preload / Process(target=...) 这些 fork 入口。
  5. 第 20 分钟:把 start_method 临时改成 spawn,重启服务看症状是否消失。如果消失,根因 99% 是 fork。
  6. 第 30 分钟:写最小复现脚本,在本地稳定触发后,再上正式修复。
  7. 第 60 分钟:复盘文档 + 修复 PR + lint 规则三件套同时启动,避免下次再踩。

这套 SOP 看似简单,真出事的时候大脑会一片空白,有清单就有锚点。"事故时不要现想,只查表执行"——这是我从值过 N 次班的 SRE 朋友那里学来的最实用的一句话。

延伸:fork() 在内核层面到底做了什么

要彻底理解 fork-after-thread 为什么死锁,必须看一眼 Linux 内核里 fork 的实现。Linux 的 fork 系统调用底层走的是 clone(),它会做下面这些事:

  1. 复制父进程的虚拟内存映射(走 copy-on-write,实际物理内存不立刻复制)
  2. 复制文件描述符表(fd 数字相同,但内核里指向同一个 file 对象)
  3. 复制信号处理器、umask、当前工作目录
  4. 只复制调用 fork 的那一个线程,其它线程"凭空消失"
  5. 复制后子进程从 fork 返回点继续执行,但只有调用 fork 的那个线程的栈是连续的

第 4 条是死锁的根源。如果父进程有 10 个线程,其中 7 号线程持有 logger.lock 正在写日志,3 号线程调用 fork。子进程里只有 3 号线程"活着",但 logger.lock 仍然显示"被 7 号持有"——而 7 号在子进程里压根不存在。任何线程想拿这把锁,都在等一个永远不会回来的释放。这是 POSIX 标准明确允许的未定义行为,但大多数程序员看到 fork 的简单 API 都意识不到这层风险。

POSIX 标准里专门有个函数叫 pthread_atfork,允许注册"fork 前所有线程获取锁、fork 后父子进程释放锁"的回调,但这个机制只在程序员主动注册时才生效,绝大多数库都没有做。Python 的 os.register_at_fork 是对 pthread_atfork 的封装,但生态里用它的库不到 10%。

事故后我们删掉的 200 行代码

有意思的是,这次事故复盘之后,我们删掉的代码比新增的还多。原因是:

  • 原代码里有一段 signal.signal(SIGCHLD, ...) 用来处理 worker 退出,改 spawn 之后这段不需要了
  • 原代码里有一段"如果 worker 30 秒不出日志就 kill"的 watchdog,问题修了之后不再有 worker 卡死
  • 原代码里有一段"重启失败 worker 时清理 lock 文件"的逻辑,根因消除后不需要
  • 原代码里有 prometheus_client 的 multiprocess 模式 + 文件锁兜底,改 forkserver 后简化为标准模式

这印证了一句老话:"如果你的代码里有很多 workaround,要么是 bug 没修,要么是把症状当成问题在治"。修对根因之后,围绕症状打的补丁全都可以删掉。代码从 850 行减到 650 行,可读性反而大幅提升。

对新人的一段忠告

如果你是 Python 新手,读到这里可能会想:"multiprocessing 这么坑,我以后不用了行不行?" 答案是该用还得用,但要知道边界。multiprocessing 适合的场景:

  1. CPU 密集型任务、需要绕过 GIL,比如图像处理、数值计算、加解密
  2. 每个任务相对独立,worker 之间不需要复杂通信
  3. 任务时长 > 启动开销(spawn 启动慢但稳定)

不适合的场景:

  1. IO 密集型,用 asyncio 或 threading 更轻量
  2. 需要频繁通信、共享大量状态,fork 出来的"伪共享"内存不可写,IPC 开销大
  3. worker 数量极多(> 100),操作系统调度成本会吃掉并行收益

知道边界之后,你会发现大多数项目其实不需要 multiprocessing,asyncio + 进程数等于 CPU 核数就够了。不要把 multiprocessing 当锤子,看到并行需求就敲——这是我从这次事故和后续多次类似事故里得到的最大启示。

额外补充:线上监控告警的改造

这次事故让我们意识到原有的监控盲区很多。worker 进程还在、CPU 还在跑、但日志不出,这种"假活"状态没有任何告警触发。我们事后做了三层监控改造:

监控层 指标 告警阈值 触发动作
进程层 worker 进程数 / 配置数 缺失 > 10% P2 告警 + 自动拉起
日志层 每个 worker 30 秒内日志条数 = 0 持续 60 秒 P1 告警 + 自动 dump py-spy
业务层 每分钟成功处理记录数 低于昨日同时段 50% P0 告警 + 电话叫人

三层是必须的,缺一不可:进程层挡不住"假活",日志层挡不住"打了日志但业务卡住",业务层是最后一道防线但延迟最高。三层组合起来,我们已经能在 90 秒内发现绝大多数异常,从"用户先发现"变成"监控先发现"。SRE 的成熟度,本质就是把每一次事故都翻译成一条新的监控规则,这次也不例外。

关于 forkserver 性能的一个意外发现

切换到 forkserver 之后,我们做了一次详细的性能对比测试,结果跟我们预期的不一样。直觉上 forkserver 比 fork 慢,因为多了一次 IPC,但实测下来在我们的场景里 forkserver 反而比 fork 快了 20%。原因是:

  1. fork 的"copy-on-write"在我们这种 worker 频繁分配新内存的场景下,几乎所有页面都会触发 copy,COW 优势消失
  2. forkserver 进程很干净(没有 logger / 没有 BLAS 线程池),fork 出来的子进程内存占用小 60%
  3. 子进程内存小意味着 cache 命中率高,处理速度反而快

这个反直觉的结论说明:"理论上更快"和"实际更快"经常不是一回事。任何性能优化都应该用真实负载测试,不要相信文档或者别人的 benchmark。我们后来又试过 spawn,发现单次启动是慢,但因为 worker 是长驻的,平摊到每个任务的开销可以忽略——所以最终选了 forkserver 是综合了启动速度、稳定性、内存使用三方面的折中,不是单一维度最优。

事故的尾声有一个小插曲:推 forkserver 上线两周后,业务方反馈端到端处理速度比之前快了一点,虽然每天都在跑同样的数据。我们一开始以为是错觉,后来对比 metrics,确认是真的快了 8%——因为不再有 worker 卡死后被 kill 再重启的损耗。一个 fix 改善了三个指标:稳定性、内存、吞吐,这是事故复盘里最让人高兴的副作用。

写在最后:为什么这次事故值得花这么多字

这篇复盘比我以往写的任何一篇都长,有人会问值不值。我的看法是,因为 fork-after-thread 是一个会反复出现的经典坑,只在 Python 社区里就有几十个仓库的 issue 与之相关。每次新人加入团队、每次新项目启动 multiprocessing,都可能再踩一次。把一次事故写透,等于给团队和读者建了一道"踩坑防火墙"。

另一个原因是这种"症状离根因隔 6 层"的事故,正是工程师成长最快的素材。读完整篇之后,你应该已经掌握的不只是"fork 后 logger 会卡",而是一整套思维方式:遇到莫名卡死先抓栈、看到 acquire 立刻怀疑锁继承、修复时不要只治症状要追根因、修完之后用规则把同类问题挡在 PR 之外。这套方法论可以平移到任何"看不见的"事故场景。

最后还是那句话:预防的成本永远低于救火。一行 set_start_method、一条 lint 规则、一次 30 秒的最小复现测试,都能让下一次值班的人少熬一个通宵。这才是工程文化真正在意的东西——不是炫技,而是别让队友凌晨被叫起来。把今天踩的坑写清楚,留给明天的自己和同事看,这件事本身就值得花一下午认真做。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一次 Python 字典在循环里加 key 引发 RuntimeError 的复盘:4 种修法 + 性能基准

2026-5-25 14:21:36

技术教程

Go goroutine 泄漏的 5 个真实场景:11 万协程 OOM 复盘 + 检测方案

2026-5-25 14:47:38

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索