Skip to main content

Chapter 39: Buffer and Binary Architecture

Python 处理二进制数据时,表面上经常出现 bytesbytearraymemoryviewarray.arraymmapstructreadinto、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 负责声明自己能接受什么形状、可变性和连续性要求。bytesbytearrayarray.arraymmap 和大量扩展类型都可以作为 producer;memoryviewstruct.unpack_fromfile.writereadinto 和 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 代码。对简单的 bytesbytearray,一个元素通常是 1 个字节;对 array.array("I") 之类对象,一个元素可能是多个字节。这个差异由 itemsizeformat 和 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 的引用持有。第三,headerbody 是派生视图,它们继承原始 view 的只读或可写能力,并且用 offset、长度和 stride 描述自己看到的逻辑范围。

Py_buffer 的核心字段也可以按同一条关系理解。buf 指向逻辑视图的起点,obj 持有 exporter,len 表示逻辑内容按连续形式展开后的字节长度,readonly 描述可写能力,itemsizeformat 描述单个元素,ndimshapestridessuboffsets 描述多维结构。阅读 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_buffermemoryview 读取。第三,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 的逻辑内容是 ace,底层地址之间有间隔。支持 strides 的 consumer 可以直接根据 stride 访问;只接受简单连续字节序列的 consumer 需要先把 ace 压成新的连续存储。

二进制 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] 这样的中间 bytespayload 仍然是对原始 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_partarray_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 设计中,参数命名也应体现这个边界。接收只读输入可以写成 datasource,内部立即构造 view = memoryview(data)。接收可写输出缓冲区可以写成 targetbuffer,并在开头检查 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.readonlyview.itemsizeview.formatview.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 成 byteslist 或新的数组、调用频率是否放大转换成本。

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。只要这个顺序稳定,bytesbytearraymemoryview、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。
  • memoryviewmemoryview 是 Python 层的通用 buffer 视图,它引用 exporter 并暴露索引、切片和转换操作。
  • 所有权bytesbytearrayarray.arraymmap 或扩展对象决定底层内存生命周期,view 只描述观察窗口。
  • Py_bufferPy_bufferbuflenreadonlyitemsizeformatshapestrides 描述内存视图。
  • 零拷贝:零拷贝成立依赖 producer 生命周期、consumer buffer 支持、连续性要求和代码中是否执行 materialization。
  • 连续性:C-contiguous 视图可按指针和长度线性扫描,带 stride 的视图需要 consumer 支持 strides 才能共享访问。
  • 切片差异bytesbytearray 普通切片会创建副本,memoryview 单维切片会创建子 view。
  • 可变性memoryview 的只读或可写状态来自 exporter,写入路径需要检查 readonly 和 consumer 的 writable 要求。
  • 结构解析struct.unpack_frompack_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。