Chapter 39: Buffer and Binary Architecture
Python 处理二进制数据时,表面上经常出现 bytes、bytearray、memoryview、array.array、mmap、struct、readinto、C 扩展和数值库这些对象与 API。它们的共同问题可以归结为:同一段底层内存如何被拥有、暴露、查看、修改和传递,以及每一步是否带来复制和生命周期约束。
本章要建立的能力是:看到一个二进制数据路径时,能够追踪数据所有权,判断当前操作是否生成副本,区分只读视图和可写视图,确认连续内存要求,并定位性能成本来自拷贝、解析、Python 循环、对象分配还是跨 C 边界调用。
贯穿本章的材料是一段网络包或文件头解析路径:先把字节读入可变缓冲区,再用 memoryview 切出头部和 payload,再把局部视图交给 struct、I/O 或 C 扩展消费。这个材料能覆盖 buffer protocol、零拷贝、切片行为和二进制互操作边界。
本文默认以 CPython 为主。官方 Buffer Protocol 文档描述的是 C API 层的 Py_buffer 结构、导出方和消费方协议;标准库 memoryview 文档描述的是 Python 代码可直接使用的视图对象。Python 3.12 起,PEP 688 把 buffer protocol 的 Python 层接口纳入语言模型;更早版本中,纯 Python 类无法完整实现这个协议,只能消费已有 exporter 暴露出来的 buffer。
39.1 Buffer protocol and memoryview
Buffer protocol 是 Python 对二进制内存建立共享访问的协议。它把一个对象分成两个角色:producer 负责导出底层内存的信息,consumer 负责声明自己能接受什么形状、可变性和连续性要求。bytes、bytearray、array.array、mmap 和大量扩展类型都可以作为 producer;memoryview、struct.unpack_from、file.write、readinto 和 C 扩展函数常作为 consumer。
在 CPython C API 中,consumer 通过 PyObject_GetBuffer() 向对象发出请求。请求会带上 flags,例如只读、可写、是否需要格式信息、是否需要 shape/strides、是否要求 C-contiguous。producer 填充一个 Py_buffer 结构,描述底层内存和逻辑视图。consumer 用完后调用 PyBuffer_Release() 释放持有关系。这里的释放主要解除视图持有和 exporter 生命周期约束,不等于释放底层对象本身。
memoryview 是 Python 层对 buffer 的通用包装。它引用一个支持 buffer protocol 的对象,并把“底层内存片段”作为可索引、可切片、可转换的视图暴露给 Python 代码。对简单的 bytes 和 bytearray,一个元素通常是 1 个字节;对 array.array("I") 之类对象,一个元素可能是多个字节。这个差异由 itemsize、format 和 shape 等元数据描述。
下面的示例展示共享视图的最小路径:bytearray 拥有内存,memoryview 只引用这段内存,切出来的 header 仍然是视图。
packet = bytearray(b"HEADpayload")
view = memoryview(packet)
header = view[:4]
body = view[4:]
header[0] = ord("R")
print(packet) # bytearray(b'READpayload')
print(bytes(body)) # b'payload'
header[0] = ord("R") 修改的是 packet 的底层存储。header 自身没有复制出新的字节数组。bytes(body) 是显式转换,它把视图内容复制成新的不可变 bytes。所以判断一次操作的成本时,要看操作停留在 view 层,还是跨到了 concrete bytes 层。
这条路径中有三个对象关系需要固定。第一,packet 是 owner,它负责实际存储和可变性。第二,view 是 consumer 侧的 Python 包装,它延长了对 exporter 的引用持有。第三,header 和 body 是派生视图,它们继承原始 view 的只读或可写能力,并且用 offset、长度和 stride 描述自己看到的逻辑范围。
Py_buffer 的核心字段也可以按同一条关系理解。buf 指向逻辑视图的起点,obj 持有 exporter,len 表示逻辑内容按连续形式展开后的字节长度,readonly 描述可写能力,itemsize 和 format 描述单个元素,ndim、shape、strides、suboffsets 描述多维结构。阅读 C 扩展或高性能库接口时,这些字段比类型名是否写作 bytes 更能解释行为。
这张图的边界是单个进程内的 Python 对象和 CPython buffer protocol。核心路径是 owner 导出 buffer,memoryview 建立视图,consumer 根据自己的请求决定共享访问、拒绝请求或创建副本。跨进程共享内存、磁盘缓存和操作系统页缓存属于更低层主题,本章只讨论 Python 对象层和 C API 层之间的二进制数据路径。
Python 3.12 起,__buffer__ 和 __release_buffer__ 让 Python 层也能表达 buffer producer 形状。对普通工程代码来说,更常见的动作仍然是消费已有 exporter:接收 bytes-like object,把它包装成 memoryview,在需要稳定不可变快照时再调用 bytes()。这个顺序能把所有权、视图和复制边界拆开。
39.2 Zero-copy, contiguous memory, and binary view
Zero-copy 在 Python 二进制路径中表示一次操作没有为数据内容分配新的等长存储。它是“当前操作相对于当前 consumer”的属性。memoryview(packet)[4:8] 可以共享底层内存;bytes(memoryview(packet)[4:8]) 会创建新的 bytes;某个 C 扩展如果只接受 C-contiguous 且只读的 buffer,传入非连续视图时会拒绝或复制,具体行为取决于 API 合约。
零拷贝成立需要四个条件同时满足。第一,producer 的底层存储在 consumer 使用期间保持有效。第二,consumer 接受 buffer protocol,并且愿意从 Py_buffer 或 memoryview 读取。第三,consumer 请求的可变性、格式、维度和连续性与 exporter 能提供的视图一致。第四,代码没有主动调用 bytes()、.tobytes()、.tolist()、切片复制或其它 materialization 操作。
连续内存是二进制性能判断中的关键约束。C-contiguous 表示逻辑顺序和内存地址递增顺序一致,consumer 可以用一个指针加长度扫描。带 stride 的视图可以表达每隔几个字节取一个元素,也可以表达反向视图。这样的 view 仍可共享内存,但 consumer 如果只会线性扫描,就需要一个连续副本才能处理。
下面的例子把连续视图和跨步视图放在一起。head 是连续子视图,every_other 是带 stride 的子视图。二者都引用原始内存,但它们对 consumer 的要求不同。
data = bytearray(b"abcdef")
view = memoryview(data)
head = view[:3]
every_other = view[::2]
print(bytes(head)) # b'abc'
print(bytes(every_other)) # b'ace'
head 可以描述为从 offset 0 开始、长度 3、步长 1 的连续窗口。every_other 的逻辑内容是 a、c、e,底层地址之间有间隔。支持 strides 的 consumer 可以直接根据 stride 访问;只接受简单连续字节序列的 consumer 需要先把 a、c、e 压成新的连续存储。
二进制 view 的另一个边界是元素视角。memoryview 不只存放“字节数组视角”,它还携带 format 和 itemsize。array.array("I", ...) 导出的元素可能是机器字长相关的无符号整数,memoryview 的长度按元素数计算,nbytes 才是视图覆盖的字节量。进行网络协议、文件格式和跨平台二进制解析时,字节序和对齐规则应交给 struct 这类显式格式工具处理。
import struct
packet = bytearray(b"\x00\x05hello")
view = memoryview(packet)
length = struct.unpack_from(">H", view, 0)[0]
payload = view[2:2 + length]
print(length) # 5
print(payload.tobytes()) # b'hello'
struct.unpack_from(">H", view, 0) 从 view 的 offset 0 位置读取两个字节,并按 big-endian unsigned short 解释。它消费的是 buffer object,没有先构造 packet[:2] 这样的中间 bytes。payload 仍然是对原始 packet 的视图。只有 .tobytes() 这一步会生成新的 bytes。
零拷贝路径也带来生命周期约束。可变 exporter 在存在导出 view 时,可能限制调整大小的操作,因为调整大小会让已有指针失效。bytearray 在有活动 memoryview 时执行某些 resize 操作会触发错误。这个行为说明 buffer view 持有的是对底层内存布局的承诺,owner 在承诺解除前需要维持可用地址和逻辑结构。
39.3 Bytes-like object and binary slicing
bytes-like object 是文档和 API 说明中常见的工程词。它通常表示“能按 buffer protocol 提供一段二进制数据的对象”,实际要求由 consumer 决定。某个 API 可能只需要只读连续字节,另一个 API 需要可写连续字节,还有 API 能接受多维、带 stride 或带 format 的 buffer。写类型注解或接口文档时,应把要求拆成只读/可写、连续/可带 stride、是否保留 view、是否复制四个维度。
bytes 是不可变二进制序列。它适合表示稳定的协议常量、不可修改的 payload、hash key 和外部 API 的不可变输入。它的切片会生成新的 bytes,因为不可变对象需要让每个值拥有独立的逻辑内容。小切片在可读性上成本有限,大量或大块切片会引入内存分配和复制成本。
bytearray 是可变二进制序列。它适合接收 I/O 写入、原地修改 header、复用缓冲区和减少重复分配。bytearray 的普通切片返回新的 bytearray;切片赋值会修改原对象。这个差异经常造成性能误判:读取 buffer[2:8] 会复制,执行 buffer[2:8] = b"xxxxxx" 会写回原始缓冲区。
memoryview 是 view object。它的单维切片返回子 view,保留对 exporter 的引用和逻辑窗口。它适合在多层 parser 之间传递“尚未复制的片段”。当 API 最终要求 bytes,再在边界调用 bytes(view) 或 view.tobytes() 创建稳定快照。
下面的例子用同一个源数据比较三种切片路径。
raw_bytes = b"0123456789"
raw_array = bytearray(raw_bytes)
view = memoryview(raw_array)
bytes_part = raw_bytes[2:6]
array_part = raw_array[2:6]
view_part = view[2:6]
raw_array[3] = ord("X")
print(bytes_part) # b'2345'
print(array_part) # bytearray(b'2345')
print(bytes(view_part)) # b'2X45'
bytes_part 和 array_part 是切片时刻的副本,后续 raw_array 的修改无法影响它们。view_part 仍指向 raw_array 的窗口,所以 raw_array[3] 的变化会在 view_part 中可见。判断 parser 是否需要复制时,这个例子给出直接标准:需要稳定快照就复制,需要共享窗口就传 view。
只读和可写也要跟 exporter 绑定。memoryview(b"abc") 是只读 view,写入会失败;memoryview(bytearray(b"abc")) 通常是可写 view。memoryview 本身没有独立决定可变性的权力,它把 exporter 的 readonly 状态传给 consumer。
readonly = memoryview(b"abc")
writable = memoryview(bytearray(b"abc"))
print(readonly.readonly) # True
print(writable.readonly) # False
writable[0] = ord("A")
API 设计中,参数命名也应体现这个边界。接收只读输入可以写成 data 或 source,内部立即构造 view = memoryview(data)。接收可写输出缓冲区可以写成 target 或 buffer,并在开头检查 memoryview(target).readonly。如果函数会保存 view 到调用返回之后,文档应明确生命周期要求,因为调用方后续调整 owner 大小或复用缓冲区会影响 view 观察到的数据。
二进制 slicing 的复用判断顺序可以固定为五步。先确认源对象拥有内存还是只提供视图;再确认切片操作返回副本还是子 view;接着确认后续 consumer 接受 buffer 还是要求 concrete bytes;然后确认 owner 在 view 生命周期内是否可能 resize 或被复用;最后把必要复制集中放在外部边界,例如网络发送、持久化、缓存 key 或跨线程交接点。
39.4 C interop and binary performance
Python 二进制性能的高频成本来自三类动作:分配新的字节对象、在 Python 层逐字节循环、以及把二进制数据反复转换成中间结构。Buffer protocol 的价值在于让 C 扩展、I/O 层和数值库直接消费同一段内存描述,把“传参”从复制数据降为传递指针、长度、格式和形状。
C 扩展作为 consumer 时,常见结构是获取 buffer、检查字段、执行处理、释放 buffer。伪代码可以写成下面的形状。这里保留 C API 名称是为了让源码阅读有定位点,代码省略错误处理细节。
Py_buffer view;
if (PyObject_GetBuffer(obj, &view, PyBUF_SIMPLE) < 0) {
return NULL;
}
/* use view.buf and view.len */
PyBuffer_Release(&view);
PyBUF_SIMPLE 表示 consumer 只请求简单连续字节视图。需要写入时会请求 PyBUF_WRITABLE。需要多维 shape、strides 或 format 时,会请求更复杂的 flags。读 C 扩展源码时,应先找 PyObject_GetBuffer() 的 flags,再看它是否检查 view.readonly、view.itemsize、view.format、view.ndim 和连续性。flags 决定了函数真正接受的 bytes-like 边界。
I/O 层也直接体现 buffer 思维。file.write(data) 只需要从 data 读取字节,通常可以接受多种 bytes-like object。readinto(buffer) 需要把外部数据写入调用方提供的可变 buffer,所以调用方要传 bytearray、可写 memoryview 或其它可写 exporter。这样可以复用一块缓冲区,减少反复创建 bytes。
import io
stream = io.BytesIO(b"abcdefgh")
target = bytearray(4)
view = memoryview(target)
n = stream.readinto(view)
print(n) # 4
print(target) # bytearray(b'abcd')
这个例子中,target 是调用方提供的输出存储。readinto 把数据写进同一块内存,返回实际写入字节数。后续 parser 可以把 view[:n] 传给 struct 或其它 consumer,形成“读取 → 解析 → 复用缓冲区”的路径。对于大文件、网络流和循环读取,这个结构比每次 read() 都创建新 bytes 更容易控制分配压力。
数值计算和图像处理库也依赖相同模型。数组对象通常需要表达元素类型、维度、shape、stride 和连续性。一个二维图像裁剪视图可能共享原始图像内存,但它的每行之间存在 stride;某个 C kernel 如果要求连续输入,就需要显式拷贝成 contiguous buffer。性能分析时,关键问题应落在四个位置:进入 C 之前是否已经复制、C 端是否又为连续性创建副本、返回 Python 时是否 materialize 成 bytes、list 或新的数组、调用频率是否放大转换成本。
struct 模块提供了一个小型但清晰的二进制互操作模型。unpack_from 从 buffer 加 offset 读取结构化字段;pack_into 把结构化字段写入可变 buffer。前者适合在 parser 中读取 header,后者适合在已有输出缓冲区中填充字段。
import struct
out = bytearray(8)
view = memoryview(out)
struct.pack_into(">H", view, 0, 0x1234)
struct.pack_into(">H", view, 2, 0xABCD)
print(out[:4]) # bytearray(b'\x124\xab\xcd')
pack_into 的目标必须可写,并且有足够空间。它把“生成字段字节串再拼接”的路径改成“在已有缓冲区指定 offset 写入”。这种写法适合固定格式协议、二进制文件头和高频编码循环。工程边界也很明确:一旦输出内容需要长期独立保存或作为 hash key,最终仍应复制成不可变 bytes。
39.5 Binary architecture checklist
二进制数据路径的架构判断应从所有权开始。先问哪一个对象真正拥有底层内存:bytes 拥有不可变内容,bytearray 拥有可变内容,memoryview 引用别人的内容,array.array 拥有按元素类型组织的连续存储,mmap 引用文件映射区域,第三方数组可能拥有或引用外部内存。owner 决定生命周期、resize 能力和最终释放责任。
第二步确认视图边界。memoryview、派生切片和 C API 中的 Py_buffer 都是对 owner 的观察窗口。它们通常携带 offset、长度、format、itemsize、shape 和 strides。只要 view 仍然活动,owner 就需要维持导出的内存结构。跨函数保存 view 时,要把这个生命周期约束写进接口约定。
第三步确认可变性。输入 parser 只读数据时,优先接受 read-only buffer;输出 API 和 readinto 这类路径需要 writable buffer。可写需求应在函数开头检查,并给出明确错误。把 bytes 传给需要可写 buffer 的函数会失败;把 bytearray 传给只读 consumer 通常可行,但 consumer 不应修改只读语义下的数据。
第四步确认连续性和格式。面向 C 指针扫描的 API 常要求 C-contiguous;面向数组的 API 可能支持 strides;面向协议字段的 API 需要明确字节序和字段格式。memoryview 的 .format、.itemsize、.ndim、.shape 和 .strides 是判断依据。只看 Python 类型名会漏掉同一类型实例之间的差异,例如一个 memoryview 可以是连续的,也可以来自跨步切片。
第五步定位复制点。复制通常发生在 bytes(x)、x.tobytes()、x.tolist()、bytes/bytearray 普通切片、字符串编码解码、拼接累积、把非连续视图交给要求连续输入的 API、以及从 C 返回 Python concrete object 时。应把复制放在需要稳定快照、跨所有权边界或满足 API 合约的位置。
第六步定位性能瓶颈。大块数据中,拷贝和分配可能主导;小块高频路径中,Python 函数调用、对象创建和格式解析可能主导;结构化解析中,逐字节 Python 循环通常会放大开销;C 扩展路径中,buffer flags 和连续性转换会决定隐藏成本。性能结论需要绑定具体数据规模、调用频率和 consumer 合约。
把本章贯穿材料整理成一个可复用 parser,可以得到如下形状。
import struct
_HEADER = struct.Struct(">HBB")
def parse_packet(source):
view = memoryview(source)
header_size = _HEADER.size
length, kind, flags = _HEADER.unpack_from(view, 0)
payload = view[header_size:header_size + length]
return kind, flags, payload
parse_packet 接收任意满足其读取要求的 bytes-like object,并把 payload 作为 view 返回。这个设计把 header 解析和 payload 复制拆开。调用方如果只转发 payload,可以继续传 view;如果要把 payload 放入缓存或跨生命周期保存,可以在调用点执行 bytes(payload)。这种接口让复制点由所有权边界决定,而非由 parser 内部提前决定。
最终检查顺序可以压缩成一句话:先找 owner,再看 view,再查 readonly,再查 contiguous 和 format,最后定位 materialization。只要这个顺序稳定,bytes、bytearray、memoryview、I/O、C 扩展和数值库之间的二进制路径就能被统一解释。
最小自检任务
阅读下面的代码,判断每个变量是否共享底层内存,并说明哪些操作创建了新的字节对象。
import struct
storage = bytearray(b"\x00\x03abcXYZ")
view = memoryview(storage)
length = struct.unpack_from(">H", view, 0)[0]
payload_view = view[2:2 + length]
payload_bytes = storage[2:2 + length]
storage[3] = ord("B")
result = bytes(payload_view)
答案要点
storage 是 owner,拥有一段可变二进制内存。view 引用 storage 的 buffer,没有复制数据。struct.unpack_from(">H", view, 0) 直接从 buffer 读取两个字节并解释出 length,没有创建 storage[:2] 这样的中间切片。
payload_view = view[2:2 + length] 创建的是子 view,仍然引用 storage 的同一段底层内存。payload_bytes = storage[2:2 + length] 创建新的 bytearray 副本,记录的是切片时刻的内容。后续 storage[3] = ord("B") 会影响 payload_view 观察到的数据,也会影响 result = bytes(payload_view) 的输入内容;它不会影响已经创建的 payload_bytes。
result = bytes(payload_view) 是明确的 materialization 操作,会把当前 view 覆盖的内容复制成新的不可变 bytes。本题的判断顺序是:先定位 owner storage,再区分 memoryview 切片和 bytearray 切片,接着确认 struct.unpack_from 消费 buffer,最后把 bytes(payload_view) 标记为复制边界。
本章知识点总结
- Buffer 协议:buffer protocol 把二进制对象拆成导出内存的 producer 和请求内存视图的 consumer。
- memoryview:
memoryview是 Python 层的通用 buffer 视图,它引用 exporter 并暴露索引、切片和转换操作。 - 所有权:
bytes、bytearray、array.array、mmap或扩展对象决定底层内存生命周期,view 只描述观察窗口。 - Py_buffer:
Py_buffer用buf、len、readonly、itemsize、format、shape和strides描述内存视图。 - 零拷贝:零拷贝成立依赖 producer 生命周期、consumer buffer 支持、连续性要求和代码中是否执行 materialization。
- 连续性:C-contiguous 视图可按指针和长度线性扫描,带 stride 的视图需要 consumer 支持 strides 才能共享访问。
- 切片差异:
bytes和bytearray普通切片会创建副本,memoryview单维切片会创建子 view。 - 可变性:
memoryview的只读或可写状态来自 exporter,写入路径需要检查readonly和 consumer 的 writable 要求。 - 结构解析:
struct.unpack_from和pack_into可以直接消费或写入 buffer,并通过 offset 控制二进制字段位置。 - I/O 缓冲:
readinto把数据写入调用方提供的可变 buffer,适合循环读取和缓冲区复用。 - C 互操作:C 扩展通过
PyObject_GetBuffer()请求 buffer,并通过 flags 表达只读、可写、格式、shape 和连续性要求。 - 复制边界:
bytes()、.tobytes()、.tolist()、普通切片和连续性转换是常见复制点。 - 性能判断:二进制性能需要同时看拷贝、分配、Python 循环、格式解析、buffer flags 和 C 边界转换。
- 检查顺序:分析二进制路径时先找 owner,再看 view,再查 readonly,再查 contiguous 和 format,最后定位 materialization。