Python bytearray 为什么不能 append:memoryview 子视图仍在使用缓冲区
bytearray明明是可变的,append却报BufferError。变量没有被冻住,也不是内存已经用完;它可能正在向memoryview提供一块必须保持有效的缓冲区。
原地改一个字节,与改变缓冲区大小,是两种操作。视图可以在不复制数据的情况下观察和修改内容,但导出者不能在视图仍被使用时随意改变存储范围。把“零复制”只理解成性能选项,就容易忽略这份生命周期责任。
下面用Python 3.12逐步保留一个子视图,观察释放父视图以后,为什么扩容仍然失败。
def expect_error(error_type, action, label):
try:
action()
except error_type:
print(label, 'rejected')
else:
raise AssertionError(f'{label}: expected {error_type.__name__}')
def main():
data = bytearray(b'abcd')
parent = memoryview(data)
child = parent[1:3]
snapshot = bytes(child)
parent[0] = ord('A')
child[0] = ord('B')
print('shared bytes:', bytes(data))
print('independent snapshot:', snapshot)
assert data == bytearray(b'ABcd') and snapshot == b'bc'
expect_error(BufferError, lambda: data.append(33), 'resize with views')
parent.release()
expect_error(ValueError, lambda: parent[0], 'read released parent')
expect_error(BufferError, lambda: data.append(33), 'resize with child alive')
assert bytes(child) == b'Bc'
child.release()
data.append(33)
print('after both released:', bytes(data))
assert data == bytearray(b'ABcd!')
with memoryview(data) as active:
readonly = active.toreadonly()
try:
expect_error(TypeError, lambda: readonly.__setitem__(0, 90), 'write through readonly')
data[0] = ord('Z')
print('readonly observes owner change:', bytes(readonly))
expect_error(BufferError, lambda: data.extend(b'xx'), 'resize while readonly alive')
finally:
readonly.release()
data.extend(b'xx')
print('after context:', bytes(data))
with memoryview(bytearray(range(6))).cast('B', shape=[2, 3]) as matrix:
print('matrix len:', len(matrix), 'nbytes:', matrix.nbytes, 'shape:', matrix.shape)
assert len(matrix) == 2 and matrix.nbytes == 6
if __name__ == '__main__':
main()
保存为view_lifetime.py执行。前两次修改直接出现在data里,先前复制的snapshot仍然是bc。释放parent后,读取它失败,但child仍能读取Bc,而且继续阻止bytearray扩容。child也释放后,append才成功。
这说明“我已经release了那个变量”不足以证明底层缓冲区没有使用者。切片得到的子视图有自己的生命周期,需要单独管理。把视图传给另一个函数后,调用者也不能单凭自己那份with结束就假定所有派生视图都已经消失。
readonly 限制写入口,不制造不可变快照
toreadonly阻止通过这份视图写入,却没有复制数据。原bytearray仍能做同长度修改,readonly随后观察到新内容。因此只读视图适合限制某个消费者的写入口,不适合宣称内容永远不变。
需要稳定快照时,可以像程序一样转成bytes,承担相应复制成本;或者规定导出期间所有拥有者都不修改,并用明确同步机制维护这项合同。视图标志不会替其他引用加锁。
释放readonly以后,with管理的active仍然存在,底层也还没有完全脱离使用。程序在两者作用域结束后才extend,刻意避免把只释放其中一份误认为扩容许可。
对共享缓冲区做并发处理时,还应决定谁可以写、何时完成、是否允许消费方保留子视图。数据没有复制,恰好意味着不同引用可能观察到同一块变化;这不是线程安全或任务安全保证。
release 让当前视图失效,不负责销毁拥有者
释放后继续索引parent会抛ValueError,这能帮助暴露超期使用。底层bytearray仍在,并不会因为一个视图释放就自动清空或删除。
with语句让释放与作用域绑定,适合短期处理一块已有数据。若把视图返回出去,或者放进列表等待异步任务消费,就需要更长的资源管理协议。不要把“函数已返回”当成所有消费者都处理完成。
遇到BufferError时,强行重建bytearray可能绕过当前扩容限制,却会让已有视图继续指向旧对象。新旧调用方随后观察两份不同数据,容易产生更隐蔽的逻辑错误。应该先确认谁还持有视图,是否真的允许它延长生命周期。
若确实需要动态追加输入,可以将当前块作为不可变或受控所有权的分段交付,下一块使用另一份存储;也可以在交付前复制需要保留的片段。选择取决于数据量与消费者期限,不能为了保留零复制标签,让缓冲区一直无法回收。
缓存视图同样会延长拥有者寿命。一个看起来只有十几个字节的子视图,可能仍然依赖很大的原始缓冲区;保留它不代表只保留那一小段内存。若数据要长期进入索引或缓存,复制少量必要字节有时反而能让大缓冲区及时退出,而不是始终追求不复制。
对象被del也只是移除一个引用,不能证明所有其他视图或消费者都已结束。依赖垃圾回收“迟早释放”会让扩容失败时点难以预测。示例显式release与with,让失败和许可由可见代码边界决定,这一点对跨函数传递尤其有价值。
切片、格式与长度要用同一套单位理解
bytes或bytearray普通切片通常产生独立值,memoryview切片则创建新视图。两种写法外观接近,保存到后台任务后的所有权完全不同。评审缓冲区代码时,要先看对象类型,再判断切片意味着复制还是共享。
memoryview还可能有多维shape与不同元素格式。示例最后一份二维视图len为2,nbytes为6,说明len不总是底层字节数。把len(view)无条件当成网络载荷长度,会在非一维字节视图中出错。
向只接受连续字节的接口传递视图时,也应核对格式、连续性与维度。并非所有可切片视图都能直接交给任意原生库;必要时转换成符合接口要求的连续数据,再明确这次转换是否复制。
本文选择可变bytearray来显示修改和扩容边界;如果拥有者是bytes,视图天然不能通过它修改内容,拥有者也没有append这样的原地扩容接口。不能把字节数组实验中的每个失败形式直接推广到所有缓冲区提供者,应核对具体对象支持的操作。
零复制只减少某个环节的搬运,不代表整个调用链都没有分配。把视图转成bytes、转码或交给会复制的下游,都可能重新产生数据副本;没有测量时,不应仅凭出现memoryview就宣传性能提升。
最容易定位的做法,是给导出和释放写清楚作用域:在谁手里创建,是否派生,哪一处确认不再使用,然后才允许拥有者改变大小。程序里parent、child、readonly三条路径,正好可以作为这份生命周期协议的回归样本。