Python 的 memoryview 为什么能零拷贝:拆开 buffer protocol、cast 和那道 BufferError

一句话结论 :memoryview 不是把数据复制一份再给你,它只是给同一块内存发了张「带格式的访问凭证」。所以改视图等于改原对象,改原对象视图立刻能看见;代价是这块内存在视图活着的时候不许被挪动。

本文所有输出均来自本机 CPython 3.13.14 实测,代码可直接粘贴运行。

一、那张「凭证」上写了什么

python 复制代码
buf = bytearray(b'0123456789')
mv = memoryview(buf)
print(mv.format, mv.itemsize, mv.ndim, mv.shape, mv.strides,
      mv.nbytes, mv.readonly, mv.c_contiguous)

本机输出:

python 复制代码
B 1 1 (10,) (1,) 10 False True

八个字段一份不少:format='B' 表示按无符号单字节解释,itemsize=1 是每格宽度,shape=(10,) 是形状,strides=(1,) 是步长,nbytes=10 是总字节数,readonly=False 说明可写,c_contiguous=True 说明是连续内存。

这些不是 memoryview 自己猜的,是底层对象通过 buffer protocol(PEP 3118) 主动报上来的。任何实现了这套协议的类型都能被 memoryview() 包一层:bytes、bytearray、array.array、NumPy 数组、mmap 对象,以及 3.12 起你自己写的类。

二、写穿:改视图就是改原对象

python 复制代码
mv[0] = 65
print(bytes(buf[:4]))          # b'A123'

sub = mv[2:6]
print(sub.nbytes, sub.readonly, sub.obj is buf)   # 4 False True
sub[0] = 88
print(bytes(buf[:7]))          # b'A1X3456'

两件事值得停一下:

  1. mv[0] = 65 直接改到了 buf 上,视图里没有自己的数据副本;
  2. 切片视图仍然共享同一块 buffer ,sub.obj is buf 是 True。这一点跟列表切片完全不同------list[2:6] 是浅拷贝,改副本不会动原列表。

再看零拷贝与快照的对比:

python 复制代码
raw = bytearray(b'AAAA')
v = memoryview(raw)
snap = v.tobytes()
raw[0] = 66
print(bytes(v), snap)          # b'BAAA' b'AAAA'

tobytes() 才是复制。只调用 memoryview() 和切片,一个字节都不搬。

三、只读不是建议,是硬约束

python 复制代码
ro = memoryview(b'abc')
print(ro.readonly, isinstance(hash(ro), int))    # True True
ro[0] = 65
# TypeError: cannot modify read-only memory

bytes 是不可变对象,导出的视图自然只读。想给一个可写视图加只读保护,用 toreadonly() 而不是复制。

顺带一个容易忽略的点:只读的、格式兼容的 memoryview 是可哈希的,能当字典键;可写的不能。

四、cast:换个解释方式,不换数据

python 复制代码
import array
arr = array.array('i', [1, 2, 3, 4])
ma = memoryview(arr)
print(ma.format, ma.itemsize, ma.shape)          # i 4 (4,)
print(ma.tobytes().hex()[:32])                   # 0100000002000000...

mb = ma.cast('B')
print(mb.format, mb.itemsize, mb.shape)          # B 1 (16,)
print(len(mb.tobytes()))                         # 16

4 个 int32 = 16 字节,cast('B') 之后变成 16 个单字节,底层还是那 16 个字节。这在解析二进制协议头、把 array.array 喂给只接受 bytes 的 API 时非常省事。

但 cast 有硬条件,长度必须是目标 itemsize 的整数倍:

python 复制代码
memoryview(bytearray(b'abc')).cast('I')
# TypeError: memoryview: length is not a multiple of itemsize

3 字节要按 4 字节整数解释,除不尽,直接拒绝。反汇编也能看到切片本身并没有额外的复制指令:

python 复制代码
import dis
def f_mv(b): return memoryview(b)[1:4]
print([i.opname for i in dis.get_instructions(f_mv)
       if 'SUBSCR' in i.opname or 'SLICE' in i.opname or 'CALL' in i.opname][:6])
# ['CALL', 'BINARY_SLICE']

五、那道 BufferError:视图活着,内存不许动

这是零拷贝最容易被忽略的代价。

python 复制代码
b2 = bytearray(b'abc')
m2 = memoryview(b2)
b2.extend(b'XYZ')
# BufferError: Existing exports of data: object cannot be re-sized
m2.release()
b2.extend(b'XYZ')       # 现在可以了,len(b2) == 6

原因很直白:bytearray 扩容可能触发重新分配内存,而 m2 里存的就是那块内存的地址。真让它扩了,视图会变成悬空指针。CPython 选择用引用计数式的「导出计数」拦住这次 resize。

release() 之后视图也不能再用了:

python 复制代码
m2[0]
# ValueError: operation forbidden on released memoryview object

这条对排查线上问题特别有用 :看到 BufferError: Existing exports of data,不要去找 bytearray 的 bug,去查是哪个 memoryview / NumPy 视图 / mmap 还活着。用 with memoryview(x) as mv: 或 try/finally: mv.release() 是最省心的写法。

六、3.12 起,自己的类也能导出 buffer

PEP 688 把协议开放给了纯 Python 类,实现 __buffer__(self, flags) 即可:

python 复制代码
class Box:
    def __init__(self, data): self.data = bytearray(data)
    def __buffer__(self, flags): return memoryview(self.data)

print(bytes(memoryview(Box(b'hi'))))    # b'hi'

本机 3.13.14 实测通过。这意味着自定义容器、环形缓冲区、网络包解析器都能走原生零拷贝路径,不必先 bytes(...) 复制一遍。

七、对照表:什么时候该用、什么时候别用

场景 建议 原因
解析大二进制文件的头部/分片 ✅ memoryview + 切片 不复制,省内存也省时间
需要把数据长期留存、脱离原对象 ✅ tobytes() / bytes(...) 拿独立快照
边持有视图边给 bytearray 扩容 ❌ 会 BufferError 先 release()
bytes 视图想改内容 ❌ TypeError 用 bytearray 打底
非连续 / 带步长的视图做 cast ❌ 只能对 C 连续视图 先 tobytes() 再重建
多线程共享可写视图 ⚠️ 需要自己加锁 零拷贝也意味着没有隔离
传给只认 bytes 的第三方 API ⚠️ 多数能收 buffer 协议,少数不能 不确定就 tobytes()

八、七条排错清单

  1. BufferError: Existing exports of data: object cannot be re-sized → 有视图没释放,查 release() 或改用 with。
  2. ValueError: operation forbidden on released memoryview object → 视图已 release(),别再访问。
  3. TypeError: cannot modify read-only memory → 底层是 bytes,换 bytearray。
  4. TypeError: memoryview: length is not a multiple of itemsize → cast 的总字节数除不尽目标格式宽度,补齐或换格式。
  5. TypeError: memoryview: casts are restricted to C-contiguous views → 带步长的非连续视图不能直接 cast,先拷贝成连续。
  6. 改了视图原对象没变 → 你可能对 mv.tobytes() 的结果做了修改,那是副本。
  7. 哈希时报 TypeError: unhashable type → 视图可写;用 toreadonly() 且格式须为字节型。

小结

把 memoryview 当成「带格式的指针 + 长度 + 读写权限」,所有行为就都能推出来:切片是换个窗口、cast 是换套解释、共享是默认、resize 被禁是为了不悬空。真正需要复制的只有 tobytes() 那一步。


本文所有结论均由本机 CPython 3.13.14 实测得出(探针 d59-probe.py),无外部数据引用。发布时按「中文前置、符号后置」策略命名,避免纯符号标题。

相关推荐
Hvitur28 分钟前
Java - Lambda
java·开发语言·python
2601_967980111 小时前
win10如何把python加入环境变量
python·win10·安装教程·命令行·环境变量
锋行天下9 小时前
langgraph 后端发自定义数据,前端接收方法概括
人工智能·python
benchmark_cc10 小时前
用 QuantDash 读取单只股票五档盘口:抽样、缺失值检查与复现记录
python·数据分析·pandas·量化交易·股票数据·quantdash
曲径海盗11 小时前
【Python 学习第 55 天】列表基础
python·学习笔记·编程入门
专注仿真11 小时前
知识问答模型用到的算法集详解及代码实例
python·算法·代码·大模型训练·控制算法
jyOverQ12 小时前
我写了一个 CSDN Markdown 图片助手:告别本地图片逐张上传
python·工具·csdn
刘马想放假15 小时前
CANviz 使用指南|Linux 安装、DBC 解码、实时曲线与 J1939 实战
linux·python·websocket
2601_9679873715 小时前
python判断变量名不要调用任何函数实现
python·函数·语法·判断·变量名