yuqi-zheng

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 为例,旧协议的序列化过程:

  1. bytearray.__reduce_ex__ 创建数据的 bytes 副本
  2. pickle.dumps 将该 bytes 对象再次拷贝到 pickle 流中
  3. 反序列化时,临时 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 数组、bytearraymemoryview

主要方法:

  • 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_BUFFERbuffers 迭代器弹出下一个缓冲区并压栈
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 ArrowPython 绑定中已实现支持

Python 3.14 将把 Protocol 5 设为默认协议。在此之前需显式指定 protocol=5


总结

  • 旧 Pickle 在序列化大型数组时产生 3 次拷贝;Protocol 5 在数据路径上将其降至零。
  • PickleBuffer 是生产者侧信号;buffer_callbackbuffers 是消费者侧钩子。
  • 调用者逐缓冲区控制路由:每个缓冲区独立决定带外或带内。
  • 反序列化目标由调用者提供——共享内存、GPU 内存或任意缓冲区协议对象。
  • Python 3.14 之前需显式指定 protocol=5

参考资料