PEP 574:Pickle Protocol 5 与带外数据传输
本文是 Python 零拷贝序列化系列第一篇。第二篇 介绍 Protocol 5 与 shared_memory 的实际选择。
Pickle 诞生于 1995 年,最初为磁盘持久化而设计。如今,它是 Python 多进程 IPC、Dask 任务图和 Ray 对象存储的核心机制。问题在于:序列化一个大型 NumPy 数组会触发三次冗余的内存拷贝。PEP 574 随 Python 3.8 引入,通过**带外数据(Out-of-Band Data)**机制从根本上解决了这个问题。
问题:三次内存拷贝
以 bytearray 为例,旧协议的序列化过程:
bytearray.__reduce_ex__创建数据的bytes副本pickle.dumps将该bytes对象再次拷贝到 pickle 流中- 反序列化时,临时
bytes对象再次被创建,才能重建最终对象
一个简单的数组经历三次拷贝。第三方库(Dask、PyArrow)曾尝试自定义序列化方案,但这些方案仅对支持自定义序列化的对象有效——普通 Python 对象仍受困于 Pickle。
设计理念:元数据与数据分离
PEP 574 的核心思想:将 pickle 流拆分为两个通道。
- 元数据流:对象类型、结构、引用关系——沿用现有 Pickle 格式,不变。
- 数据缓冲区:大型二进制数据作为独立的带外缓冲区传输,直接传递内存视图,不拷贝。
生产者通过 PickleBuffer 标记”大型数据”,消费者决定是带外传输(零拷贝)还是带内嵌入。
新增 API
| API | 作用 |
|---|---|
pickle.PickleBuffer | 在 __reduce_ex__ 中包装带外数据缓冲区 |
buffer_callback(序列化时) | 处理带外缓冲区;返回 False 则带外传输 |
buffers(反序列化时) | 按顺序提供带外缓冲区的可迭代对象 |
| Protocol 5 | 支持上述所有特性的协议版本 |
不使用新 API 的现有代码行为完全不变——Protocol 5 向后兼容。
生产者 API:PickleBuffer
通过在 __reduce_ex__ 中返回 PickleBuffer 实现带外支持:
import pickle
class LargeArray:
def __reduce_ex__(self, protocol):
if protocol >= 5:
return type(self), (pickle.PickleBuffer(self),)
# 旧协议回退到拷贝
return type(self), (bytes(self),)
PickleBuffer 可包装任何实现 PEP 3118 缓冲区协议的对象——NumPy 数组、bytearray、memoryview。
主要方法:
memoryview(pb):获取形状、步长、格式信息pb.raw():原始连续视图(处理 Fortran 顺序数组时需要)pb.release():显式释放底层缓冲区
缓冲区必须是连续的(C 或 Fortran 顺序),非连续缓冲区在序列化时抛出异常。
消费者 API:序列化
buffers = []
data = pickle.dumps(obj, protocol=5, buffer_callback=buffers.append)
提供 buffer_callback 时,每个 PickleBuffer 都会触发回调:
- 返回
False(或None)→ 缓冲区带外传输,存入buffers,pickle 流中写入NEXT_BUFFER操作码 - 返回
True→ 缓冲区带内嵌入(与旧行为相同)
调用者可逐个缓冲区决定:大数组带外,小型元数据带内。
消费者 API:反序列化
obj = pickle.loads(data, buffers=buffers)
buffers 是按序消费的可迭代对象。流中每遇到 NEXT_BUFFER 操作码,就从迭代器取下一个缓冲区。消费者可以预先将这些缓冲区分配在共享内存、GPU 内存或任意目标位置——反序列化直接写入目标,无中间拷贝。
新增操作码
| 操作码 | 作用 |
|---|---|
BYTEARRAY8 | 从流中的内联数据构建 bytearray |
NEXT_BUFFER | 从 buffers 迭代器弹出下一个缓冲区并压栈 |
READONLY_BUFFER | 将栈顶缓冲区转换为只读视图 |
READONLY_BUFFER 适用于 bytes 等不可变对象——消费者可将只读内存区域直接映射为反序列化值。
零拷贝共享内存示例
import numpy as np
import pickle
a = np.zeros(10)
buffers = []
# 序列化:buffer_callback 捕获数组内存视图,不拷贝
data = pickle.dumps(a, protocol=5, buffer_callback=buffers.append)
# 反序列化:注入相同缓冲区,不拷贝
b = pickle.loads(data, buffers=buffers)
b[0] = 42
print(a[0]) # 42.0 —— a 与 b 共享同一块内存
在真实多进程场景中,消费者预先在共享内存中分配目标缓冲区,作为 buffers 传入,反序列化后的数组直接驻留于共享内存,零拷贝。
被拒绝的替代方案
Persistent load 接口:persistent_id() / persistent_load() 理论上可承载缓冲区,但对每个对象(包括 int)都触发调用,性能损失不可接受。
批量传递缓冲区:传列表而非回调,无法逐缓冲区决定带内/带外路由,被拒绝。
在旧协议中支持 PickleBuffer:在 Protocol 4 中嵌入 PickleBuffer 需要两次额外拷贝,背离初衷。强制要求 Protocol ≥ 5。
生态支持现状
| 项目 | 状态 |
|---|---|
| CPython 3.8+ | 正式合并 |
pickle5(PyPI) | Python 3.6/3.7 向后移植 |
| NumPy | 已添加 Protocol 5 与带外缓冲区支持 |
| Apache Arrow | Python 绑定中已实现支持 |
Python 3.14 将把 Protocol 5 设为默认协议。在此之前需显式指定 protocol=5。
总结
- 旧 Pickle 在序列化大型数组时产生 3 次拷贝;Protocol 5 在数据路径上将其降至零。
PickleBuffer是生产者侧信号;buffer_callback和buffers是消费者侧钩子。- 调用者逐缓冲区控制路由:每个缓冲区独立决定带外或带内。
- 反序列化目标由调用者提供——共享内存、GPU 内存或任意缓冲区协议对象。
- Python 3.14 之前需显式指定
protocol=5。