别被 Process.start() 骗了:Python multiprocessing 从面试题到线上生产的进程启动机制实战
很多 Python 面试题里,multiprocessing 看起来非常简单:
python
from multiprocessing import Process
def worker():
print("working...")
p = Process(target=worker)
p.start()
p.join()
于是我们很容易形成一种直觉:
Process.start()不就是创建一个子进程,然后执行worker()吗?
在一个十几行的演示程序里,确实差不多。
但到了生产环境,事情会突然变得复杂。
你的主进程可能已经加载了数百 MB 数据;Web 服务里可能存在日志线程、监控线程、OpenTelemetry exporter;SQLAlchemy 已经创建了数据库连接池;Redis、HTTP Client、TLS socket 已经初始化;某个 C 扩展内部甚至悄悄启动了线程。
此时一句:
python
Process(target=worker).start()
真正的问题已经不是"如何创建进程",而是:
这个新进程到底继承什么?重新初始化什么?重新 import 什么?哪些资源看起来还在,实际上已经不能安全使用?
这才是 multiprocessing 在线上远比面试题复杂的原因。
一、理解 multiprocessing,先理解三种 start method
Python multiprocessing 目前主要存在三种进程启动机制:
| 启动方式 | 核心思想 | 启动速度 | 状态继承 | 主要风险 |
|---|---|---|---|---|
fork |
直接复制父进程 | 快 | 很多 | 线程、锁、socket、数据库连接 |
spawn |
启动全新 Python 解释器 | 慢 | 很少 | import、副作用、pickle |
forkserver |
由专门的单线程 server 执行 fork | 较快 | 有控制地继承 | pickle、import、预加载设计 |
真正关键的是:默认值并不是永远不变的。
Python 3.8 起,macOS 默认采用 spawn;Python 3.14 又进一步改变了 POSIX 平台的默认行为:Linux 等支持 forkserver 的 POSIX 平台默认从 fork 改成了 forkserver,而 fork 在 Python 3.14 已经不再是任何平台的默认启动方法。Windows 和 macOS 仍然默认 spawn。(Python documentation)
这意味着:
python
Process(target=worker).start()
在不同 Python 版本、不同操作系统上,背后的执行模型可能完全不同。
这也是升级 Python 后首先应该警觉的事情。
二、fork:看起来最快,但它继承得"太多了"
POSIX 上的 fork() 可以近似理解为:
text
Parent Process
|
| fork
v
Child Process
子进程刚创建时,虚拟内存空间看起来几乎就是父进程的复制品。
但操作系统通常不会马上完整复制全部物理内存,而是使用 Copy-On-Write,写时复制。
假设父进程已经加载了一个 2 GB 的模型:
python
MODEL = load_big_model()
然后:
python
ctx = multiprocessing.get_context("fork")
for _ in range(8):
ctx.Process(target=worker).start()
并不代表系统立即分配:
text
2 GB × 8 = 16 GB
很多只读页面可以暂时共享。
这正是过去大量 Linux Python 服务喜欢 fork 的重要原因之一。
但 Copy-On-Write 不等于"免费共享内存"
Python 本身会不断修改对象元数据。
例如:
- 引用计数变化;
- GC 遍历对象;
- 内存分配器修改 arena/page;
- 字典、列表发生写入;
- 缓存被更新。
一旦某个内存页发生写操作,这个 page 就可能从共享变成私有副本。
所以一个服务刚启动时:
text
Master: 2 GB
Worker × 8
看起来 RSS/PSS 很漂亮。
运行几个小时以后,却可能不断膨胀。
Python 官方甚至专门提供了 gc.freeze(),用于某些明确采用 fork() 且希望减少 GC 导致 Copy-On-Write 的高级场景。官方建议这种特殊模型可以考虑在父进程早期关闭 GC、fork 前 freeze,子进程再重新启用 GC。(Python documentation)
但这属于测量之后才能使用的高级优化,不要为了"省内存"盲目加入生产代码。
三、spawn:没有继承包袱,却带来了 import 问题
spawn 的思路完全不同。
它更接近:
text
Parent
|
+----> New Python Interpreter
|
+--> import
+--> unpickle target
+--> execute worker
不是把当前 Python 世界直接复制过去,而是重新启动解释器。
因此:
python
GLOBAL_CONFIG = load_config()
def worker():
print(GLOBAL_CONFIG)
你不能再简单把它理解成:
子进程一定能看到
Process.start()那一刻父进程中的所有运行时状态。
spawn 和 forkserver 对可序列化性要求明显更严格。目标函数和很多传给子进程的对象需要能够被 pickle,同时主模块必须能够被安全重新导入。Python 官方明确要求此类程序保护主入口。(Python documentation)
正确:
python
import multiprocessing as mp
def worker(task_id):
print(task_id)
if __name__ == "__main__":
ctx = mp.get_context("spawn")
p = ctx.Process(
target=worker,
args=(1001,),
)
p.start()
p.join()
不推荐:
python
if __name__ == "__main__":
worker = lambda x: print(x)
Process(target=worker, args=(1,)).start()
嵌套函数、lambda,以及绑定复杂不可序列化状态的对象,都可能让 spawn 下的问题暴露出来。
而这些代码以前如果一直运行在 fork 下,可能很多年都没有出错。
这就是为什么 Python 升级以后,经常突然出现:
text
PicklingError
或者:
text
AttributeError:
Can't pickle local object ...
并不一定是 Python "把 multiprocessing 改坏了"。
有可能只是以前 fork 帮你隐藏了架构问题。
四、import 副作用,是 spawn/forkserver 下最容易忽略的问题
看下面这段代码:
python
# app.py
engine = create_database_engine()
client = create_redis_client()
start_metrics_thread()
configure_logging()
def worker():
...
很多项目喜欢把初始化逻辑直接写到模块顶层。
对于普通单进程程序问题不大。
但在 multiprocessing 世界里,import 本身必须尽量没有业务副作用。
更好的设计应该是:
python
def create_resources():
engine = create_database_engine()
client = create_redis_client()
return engine, client
def worker(task):
engine, client = create_resources()
try:
process(task, engine, client)
finally:
engine.dispose()
把:
text
import module
和:
text
initialize runtime resources
区分开来。
这是写出真正可迁移 Python multiprocessing 程序的重要一步。
五、线程为什么会让 fork 变成定时炸弹?
这是生产环境最危险的一部分。
假设父进程存在:
text
Main Thread
Logging Thread
Metrics Thread
HTTP Thread
Background Worker Thread
此时 Main Thread 调用了:
python
fork()
子进程中会发生一个非常反直觉的结果:
只有执行 fork 的那个线程继续存在。
其他线程不会跟着完整"复制运行"。
问题在于,它们曾经持有的某些锁或 C 库状态却可能留在那里。
例如:
python
lock.acquire()
恰好是 Logging Thread 拿到了锁。
此刻 Main Thread fork。
子进程得到:
text
lock = locked
但是:
text
Logging Thread = 不存在
于是这个锁可能永远没人释放。
结果不是漂亮地抛异常,而往往是最让运维头疼的:
text
Worker 卡住了。
CPU 0%。
没有 traceback。
没有明显异常。
Python 3.12 开始,如果 CPython 能检测到当前进程已经有多个线程,fork 路径可能产生 DeprecationWarning。官方明确指出,多线程进程中安全使用 fork() 本身就存在根本性问题。(Python documentation)
所以线上看到:
text
DeprecationWarning related to fork()
不要第一反应:
python
warnings.filterwarnings("ignore")
真正应该问的是:
谁在 fork 之前启动了线程?
六、数据库连接:文件描述符复制了,不代表连接安全复制了
这是另一个经典事故现场。
例如:
python
engine = create_engine(DB_URL)
def worker():
with engine.connect() as conn:
...
如果采用 fork,子进程可能继承数据库 TCP socket 对应的文件描述符。
问题来了:
text
Parent
\
同一个底层 socket
/
Child
两个独立 Python 进程可能认为:
这是我的数据库连接。
但数据库服务器只看到一条连接。
于是可能出现:
text
protocol error
connection reset
unexpected response
transaction state corruption
偶发查询失败
更麻烦的是,它可能不是 100% 复现。
SQLAlchemy 官方明确提醒:连接池中的连接不能直接跨 fork 后的进程共享;推荐确保子进程使用自己新建的数据库连接。对于确实继承了 Engine 的场景,可以在子进程初始化阶段使用 engine.dispose(close=False),使子进程获得新的连接池,而不去关闭父进程正在使用的连接。(SQLAlchemy Documentation)
一个更清晰的设计是:
python
def worker(task):
engine = create_engine(DB_URL)
try:
with engine.begin() as conn:
handle_task(conn, task)
finally:
engine.dispose()
核心原则只有一句:
配置可以传,活连接不要传。
下面这些对象都应该保持高度警惕:
text
DB Connection
SQLAlchemy Session
Redis connection
HTTP persistent connection
TLS socket
Kafka consumer
message broker channel
进程边界应该被视为资源生命周期边界。
七、logging:能 print 出来,不代表日志系统是进程安全的
另一个容易出现错觉的地方:
python
logger.info("processing task")
单进程运行很完美。
开 16 个 worker 后:
text
日志重复
日志交叉
JSON 被写成半行
rotation 异常
某些日志直接消失
Python 官方指出,标准 logging 的某些 handler 并不会自动提供跨进程共享锁;多个进程直接写同一个文件存在混杂风险。官方 Logging Cookbook 推荐的一种典型方案就是让 worker 通过 QueueHandler 发日志,由一个统一的 listener 负责真正输出。(Python documentation)
例如:
python
import logging
import multiprocessing as mp
from logging.handlers import QueueHandler, QueueListener
def configure_worker_logging(log_queue):
root = logging.getLogger()
root.handlers.clear()
root.setLevel(logging.INFO)
root.addHandler(QueueHandler(log_queue))
def worker(task_id, log_queue):
configure_worker_logging(log_queue)
logger = logging.getLogger(__name__)
logger.info(
"processing task=%s pid=%s",
task_id,
mp.current_process().pid,
)
def main():
available = mp.get_all_start_methods()
method = (
"forkserver"
if "forkserver" in available
else "spawn"
)
ctx = mp.get_context(method)
log_queue = ctx.Queue()
output_handler = logging.StreamHandler()
listener = QueueListener(
log_queue,
output_handler,
)
listener.start()
processes = [
ctx.Process(
target=worker,
args=(task_id, log_queue),
)
for task_id in range(4)
]
for process in processes:
process.start()
for process in processes:
process.join()
listener.stop()
if __name__ == "__main__":
main()
生产系统里,可以把真正输出日志的角色集中起来:
text
Worker 1 ─┐
Worker 2 ─┼─> multiprocessing.Queue
Worker 3 ─┤ |
Worker 4 ─┘ v
Log Listener
|
stdout / file / collector
这样日志资源的 ownership 会清楚得多。
八、forkserver:为什么 Python 3.14 把它推到了更重要的位置?
forkserver 可以看成对 fork 和 spawn 的折中。
程序会先启动一个专门的 fork server:
text
Main Process
|
| request
v
Fork Server
/ | \
/ | \
W1 W2 W3
这个 server 原则上保持单线程,然后由它执行 fork。
最大的意义就是:
避免从一个已经被数据库、日志、监控、Web 框架搞得非常复杂的多线程主进程直接 fork。
Python 官方说明,forkserver 本身通常保持单线程,而且不会把父进程中那些没有必要的资源全部继承给 worker。Python 3.14 正是为了在保留较好性能特征的同时规避常见多线程 fork 问题,把它设为了支持该机制的 POSIX 平台默认方法。(Python documentation)
如果启动成本主要来自大型模块 import,还可以研究:
python
import multiprocessing as mp
mp.set_forkserver_preload([
"numpy",
"your_heavy_module",
])
这样 forkserver 可以提前加载部分模块,新 worker 再继承这些状态。
但有一个重要前提:
被 preload 的模块最好不要在 import 阶段启动后台线程。
否则你又可能把 forkserver 从"干净的单线程进程"变复杂了。
九、一个更适合生产环境的 multiprocessing 模板
我更推荐显式选择上下文,而不是到处直接:
python
multiprocessing.Process(...)
例如:
python
import logging
import multiprocessing as mp
import os
import platform
import sys
def worker(task):
logger = logging.getLogger(__name__)
logger.info(
"pid=%s task=%r",
os.getpid(),
task,
)
# 在 worker 内初始化进程私有资源
# db = create_db()
# redis = create_redis()
# client = create_http_client()
return task
def choose_context():
available = mp.get_all_start_methods()
if "forkserver" in available:
return mp.get_context("forkserver")
return mp.get_context("spawn")
def main():
ctx = choose_context()
print("python:", sys.version)
print("platform:", platform.platform())
print("start method:", ctx.get_start_method())
workers = [
ctx.Process(
target=worker,
args=(i,),
)
for i in range(4)
]
for p in workers:
p.start()
for p in workers:
p.join()
if p.exitcode != 0:
raise RuntimeError(
f"worker {p.pid} failed: {p.exitcode}"
)
if __name__ == "__main__":
main()
这里最重要的并不是选了哪个 method。
而是:
text
start method 是架构设计的一部分。
不要让它成为操作系统或者 Python 小版本背后的隐式偶然行为。
十、升级 Python 后 multiprocessing 行为改变,第一件事检查什么?
这其实是一道很好的高级工程面试追问。
我的答案不会是:
Linux 默认就是 fork。
而会先检查当前运行时真实使用的 start method。
建议上线诊断时至少输出:
python
import multiprocessing as mp
import platform
import sys
print("Python:", sys.version)
print("Platform:", platform.platform())
print(
"Supported / platform default:",
mp.get_all_start_methods(),
)
print(
"Already configured:",
mp.get_start_method(allow_none=True),
)
这里还有一个很有用的细节:
python
mp.get_all_start_methods()
返回列表中的第一个,就是当前平台默认方式。Python 官方就是这样定义该接口的。(Python documentation)
例如 Python 版本升级前后,你可能看到:
text
旧环境:
['fork', 'spawn', 'forkserver']
升级 Python 3.14 后可能变成:
text
['forkserver', 'spawn', 'fork']
这一个顺序变化,就可能解释为什么以前依赖"父进程全局状态继承"的代码突然出现:
text
NameError
PicklingError
初始化顺序变化
启动时间增加
worker 内存变化
数据库行为变化
接下来我会继续检查四件事:
- 是否有框架或第三方库显式调用了
set_start_method(); - 是否使用
get_context()或ProcessPoolExecutor(mp_context=...)覆盖默认方式; - fork 之前是否已经存在后台线程;
- worker 是否依赖父进程中的全局变量、数据库连接、日志 handler 或不可 pickle 对象。
尤其值得注意的是,Python 3.14 对 ProcessPoolExecutor 的默认启动行为也发生了相应变化;依赖 fork 的代码现在需要显式传递对应 multiprocessing context。(Python documentation)
所以升级 Python 后,不要先改业务代码。
先回答:
text
旧环境到底怎么创建进程?
新环境到底怎么创建进程?
十一、把 start method 纳入 CI,而不是等生产替你测试
一个非常实用的办法,是让核心 multiprocessing 代码在多个 start method 下跑测试。
例如:
python
import multiprocessing as mp
import pytest
@pytest.mark.parametrize(
"method",
mp.get_all_start_methods(),
)
def test_worker_under_start_methods(method):
ctx = mp.get_context(method)
queue = ctx.Queue()
def_result = "ok"
p = ctx.Process(
target=top_level_worker,
args=(queue,),
)
p.start()
p.join(timeout=10)
assert p.exitcode == 0
assert queue.get() == def_result
这样很容易提前发现:
text
依赖 fork 的全局变量
不可 pickle 的参数
import-time side effects
错误的数据库生命周期
跨进程锁问题
日志重复初始化
对于准备升级 Python 大版本的系统,这种测试的价值尤其高。
十二、线上最佳实践:不要问"哪个最快",先问"谁拥有资源"
对于 multiprocessing,真正成熟的设计通常有几个共同特点:
第一,显式指定运行模型。
应用可以根据部署环境明确:
python
ctx = mp.get_context("forkserver")
而不是假设默认永远不变。
第二,进程拥有自己的有状态资源。
数据库连接、Session、HTTP Client、Redis Client 等尽量在 worker 中创建,并在 worker 中关闭。
第三,让 import 尽量纯净。
避免:
python
import module
就顺带发生:
text
建立数据库连接
启动线程
创建 Process
注册复杂 handler
发网络请求
第四,不要把线程与 fork 的组合当作普通场景。
如果父进程已经高度多线程化,优先认真评估 spawn 或 forkserver。
第五,IPC 尽量传"小而清晰的数据"。
不要为了省代码,把巨大的复杂 Python 对象不停通过 Queue pickle。
Python 官方也建议 multiprocessing 程序尽量减少大量数据在进程之间搬运,并优先采用清晰的消息传递模型。(Python documentation)
结语:multiprocessing 的真正难点,不是并行,而是状态
面试时,我们看到的是:
python
Process(target=worker).start()
线上真正运行的却是:
text
Python Runtime
+
Operating System
+
Memory Model
+
Import System
+
Pickle
+
Threads
+
Locks
+
Database Connections
+
Sockets
+
Logging
+
Third-party C Extensions
这也是为什么一个看似十分钟能讲完的 Python API,到了生产环境可能值得研究很多年。
fork、spawn、forkserver 之间没有一个绝对意义上的"冠军"。
它们代表的是不同的进程初始化契约。
而真正可靠的工程代码,不应该建立在:
"我记得 Linux 默认是 fork。"
这种假设上。
更可靠的习惯应该是:
python
print(sys.version)
print(platform.platform())
print(mp.get_all_start_methods())
print(mp.get_start_method(allow_none=True))
先观察运行时事实,再讨论程序行为。
如果只能记住本文一句话,我希望是:
升级 Python 后 multiprocessing 出现变化,第一件事不是怀疑业务逻辑,而是确认 Python 版本、操作系统、实际 start method,以及你的程序是否曾经偷偷依赖旧的默认行为。
这一步,往往能让一次几个小时甚至几天的线上排障,缩短成十几分钟。
而这也正是 Python 工程能力从"会写 multiprocessing",走向"真正理解 multiprocessing"的分界线。
参考资料
建议继续阅读 Python 官方 multiprocessing 文档,尤其是 Contexts and start methods 、Programming guidelines ;同时关注 Python 3.14 的迁移说明。涉及多进程日志,可阅读 Python Logging Cookbook;涉及 SQLAlchemy,则重点阅读 Using Connection Pools with Multiprocessing or os.fork()。
对于 multiprocessing 这样的底层并发能力,官方文档里那些不起眼的 "Changed in version ..." 往往比很多二手教程更加重要。
最后也留两个问题:
你的生产环境现在实际采用的是 fork、spawn,还是 forkserver?
如果明天 Python 再次改变默认并发策略,你的代码能否做到只改配置,而不用大规模修改业务逻辑?
这两个问题,很值得每一个正在维护 Python 服务的人认真检查一次。