云计算百科
云计算领域专业知识百科平台

Python 多进程对象传递深度解析:为什么必须序列化,以及如何降低通信成本

Python 多进程对象传递深度解析:为什么必须序列化,以及如何降低通信成本

在 Python 并发编程中,multiprocessing 经常被用来处理 CPU 密集型任务,例如图片处理、视频转码、数据清洗、科学计算、批量特征提取以及机器学习预处理。

很多初学者第一次接触多进程时,会产生一个很自然的疑问:

既然我已经在父进程里创建了一个 Python 对象,为什么不能直接把它交给子进程使用?为什么还需要 pickle 序列化?

例如下面这段代码,看起来只是把一个字典传给子进程:

from multiprocessing import Process

def worker(data):
print(data)

if __name__ == "__main__":
user = {
"name": "Alice",
"age": 28,
"skills": ["Python", "SQL", "Docker"],
}

p = Process(target=worker, args=(user,))
p.start()
p.join()

从代码表面看,我们好像只是进行了:

父进程中的 user

传给 worker

子进程使用

但底层真正发生的事情远比这复杂。

在很多场景下,实际过程更接近:

Python 对象

序列化

字节数据

进程间通信

反序列化

新的 Python 对象

也就是说,多进程程序中的“传对象”,很多时候实际上并不是把那个对象本身搬过去,而是在另一个进程中重建一个等价对象。

理解这一点,是掌握 Python multiprocessing、Queue、Pipe、Pool、ProcessPoolExecutor 乃至分布式系统通信机制的重要基础。

本文就围绕这个问题展开:

为什么多进程对象传递通常需要序列化?序列化到底解决了什么问题?它为什么会影响性能?哪些对象不能传?又应该如何设计高性能的多进程程序?


一、先从一个根本问题开始:什么是 Python 对象?

我们平时写:

user = {
"name": "Alice",
"age": 28
}

从 Python 层面看:

user

是一个字典。

但从进程和操作系统角度看,它实际上存在于:

当前 Python 进程的虚拟内存空间

可以粗略理解为:

Process A

Virtual Memory
┌────────────────────────────┐
│ Python Interpreter │
│ │
│ user ───────┐ │
│ ↓ │
│ dict object │
│ 0x7f123456 │
│ │
└────────────────────────────┘

这里所谓:

0x7f123456

只是为了帮助理解的“内存地址”。

关键问题在于:

这个地址只在当前进程自己的虚拟地址空间中有意义。

如果启动另一个进程:

Process B

Virtual Memory
┌────────────────────────────┐
│ Python Interpreter │
│ │
│ 0x7f123456 │
│ │
│ 可能对应完全不同的数据 │
│ 或者根本不可访问 │
│ │
└────────────────────────────┘

因此你不能简单地告诉子进程:

对象在 0x7f123456,
你去那里拿吧。

因为:

不同进程拥有彼此隔离的地址空间。

这就是整个问题的起点。


二、线程可以直接传对象,为什么进程不行?

这是理解序列化最好的切入点。

先看多线程:

import threading

data = [1, 2, 3]

def worker():
data.append(4)

t = threading.Thread(target=worker)
t.start()
t.join()

print(data)

输出:

[1, 2, 3, 4]

原因在于,同一个进程中的多个线程共享:

同一个虚拟地址空间

结构大致是:

Process

├── Thread A

├── Thread B

└── Thread C

共同访问:

Heap
Global Variables
Python Objects

因此:

Thread A
\\
→ same list object
/
Thread B

线程之间可以直接通过对象引用访问同一对象。

但多进程完全不同:

Process A Process B

Memory A Memory B
┌─────────────┐ ┌─────────────┐
│ object A │ │ │
│ list │ │ │
└─────────────┘ └─────────────┘

两个进程:

内存空间彼此隔离

因此需要某种机制把信息从:

Process A

搬到:

Process B

这就是:

IPC
Inter-Process Communication
进程间通信

常见 IPC 机制包括:

Pipe
Queue
Socket
Shared Memory
Message Queue
File
Memory Map

而如果我们要传递的是复杂 Python 对象,就产生了一个新问题:

Pipe 和 Socket 本质上能传什么?

答案通常不是:

Python dict
Python class
Python list

而是:

bytes

于是序列化就出现了。


三、什么叫序列化?

序列化,本质就是:

把一个运行时对象转换成一种可以保存或传输的数据表示。

例如:

user = {
"name": "Alice",
"age": 28
}

经过序列化之后,可以变成一段字节数据:

Python dict

pickle

b'\\x80\\x04\\x95…'

发送到另一个进程:

Process A

dict

pickle.dumps()

bytes

================= IPC =================>

Process B

pickle.loads()

dict

代码示例:

import pickle

user = {
"name": "Alice",
"age": 28,
}

raw = pickle.dumps(user)

print(type(raw))
print(raw)

new_user = pickle.loads(raw)

print(type(new_user))
print(new_user)

输出类似:

<class 'bytes'>
b'\\x80\\x04…'

<class 'dict'>
{'name': 'Alice', 'age': 28}

注意:

user is new_user

结果是:

False

因为它们并不是同一个对象。

只是:

user == new_user

通常为:

True

也就是说:

序列化通常传递的是“对象的状态”,而不是“对象本身”。


四、为什么 multiprocessing 中特别需要 pickle?

Python 的 multiprocessing 希望给开发者一种非常自然的接口:

p = Process(
target=worker,
args=(data,)
)

我们写的是:

args=(data,)

看起来像普通函数调用:

worker(data)

但实际上 worker 可能运行在完全不同的 Python 进程中。

于是 multiprocessing 必须解决:

函数是谁?
参数是什么?
对象是什么类型?
对象当前状态是什么?

这些信息怎么传给另一个解释器?

Python 标准库通常利用:

pickle

将可序列化对象转换成字节流。

因此在 spawn、进程池、Queue 等很多场景中:

object

pickle

IPC

unpickle

是底层重要机制。


五、一个实验:Queue 并不是简单地共享对象

来看:

from multiprocessing import Process, Queue

def worker(q):
data = q.get()
print("child:", data)

if __name__ == "__main__":
q = Queue()

data = [1, 2, 3]

p = Process(
target=worker,
args=(q,)
)

p.start()

q.put(data)

p.join()

看起来:

q.put(data)

像是把:

data

直接放进一个公共容器。

但概念上它更接近:

Parent Process

data

pickle

Queue internal pipe

=========================

Child Process

unpickle

new data object

因此如果子进程执行:

data.append(999)

父进程原来的列表不会自动改变。

例如:

from multiprocessing import Process, Queue

def worker(q):
data = q.get()
data.append(999)

print("child:", data)

if __name__ == "__main__":
q = Queue()

data = [1, 2, 3]

p = Process(
target=worker,
args=(q,)
)

p.start()
q.put(data)

p.join()

print("parent:", data)

可能输出:

child: [1, 2, 3, 999]
parent: [1, 2, 3]

这正说明:

父进程对象

子进程对象

两边只是内容相同。


六、序列化解决的其实是“跨地址空间重建”

如果把问题说得更准确一点:

序列化并不是因为 Python“故意麻烦”。

而是因为:

一个 Python 对象

不是简单的一段字符。

例如:

data = {
"users": [
{"name": "Alice", "score": 95},
{"name": "Bob", "score": 88},
],
"active": True,
}

这个对象在内存中可能由:

dict
├── str
├── list
│ ├── dict
│ │ ├── str
│ │ └── int
│ └── dict
│ ├── str
│ └── int
└── bool

组成。

其中每个元素都可能是独立对象。

也就是说,Python 对象实际形成的是:

object graph
对象图

而不是:

一块连续的数据

序列化要做的事情就是:

复杂对象图

转成稳定的字节表示

传输

重新构造对象图

因此 pickle 不只是保存:

value

还需要保存足够的信息来恢复:

type
structure
references
state

这就是为什么序列化是一个真正的计算过程,而不是简单的内存复制。


七、为什么普通变量不能直接共享?

来看经典代码:

from multiprocessing import Process

count = 0

def worker():
global count
count += 1

if __name__ == "__main__":
p = Process(target=worker)

p.start()
p.join()

print(count)

很多初学者希望输出:

1

但通常仍然是:

0

原因就是:

Parent Process

count = 0

和:

Child Process

count = 0

count = 1

实际上存在于不同内存空间。

结构上是:

Parent Child

count count
0 0

1

子进程修改的是自己的:

count

不是父进程的。

因此:

多进程编程时,不应该把普通全局变量想象成线程中的共享变量。


八、为什么 spawn 对序列化要求特别明显?

Python 多进程有不同启动方式,例如:

fork
spawn
forkserver

其中 spawn 的行为尤其能帮助我们理解序列化。

使用:

ctx = multiprocessing.get_context("spawn")

创建进程时,Python 会启动一个:

新的 Python 解释器

可以粗略理解为:

Parent

Python Interpreter
|
| spawn

New Python Interpreter
|

import module

deserialize data

execute worker

这时候子进程不像线程那样可以看到父进程现有对象。

它需要知道:

目标函数是谁?
参数是什么?

因此目标函数和相关参数必须能够以某种方式重新定位或重建。

这也是为什么下面的代码容易出问题:

import multiprocessing as mp

if __name__ == "__main__":
ctx = mp.get_context("spawn")

func = lambda x: x * 2

p = ctx.Process(
target=func,
args=(10,)
)

p.start()
p.join()

lambda 通常无法像普通模块顶层函数那样被标准 pickle 正常定位。


九、为什么局部函数也经常无法传给子进程?

例如:

def main():

def worker(x):
print(x * 2)

...

这里:

worker

不是模块级函数。

它属于:

main.<locals>.worker

新进程无法简单执行:

import module
module.worker

找到它。

因此更推荐:

def worker(x):
print(x * 2)

def main():
...

也就是:

多进程 worker 尽量定义在模块顶层。

这是 Python 多进程代码中非常重要的最佳实践。


十、类实例能不能序列化?

通常可以,但需要看类的内部状态。

例如:

import pickle

class User:

def __init__(self, name, age):
self.name = name
self.age = age

user = User("Alice", 28)

raw = pickle.dumps(user)

new_user = pickle.loads(raw)

print(new_user.name)
print(new_user.age)

通常可以正常执行。

但如果类包含:

thread lock
socket
database connection
file handle
generator
某些 C 扩展对象

就可能失败。

例如:

import pickle
import threading

class Task:

def __init__(self):
self.lock = threading.Lock()

task = Task()

pickle.dumps(task)

很可能报:

TypeError:
cannot pickle '_thread.lock' object

为什么?

因为一个锁不仅仅是:

普通数据

它代表的是当前进程运行时中的:

同步状态
操作系统资源
线程状态

这些东西不能简单地转换成几个字段,然后在另一个进程里恢复成“同一把锁”。


十一、哪些对象通常容易 pickle?

常见可序列化对象包括:

int
float
str
bytes
bool
None

list
tuple
dict
set

模块顶层定义的函数
模块顶层定义的类
由可 pickle 数据组成的类实例

例如:

data = {
"name": "Alice",
"scores": [90, 88, 96],
"active": True,
}

非常适合进程间传递。


十二、哪些对象经常不能直接 pickle?

常见高风险对象包括:

lambda
局部函数
生成器
线程锁
打开的文件对象
socket
数据库连接
线程池
某些第三方 C 扩展对象
复杂运行时上下文

因此如果你遇到:

PicklingError

或者:

Can't pickle local object

第一反应应该检查:

我是不是在把“运行状态”当作“普通数据”传?


十三、实战:错误地传递数据库连接

例如:

from multiprocessing import Process

db = create_database_connection()

def worker():
db.execute("SELECT …")

if __name__ == "__main__":
p = Process(target=worker)
p.start()
p.join()

这样的代码非常危险。

数据库连接背后通常包含:

TCP socket
session
transaction
buffer
authentication state
protocol state

这些东西不适合通过普通序列化在进程之间传递。

更加合理的方法是:

def worker(config):

db = create_database_connection(
host=config["host"],
port=config["port"],
user=config["user"],
)

try:
db.execute(...)
finally:
db.close()

传递:

连接配置

而不是:

连接本身

这是一条非常实用的设计原则:

跨进程传递数据,而不是传递资源。


十四、进一步理解:序列化其实是一种“协议”

假设父进程拥有:

user = User(
name="Alice",
age=28
)

要在子进程中重新构造它,本质上需要描述:

这是哪个类?
属性有哪些?
属性分别是什么?
引用关系是什么?

可以抽象成:

{
type: "User",
state: {
name: "Alice",
age: 28
}
}

于是:

Serialize

相当于把:

运行时对象

映射为:

传输协议

而:

Deserialize

则是:

协议数据

重新构建运行时对象

这和分布式系统中的:

JSON
MessagePack
Protocol Buffers
Avro

本质上是同一种设计思想。

不同之处只是:

pickle 更偏向:

Python ↔ Python

而 JSON、Protobuf 等更适合:

Python ↔ Java
Python ↔ Go
Python ↔ JavaScript


十五、真正值得关注的问题:序列化有成本

多进程并不是:

任务拆成 8 份
=
速度自动提升 8 倍

实际执行时间可能是:

T_total
=
T_pickle
+
T_transfer
+
T_unpickle
+
T_compute
+
T_sync

其中:

T_pickle

是序列化时间。

T_transfer

是传输时间。

T_unpickle

是反序列化时间。

如果任务本身计算非常轻:

T_compute = 1 ms

但序列化加通信:

10 ms

那么使用多进程反而会更慢。


十六、一个性能陷阱:传递巨大列表

例如:

from concurrent.futures import ProcessPoolExecutor

def compute(data):
return sum(data)

if __name__ == "__main__":

huge_data = list(
range(10_000_000)
)

with ProcessPoolExecutor() as pool:

result = pool.submit(
compute,
huge_data
).result()

print(result)

问题在于:

huge_data

非常大。

程序可能需要经历:

巨大 Python list

pickle

巨大 bytes

IPC

unpickle

巨大 Python list

于是你的实际瓶颈可能根本不是:

sum(data)

而是:

对象复制与传输

这就是为什么很多人会发现:

“明明用了 8 核 CPU,为什么反而比单进程还慢?”

答案经常不是 GIL,而是:

serialization overhead


十七、用一个简单测试观察序列化成本

可以自己测试:

import pickle
import time

data = list(
range(5_000_000)
)

start = time.perf_counter()

raw = pickle.dumps(data)

t1 = time.perf_counter()

new_data = pickle.loads(raw)

t2 = time.perf_counter()

print(
"serialize:",
t1 start
)

print(
"deserialize:",
t2 t1
)

print(
"size:",
len(raw) / 1024 / 1024,
"MB"
)

你会发现:

对象越大

序列化时间越长

数据传输越重

进程并行收益越容易被抵消

所以优化 multiprocessing 时,不能只盯着:

CPU utilization

还要观察:

序列化
复制
传输
同步
任务粒度


十八、Pool 为什么特别容易产生序列化开销?

来看:

from multiprocessing import Pool

def compute(data):
return sum(data)

if __name__ == "__main__":

with Pool(4) as pool:

result = pool.map(
compute,
datasets
)

概念上:

Main Process
|
├── pickle(dataset1)
| ↓
| Worker 1
|
├── pickle(dataset2)
| ↓
| Worker 2
|
├── pickle(dataset3)
| ↓
| Worker 3
|
└── pickle(dataset4)

Worker 4

计算完成后结果可能还需要:

pickle(result)

Main Process

也就是说:

输入序列化一次
+
输出序列化一次

任务越碎,开销越明显。


十九、最佳实践一:提高任务粒度

错误思路:

pool.map(
process,
millions_of_tiny_items
)

如果每个任务只执行:

几十微秒

通信成本可能远大于计算成本。

可以考虑批处理:

def process_batch(items):

result = []

for item in items:
result.append(
process(item)
)

return result

然后:

100 万个任务

变成:

1000 个 batch

结构从:

serialize
compute
serialize

serialize
compute
serialize

serialize
compute
serialize

变成:

serialize batch

大量计算

serialize result

多进程效率通常会明显提升。


二十、map 中的 chunksize 为什么很重要?

Pool.map() 支持:

chunksize

例如:

with Pool(4) as pool:

result = pool.map(
calculate,
range(1_000_000),
chunksize=1000,
)

如果每个任务非常小:

1 item
1 IPC message

会产生大量通信。

使用 chunks:

1000 items
1 IPC message

可以减少:

任务调度
序列化
消息数量
进程同步

开销。

因此处理大量轻量任务时:

调整 chunksize 往往比单纯增加 worker 数更有效。


二十一、最佳实践二:让 worker 自己加载大资源

假设有一个:

2 GB 模型

最差的设计:

def infer(model, image):
...

然后每次都:

pool.submit(
infer,
model,
image
)

概念上可能不断尝试传:

2 GB model

正确思路通常是:

Worker 启动

每个 Worker 初始化模型

以后只传轻量任务参数

例如:

model = None

def init_worker(model_path):

global model

model = load_model(
model_path
)

def infer(image_path):

global model

image = load_image(
image_path
)

return model.predict(image)

创建 Pool:

from multiprocessing import Pool

if __name__ == "__main__":

with Pool(
processes=4,
initializer=init_worker,
initargs=("model.bin",),
) as pool:

results = pool.map(
infer,
image_paths,
)

这样通信内容变成:

"001.jpg"
"002.jpg"
"003.jpg"

而不是:

2 GB model
+
image

这属于非常典型的多进程优化模式。


二十二、最佳实践三:传 ID,不要传大对象

假设你需要处理:

user = {
"id": 10001,
"name": "…",
"history": [...大量数据...],
}

不要总是:

pool.submit(
process_user,
user
)

可以考虑:

pool.submit(
process_user,
user["id"]
)

然后 worker 根据:

user_id

自己访问:

数据库
本地文件
内存映射
共享存储

这种设计在分布式系统里尤其常见:

传 reference
而不是
传整个 object

本质上是在降低:

data movement


二十三、最佳实践四:使用共享内存

如果不同进程确实需要访问大块数据:

大型 NumPy 数组
图像矩阵
模型输入
数值数据

反复 pickle 通常不是最佳方案。

可以考虑:

multiprocessing.shared_memory

基本思想是:

Shared Memory
┌───────────────┐
│ Large Array │
└───────────────┘
↑ ↑
│ │
Process A Process B

这时候并不是:

复制整个大数组

而是:

多个进程访问同一块共享内存

如果需要传输的信息只有:

共享内存名称
shape
dtype
offset

通信成本会小得多。


二十四、NumPy 场景尤其适合共享内存

例如:

import numpy as np

data = np.random.rand(
10000,
10000,
)

如果直接:

pool.submit(
calculate,
data
)

可能需要序列化巨大数组。

而共享内存方案大致可以设计成:

Main Process

NumPy Array

Shared Memory

shm_name = "psm_xxxxx"

worker 只接收:

{
name,
shape,
dtype
}

worker 再通过:

shared memory

重建 NumPy view。

这里传递的不是:

800 MB 数组

而可能只是:

几十字节 metadata

这就是数据密集型并行程序中非常重要的优化思想。


二十五、那 Manager 为什么看起来可以共享对象?

Python 提供:

multiprocessing.Manager

例如:

from multiprocessing import Manager

with Manager() as manager:

shared_list = manager.list(
[1, 2, 3]
)

很多初学者以为:

Manager
=
真正共享普通 Python list

其实不是这么简单。

Manager 通常会启动一个专门的管理进程:

Worker A
|
| RPC

Manager Process
|
| owns list

[1, 2, 3]


| RPC
|
Worker B

Worker 拿到的是:

proxy
代理对象

例如:

shared_list.append(4)

内部可能变成:

发送请求:
"请执行 append(4)"

Manager process 执行后,再返回结果。

所以 Manager 很方便,但操作频繁时:

序列化
IPC
同步
代理调用

都会产生开销。

因此:

Manager 适合方便地共享少量复杂状态,不适合高频、大规模数值计算。


二十六、Value 和 Array 为什么更快?

multiprocessing 还提供:

Value
Array

例如:

from multiprocessing import Value

counter = Value(
"i",
0
)

这种结构更接近:

共享内存中的基础 C 数据类型

而不是:

整个 Python object

因此对于:

整数
浮点数
固定数组

这类结构,通常比 Manager 代理对象更加直接。

但代价是:

支持的数据结构没那么灵活

这就是经典工程取舍:

便利性
vs
性能


二十七、序列化和共享内存到底怎么选?

可以用下面的判断方式。

小数据、低频通信

例如:

任务 ID
文件名
简单字典
配置项
少量数值

直接:

pickle + Queue / Pool

通常足够。


中等数据、计算任务较重

例如:

几 MB 输入
每个任务计算几秒

即使有序列化成本:

T_pickle
<<
T_compute

仍然值得使用多进程。


超大数据、高频通信

例如:

数百 MB NumPy 数组
大型模型参数
视频帧
矩阵

应该认真考虑:

shared_memory
mmap
memory-mapped file
worker local cache

而不是反复 pickle。


二十八、一个实战设计:批量图片分析系统

假设需要处理:

100000 张图片

需求:

读取图片

缩放

提取特征

计算统计信息

保存结果

错误架构:

Main Process

读取所有图片到内存

每张图片作为 NumPy Array

pickle

Worker

问题:

内存高
序列化重
IPC 数据量巨大
主进程容易成为瓶颈

更加合理:

Main Process

扫描文件

只获取路径

image_001.jpg
image_002.jpg
image_003.jpg


ProcessPool

Worker

读取图片

处理

返回小型统计结果

代码:

from concurrent.futures import ProcessPoolExecutor

def analyze_image(path):

image = load_image(path)

features = extract_features(
image
)

return {
"path": path,
"score": features.score,
}

if __name__ == "__main__":

with ProcessPoolExecutor() as executor:

results = list(
executor.map(
analyze_image,
image_paths,
)
)

通信从:

巨大 image array

变成:

几十字节 path

这往往能带来非常明显的性能提升。


二十九、再看一个生产案例:日志分析

假设有:

100 GB 日志文件

不要这样:

content = open(
"server.log"
).read()

pool.map(
analyze,
split(content)
)

因为:

大量字符串

序列化

复制

大量内存

更好的方案是:

Main Process

生成:

(file_path, start, end)

Worker

打开文件

seek(offset)

读取指定区域

分析

也就是说:

传元数据
不要传数据本体

这是高性能并行程序中的核心理念之一。


三十、为什么“尽量少传对象”是高级 multiprocessing 技巧?

初学 multiprocessing 时,我们关注:

怎么创建 Process?

进一步学习:

怎么用 Queue?
怎么用 Pool?

再往后:

怎么共享数据?

真正进入性能优化阶段后,问题往往会反过来:

我能不能根本不传这些数据?

这是一种非常重要的思维升级。

因为计算机系统中最昂贵的事情之一通常不是:

计算

而是:

数据移动

例如:

磁盘 → 内存
内存 → CPU Cache
进程 A → 进程 B
机器 A → 机器 B
CPU → GPU

所以高级性能优化经常遵循:

Move computation to data

而不是:

Move huge data to computation


三十一、一个实用的 multiprocessing 性能公式

评估一个多进程任务是否值得并行,可以粗略考虑:

T_parallel
=
T_startup
+
T_pickle_input
+
T_transfer_input
+
T_compute / N
+
T_pickle_output
+
T_transfer_output
+
T_sync

其中:

N

是 worker 数量。

理想情况下大家只关注:

T_compute / N

于是以为:

8 核

8 倍性能

实际上:

startup
serialization
communication
synchronization

都会消耗时间。

因此多进程收益通常满足:

计算越重
任务越独立
通信越少
并行收益越明显

反过来:

任务越小
对象越大
通信越频繁
多进程越容易变慢


三十二、ProcessPoolExecutor 的一个经典误区

假设:

def add_one(x):
return x + 1

然后:

with ProcessPoolExecutor() as executor:

results = list(
executor.map(
add_one,
range(10_000_000)
)
)

单次:

x + 1

几乎没有计算量。

但每个任务可能涉及:

调度
序列化
传输
反序列化
计算
返回
序列化
再传输

这类任务可能比普通:

[x + 1 for x in data]

慢很多。

因此:

多进程适合“重任务”,不是“任务数量很多”就一定适合多进程。


三十三、如何判断任务粒度是否合理?

可以做一个简单判断。

假设单个任务:

计算 = 500 ms
通信 = 2 ms

那么:

通信成本占比很低

非常适合多进程。

如果:

计算 = 0.05 ms
通信 = 1 ms

那么:

通信比计算重 20 倍

这种时候并行往往得不偿失。

所以实践中应该测:

import time

start = time.perf_counter()

result = compute(task)

elapsed = time.perf_counter() start

print(elapsed)

不要只凭感觉决定:

我要不要 multiprocessing


三十四、pickle 与 JSON 有什么区别?

很多读者会问:

既然都是序列化,为什么 multiprocessing 不直接用 JSON?

因为 JSON 能表达的数据类型有限:

string
number
boolean
null
array
object

但是 Python 多进程可能需要传递:

tuple
set
bytes
自定义 class
函数引用
复杂 Python 状态

例如:

data = {
"point": (10, 20),
"tags": {"python", "linux"},
}

pickle 更了解:

Python object model

所以非常适合:

Python → Python

但有一个必须知道的重要原则:

不要对不可信来源的数据执行 pickle.loads()。

因为 pickle 的反序列化过程可能执行危险代码。

因此:

pickle

适合:

受信任的 Python 内部通信

却不应该直接用于:

处理互联网上陌生用户提交的数据


三十五、如果对象不能 pickle,应该怎么办?

遇到:

Can't pickle …

不要第一时间想办法“强行序列化”。

先判断对象是什么。

如果是:

数据库连接

传连接参数。

如果是:

文件对象

传文件路径。

如果是:

网络连接

让 worker 自己建立连接。

如果是:

线程锁

重新设计同步方式。

如果是:

大型数据

考虑共享内存。

如果是:

局部函数

移到模块顶层。

这套思路非常值得记住:

不可 pickle

先问:
“它应该被传吗?”

很多时候答案其实是:

不应该。


三十六、如何设计一个“适合 multiprocessing”的对象?

假设:

class ImageTask:

def __init__(
self,
image_path,
model_name,
):

self.image_path = image_path
self.model_name = model_name

这样的对象非常适合:

序列化

因为里面都是:

普通字符串

但如果设计成:

class ImageTask:

def __init__(
self,
image_path,
):

self.image_path = image_path

self.file = open(
image_path,
"rb"
)

self.db = create_connection()

self.lock = threading.Lock()

就非常不适合跨进程传递。

因此数据类最好承担:

描述任务

而不是:

携带全部运行时资源

例如:

from dataclasses import dataclass

@dataclass
class ImageTask:

image_path: str

resize_width: int

resize_height: int

非常清晰。

Worker:

def process(task):

image = load_image(
task.image_path
)

...

这就是良好的:

data-oriented design


三十七、多进程程序的推荐分层结构

真实项目中,可以设计成:

┌─────────────────────────┐
│ Main Process │
│ │
│ 任务发现 / 调度 │
└────────────┬────────────┘

│ small task

┌─────────────────────────┐
│ Worker Process │
│ │
│ 初始化本地资源 │
│ 执行 CPU 任务 │
└────────────┬────────────┘

│ small result

┌─────────────────────────┐
│ Main Process │
│ │
│ 聚合 / 保存结果 │
└─────────────────────────┘

理想通信对象应该:


简单
不可变优先
易序列化
与运行环境解耦

这套设计不仅适合 multiprocessing,也适合:

Celery
Ray
Spark
消息队列
微服务
分布式计算


三十八、进程间通信的最佳实践总结

真实 Python 项目里,可以遵循以下原则:

1. 默认认为不同进程内存彼此隔离

2. 不依赖普通全局变量共享状态

3. Queue / Pipe / Pool 中传递对象时,
要意识到序列化成本

4. worker 定义在模块顶层

5. 参数尽量由简单数据组成

6. 不传数据库连接、socket、线程锁等资源

7. 大资源优先让 worker 自己初始化

8. 巨大对象考虑共享内存

9. 高频小任务考虑批处理

10. 使用 Pool.map 时关注 chunksize

11. 不要把“多进程”理解成免费性能

12. 优化时重点关注数据移动成本

如果只记住一句:

进程之间传递的最好是“任务描述”,而不是完整运行状态。


三十九、面试中如何回答“为什么多进程对象传递需要序列化?”

如果面试官问:

Python multiprocessing 为什么对象传递需要序列化?

可以这样回答:

因为不同进程拥有彼此独立的虚拟地址空间。父进程中的 Python 对象引用或内存地址,对另一个进程通常没有直接意义。为了通过 Pipe、Queue 或其他 IPC 机制把对象信息发送给其他进程,需要先把对象转换成可以传输的字节表示,再由目标进程根据这些数据重新构建对象,这个过程就是序列化和反序列化。Python multiprocessing 中很多场景使用 pickle 完成这一过程,因此对象通常需要具备可 pickle 性。

如果面试官继续追问性能问题,可以补充:

序列化不是免费的。大型 Python 对象会带来 pickle、内存复制和 IPC 成本,因此多进程性能不仅取决于计算速度,还取决于任务粒度和数据传输量。大量数据场景通常应该考虑共享内存、批处理或者让 worker 自己加载资源。

如果再追问:

为什么线程不需要?

回答:

因为同一进程内的线程共享虚拟地址空间,可以直接访问同一个 Python 对象,而独立进程之间默认没有这种共享关系。

这三层回答基本已经覆盖:

操作系统
Python 对象模型
IPC
pickle
性能优化

几个关键知识点。


四十、从 multiprocessing 进一步理解分布式系统

理解完多进程序列化,你其实已经迈进了分布式计算的大门。

单机多进程:

Process A

serialize

IPC

Process B

跨机器服务:

Machine A

serialize

network

Machine B

本质结构非常接近。

只是协议可能从:

pickle

变成:

JSON
Protobuf
MessagePack
Avro

通信方式从:

Pipe

变成:

TCP
HTTP
gRPC
Kafka
RabbitMQ

所以当你真正理解:

为什么不能把 Python 对象直接传给另一个进程

也就很容易理解:

为什么微服务接口需要定义数据结构?

为什么 RPC 需要序列化?

为什么 Kafka 消息不能直接保存 Python 内存对象?

为什么分布式系统特别强调数据协议?

答案都是类似的:

运行时对象只属于自己的运行环境,
跨越边界需要明确的数据表示。


总结:序列化不是额外负担,而是进程隔离之后必然出现的桥梁

Python 多进程中的对象传递,表面上看似:

worker(data)

实际上背后可能经历:

Python Object

Serialization

Bytes

IPC

Bytes

Deserialization

New Python Object

为什么必须这么做?

根本原因只有一个:

不同进程拥有独立的地址空间,一个进程中的 Python 对象无法天然成为另一个进程中的同一个 Python 对象。

序列化承担的角色,就是把:

运行时对象

转换成:

可传输数据

然后再:

重建对象

理解这个机制之后,很多 Python 多进程中的现象都会变得非常自然:

为什么普通全局变量不能直接共享?

为什么 Queue 里的对象被修改后不会影响父进程?

为什么 lambda 和局部函数经常报 pickle 错误?

为什么数据库连接不应该直接传给 worker?

为什么大型 NumPy 数组会拖慢 ProcessPool?

为什么共享内存能提升某些数据密集型任务的性能?

它们其实都是同一个核心问题的不同表现:

进程边界
+
对象表示
+
数据移动成本

而真正成熟的 Python 多进程设计,并不是想办法“把所有东西都传给 worker”。

恰恰相反,优秀的系统会不断思考:

哪些数据真的必须传?

能不能只传 ID?

能不能只传路径?

能不能批量传?

能不能让 worker 自己加载?

能不能使用共享内存?

能不能把计算移动到数据附近?

当你的关注点从:

“如何创建更多进程?”

转变为:

“如何减少进程之间的数据移动?”

你就已经从会使用 multiprocessing,走向真正理解高性能 Python 编程了。


写在最后

Python 的魅力常常就在这些看似简单的 API 背后。

一句:

pool.submit(worker, data)

背后连接着:

Python 对象模型
操作系统虚拟内存
进程隔离
IPC
pickle
共享内存
并行计算
分布式系统

如果你愿意顺着一个小问题不断往底层追问,会发现很多高级知识其实并没有想象中那么割裂。

它们往往只是同一个基本原理,在不同层次上的重复出现。

最后也留两个问题:

你在实际项目中遇到过哪些因为 pickle、Queue 或 ProcessPool 导致的性能问题?

如果要在多进程之间处理一个数 GB 的 NumPy 数组,你会选择直接序列化、共享内存,还是重新设计任务边界?

欢迎把你的实践方案和踩坑经历分享出来。很多真正有价值的 Python 最佳实践,往往并不是来自 API 文档,而是来自一次又一次对性能瓶颈和异常行为的追踪。


参考学习方向

如果希望继续深入 Python 多进程与高性能计算,可以重点学习:

  • Python 官方 multiprocessing 文档
  • multiprocessing.Queue
  • multiprocessing.Pipe
  • multiprocessing.shared_memory
  • concurrent.futures.ProcessPoolExecutor
  • Python pickle 模块
  • NumPy 内存模型
  • memory mapping
  • IPC 与虚拟内存基础
  • Celery、Ray 等分布式任务框架

推荐书籍:

  • 《流畅的 Python》
  • 《Effective Python》
  • 《Python 编程:从入门到实践》
  • 《High Performance Python》

建议搜索和持续关注的关键词:

Python编程、Python教程、Python实战、Python最佳实践、Python multiprocessing、Python多进程、Python pickle、进程间通信、Python共享内存、ProcessPoolExecutor、多进程性能优化。

赞(0)
未经允许不得转载:网硕互联帮助中心 » Python 多进程对象传递深度解析:为什么必须序列化,以及如何降低通信成本
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!