别被 `Process.start()` 骗了:Python multiprocessing 从面试题到线上生产的进程启动机制实战

别被 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() 那一刻父进程中的所有运行时状态。

spawnforkserver 对可序列化性要求明显更严格。目标函数和很多传给子进程的对象需要能够被 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 可以看成对 forkspawn 的折中。

程序会先启动一个专门的 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 内存变化
数据库行为变化

接下来我会继续检查四件事:

  1. 是否有框架或第三方库显式调用了 set_start_method()
  2. 是否使用 get_context()ProcessPoolExecutor(mp_context=...) 覆盖默认方式;
  3. fork 之前是否已经存在后台线程;
  4. 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 的组合当作普通场景。

如果父进程已经高度多线程化,优先认真评估 spawnforkserver

第五,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,到了生产环境可能值得研究很多年。

forkspawnforkserver 之间没有一个绝对意义上的"冠军"。

它们代表的是不同的进程初始化契约。

而真正可靠的工程代码,不应该建立在:

"我记得 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 methodsProgramming guidelines ;同时关注 Python 3.14 的迁移说明。涉及多进程日志,可阅读 Python Logging Cookbook;涉及 SQLAlchemy,则重点阅读 Using Connection Pools with Multiprocessing or os.fork()

对于 multiprocessing 这样的底层并发能力,官方文档里那些不起眼的 "Changed in version ..." 往往比很多二手教程更加重要。

最后也留两个问题:

你的生产环境现在实际采用的是 forkspawn,还是 forkserver

如果明天 Python 再次改变默认并发策略,你的代码能否做到只改配置,而不用大规模修改业务逻辑?

这两个问题,很值得每一个正在维护 Python 服务的人认真检查一次。

相关推荐
geovindu1 小时前
java: Task Scheduler
java·开发语言·后端
神奇小梵1 小时前
java的特殊语法点,与常规语言相异的点
java·开发语言
愚公移山填海1 小时前
【无标题】
笔记·python·学习·pycharm
bnmoel1 小时前
C++ 基础入门篇(二):引用
开发语言·c++·引用·底层
天远API2 小时前
零信任架构实战:基于天远公安三要素即时版构建自动化理赔合规网关
人工智能·python·架构·自动化
言乐62 小时前
Python加速器2视频网页加速器
前端·javascript·css·python·音视频
计算机源码社2 小时前
基于Hadoop+Spark的黄金市场历史数据特征分析与可视化大屏 基于K-Means聚类算法的黄金历史价格阶段划分研究
大数据·hadoop·python·数据挖掘·数据分析·spark·毕业设计
hhzz2 小时前
【OpenCV 入门到精通 05】核心操作与像素处理:ROI、运算与性能优化
人工智能·python·opencv·性能优化
影视飓风TIM2 小时前
C++ 智能指针:auto_ptr / unique_ptr / shared_ptr / weak_ptr 原理与使用
开发语言·c++