那天下午我正在喝咖啡,一台数据处理机突然不出日志了。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),按这个清单过一遍:
- 项目里有没有自定义 logger?有的话用 QueueHandler 模式。
- 项目有没有连接池(DB / Redis / HTTP)?fork 后必须显式重新初始化(连接池里的 socket fd 不能跨进程)。
- 项目有没有用第三方 SDK 的"懒加载 client"(boto3 / opentelemetry / sentry-sdk)?子进程要确认它们能正确感知 fork。
- 项目有没有 import 时启动的后台线程?能避免就避免,实在不行就 register_at_fork。
- start_method 是不是显式指定的?不要依赖默认值,因为 Python 版本和平台默认不同。
- 是不是用了 numpy / scipy / sklearn?它们的 BLAS 后端在 fork 后会复制线程池,显式
OMP_NUM_THREADS=1避免。 - 有没有写过单元测试覆盖"fork 后子进程能正常工作"?写一个最简单的,启动 100 个子进程都打 10 条日志,看是否全部完成。
这 7 条审过一遍,几乎能消灭 90% 的 fork 相关事故。
事后的工程化沉淀
事故复盘后,我们做了 4 件事:
- 所有 multiprocessing 使用必须显式 set_start_method。CI 加了一条 ruff 规则,导入 multiprocessing 但没有 set_start_method 直接 fail。
- Logger 配置统一通过 utils.logging.setup_for_multiprocess()。这个函数内部用 QueueHandler + QueueListener,业务代码只需要调用这一个函数即可。
- 每个 worker 启动时打一条 "alive" 日志。便于监控发现"worker 进程在但不出日志"的情况,告警阈值设为 30 秒。
- 新增一个 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 条规矩
- 所有 multiprocessing 必须显式 set_start_method,且默认值是 forkserver。 不允许依赖平台默认。
- logger 配置必须通过 setup_for_multiprocess 函数。 任何手写 RotatingFileHandler 在多进程下都不允许。
- import 时启动后台线程的库一律封装隔离。 在 worker 子进程里按需启动,不在父进程 import 时启动。
- 每个 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 件事:
- 打开主入口文件,加一行
multiprocessing.set_start_method('forkserver', force=True)。 - 检查你的 logger 配置,改成 QueueHandler 模式。
- 装 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 是一个跨语言、跨年代的经典坑,踩过的项目能列一长串:
- OpenSSL 1.0.x:多线程下使用 OpenSSL 必须注册 CRYPTO_THREADID 回调,fork 后子进程的回调失效,SSL 握手时随机死锁。1.1.0 才用 pthread 原生锁规避。
- glibc malloc(ptmalloc2):多线程下 malloc arena 有内部锁,fork 后子进程继承"已加锁但无主"的 arena,首次 malloc 死锁。glibc 用
__register_atfork在 fork 前后做锁重置才修好。 - Boost.Asio:io_context 在 fork 后必须显式调用
notify_fork(),否则子进程的事件循环不工作。 - libpq(PostgreSQL 客户端):文档明确写"不要在 fork 后使用同一个 PGconn",连接里的 fd 和 buffer 都是父进程的。
- 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 条原则
- 不要相信"默认值是安全的"。fork 是 Linux 默认,但它和现代多线程模型从根上不兼容。所有使用 multiprocessing 的项目都应该显式 set_start_method。
- 对症状的解释要追到根因。"日志不出来"不是根因,"acquire 卡住"也不是根因,"父进程持锁时被 fork"才是。每往下挖一层都要有证据。
- 预防工具值得提前装。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 条:
- 是否显式调用了
set_start_method? - 子进程入口函数是不是纯函数(不依赖父进程的全局状态)?
- 所有打开的连接(DB / Redis / HTTP / 文件)在子进程是否重新初始化?
- logger 配置是不是用了 QueueHandler 模式或者 spawn?
- 子进程是否有自己的 random.seed?
- 有没有写一个最小复现测试覆盖"100 个子进程同时启动并完成任务"?
这 6 条审查规则上线后,接下来一年里我们再也没出过 fork 相关的事故。规则比"小心"管用——人会忘、会偷懒、会觉得"这次应该不会出问题",但 lint 和 CI 不会。
给未来的我:下次遇到类似事故时的 checklist
把这段贴在团队 wiki 上,自己也常翻:
- 第 1 分钟:确认进程还在(
ps)、CPU 还有(top)、磁盘没满(df)、内存没爆(free)。 - 第 5 分钟:py-spy dump 抓栈,看 worker 卡在哪一行。
- 第 10 分钟:如果栈里出现
acquire/Lock/RLock,立刻怀疑 fork-after-thread 或者跨进程同步问题。 - 第 15 分钟:检查代码里有没有 multiprocessing / gunicorn preload / Process(target=...) 这些 fork 入口。
- 第 20 分钟:把 start_method 临时改成 spawn,重启服务看症状是否消失。如果消失,根因 99% 是 fork。
- 第 30 分钟:写最小复现脚本,在本地稳定触发后,再上正式修复。
- 第 60 分钟:复盘文档 + 修复 PR + lint 规则三件套同时启动,避免下次再踩。
这套 SOP 看似简单,真出事的时候大脑会一片空白,有清单就有锚点。"事故时不要现想,只查表执行"——这是我从值过 N 次班的 SRE 朋友那里学来的最实用的一句话。
延伸:fork() 在内核层面到底做了什么
要彻底理解 fork-after-thread 为什么死锁,必须看一眼 Linux 内核里 fork 的实现。Linux 的 fork 系统调用底层走的是 clone(),它会做下面这些事:
- 复制父进程的虚拟内存映射(走 copy-on-write,实际物理内存不立刻复制)
- 复制文件描述符表(fd 数字相同,但内核里指向同一个 file 对象)
- 复制信号处理器、umask、当前工作目录
- 只复制调用 fork 的那一个线程,其它线程"凭空消失"
- 复制后子进程从 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 适合的场景:
- CPU 密集型任务、需要绕过 GIL,比如图像处理、数值计算、加解密
- 每个任务相对独立,worker 之间不需要复杂通信
- 任务时长 > 启动开销(spawn 启动慢但稳定)
不适合的场景:
- IO 密集型,用 asyncio 或 threading 更轻量
- 需要频繁通信、共享大量状态,fork 出来的"伪共享"内存不可写,IPC 开销大
- 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%。原因是:
- fork 的"copy-on-write"在我们这种 worker 频繁分配新内存的场景下,几乎所有页面都会触发 copy,COW 优势消失
- forkserver 进程很干净(没有 logger / 没有 BLAS 线程池),fork 出来的子进程内存占用小 60%
- 子进程内存小意味着 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