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、多进程性能优化。
网硕互联帮助中心





评论前必须登录!
注册