Bỏ qua để đến nội dung

Buffer protocol, memoryview, array, struct và mmap: làm việc với byte không cần copy

Khi xử lý dữ liệu nhị phân lớn (file ảnh, gói tin mạng, file log vài GB), thứ làm chương trình chậm nhất thường không phải tính toán mà là sao chép bộ nhớ. Python có một cơ chế ít người biết để tránh chuyện đó: buffer protocol.

Trong bài này, bạn sẽ học:

  • Buffer protocol là gì và những object nào hỗ trợ nó
  • Dùng memoryview để cắt và sửa dữ liệu lớn mà không sao chép
  • Tái sử dụng buffer với readinto / recv_into
  • array.array cho dãy số lớn gọn như C
  • Đọc/ghi định dạng nhị phân với struct và bẫy padding
  • Xử lý file lớn hơn RAM bằng mmap

1. Vấn đề: slicing bytes luôn tạo bản sao

Phần tiêu đề “1. Vấn đề: slicing bytes luôn tạo bản sao”
data = bytes(10_000_000) # 10 MB
chunk = data[1:] # tạo MỘT OBJECT MỚI 10 MB, copy từng byte

Nếu bạn xử lý một gói tin bằng cách liên tục cắt data = data[n:], mỗi lần cắt là một lần copy toàn bộ phần còn lại → O(n²).

Đây là một giao thức ở tầng C cho phép một object cho object khác mượn trực tiếp vùng nhớ thô của nó, kèm thông tin mô tả (kích thước phần tử, định dạng, số chiều, stride). Các object hỗ trợ:

Object Ghi được?
bytes không
bytearray
array.array
memoryview tuỳ object gốc
mmap.mmap tuỳ chế độ mở
numpy.ndarray, PIL.Image (qua numpy)…

Nhờ giao thức này, f.write(arr), socket.send(buf), hashlib.sha256(buf), numpy.frombuffer(buf) đều làm việc trực tiếp trên vùng nhớ gốc mà không cần chuyển đổi.

import timeit
data = bytes(10_000_000)
mv = memoryview(data)
print(timeit.timeit("data[1:]", globals=globals(), number=100)) # ~0.026s
print(timeit.timeit("mv[1:]", globals=globals(), number=100)) # ~0.000006s

mv[1:] chỉ tạo một “cửa sổ” mới (vài chục byte metadata) nhìn vào cùng vùng nhớ - nhanh hơn hàng nghìn lần.

ba = bytearray(b"hello world")
m = memoryview(ba)
m[0:5] = b"HELLO"
print(ba) # bytearray(b'HELLO world') - object gốc bị thay đổi

sock.send() có thể chỉ gửi được một phần. Cách “ngây thơ” cắt bytes sẽ copy liên tục:

def send_all(sock, data: bytes):
view = memoryview(data)
while view:
sent = sock.send(view) # send nhận memoryview trực tiếp
view = view[sent:] # không copy

(Thực tế đã có sock.sendall(), nhưng đây là mẫu chung cho mọi vòng lặp “tiêu thụ dần” buffer.)

Ứng dụng: đọc thẳng vào buffer có sẵn

Phần tiêu đề “Ứng dụng: đọc thẳng vào buffer có sẵn”
import io
buf = bytearray(4)
f = io.BytesIO(b"abcdefgh")
n = f.readinto(buf) # ghi thẳng vào buf, không tạo bytes mới
print(n, buf) # 4 bytearray(b'abcd')

readinto (file) và recv_into (socket) cho phép tái sử dụng một buffer duy nhất cho hàng triệu lần đọc - giảm áp lực cho bộ cấp phát và GC.

Lưu ý: memoryview “khoá” kích thước object gốc

Phần tiêu đề “Lưu ý: memoryview “khoá” kích thước object gốc”
ba = bytearray(b"abc")
mv = memoryview(ba)
try:
ba.append(100)
except BufferError as e:
print(e) # Existing exports of data: object cannot be re-sized
mv.release() # hoặc dùng: with memoryview(ba) as mv: ...
ba.append(100) # OK

Khi đang cho mượn vùng nhớ, bytearray không được đổi kích thước (vì realloc sẽ làm memoryview trỏ vào vùng nhớ đã giải phóng).

Như đã thấy ở bài Object trong bộ nhớ, list các số tốn ~36 byte mỗi phần tử. array lưu số trực tiếp:

from array import array
a = array("i", [1, 2, 3, 4]) # 'i' = int C 4 byte
m = memoryview(a)
print(m.format, m.itemsize, m.nbytes) # i 4 16
print(m.tolist()) # [1, 2, 3, 4]
# Nhìn cùng vùng nhớ dưới dạng từng byte
print(m.cast("B").tolist()[:8]) # [1, 0, 0, 0, 2, 0, 0, 0] (little-endian)

Mã kiểu phổ biến: 'b'/'B' (1 byte), 'h'/'H' (2), 'i'/'I' (4), 'q'/'Q' (8), 'f' (float 4 byte), 'd' (double 8 byte).

Ghi/đọc mảng số ra file nhị phân cực nhanh:

from array import array
nums = array("d", (i * 0.5 for i in range(1_000_000)))
with open("nums.bin", "wb") as f:
nums.tofile(f) # ghi 8 MB trực tiếp
loaded = array("d")
with open("nums.bin", "rb") as f:
loaded.fromfile(f, 1_000_000)
print(loaded[:3]) # array('d', [0.0, 0.5, 1.0])

Khi cần tính toán vector hoá, hãy chuyển sang NumPy - và nhờ buffer protocol, numpy.frombuffer(a, dtype="float64") dùng chung vùng nhớ với array mà không copy.

File ảnh, file âm thanh, giao thức mạng… đều có “header” với các trường số theo thứ tự byte cố định. struct chuyển đổi giữa giá trị Python và byte:

import struct
# '<' little-endian, '4s' chuỗi 4 byte, 'I' uint32, 'I' uint32
HEADER = struct.Struct("<4sII")
raw = HEADER.pack(b"IMG0", 1920, 1080)
print(raw, HEADER.size) # 12 byte
print(HEADER.unpack(raw)) # (b'IMG0', 1920, 1080)

Đọc một bản ghi ở giữa buffer mà không cần cắt:

magic, w, h = HEADER.unpack_from(buffer, offset)
HEADER.pack_into(out_buffer, offset, b"IMG0", w, h)
import struct
print(struct.calcsize("BI")) # 8 - chế độ native, chèn 3 byte padding để căn lề I
print(struct.calcsize("<BI")) # 5 - chỉ định byte order thì KHÔNG padding

Khi đọc định dạng file/giao thức, luôn ghi rõ byte order (<, >, ! cho network) để tránh padding và khác biệt giữa các máy.

Nối bytes trong vòng lặp tạo object mới mỗi lần. bytearray có over-allocation giống list:

buf = bytearray()
for chunk in chunks:
buf += chunk # nối tại chỗ, khấu hao O(1)
data = bytes(buf) # chuyển về bytes khi xong nếu cần

7. mmap: coi file như một mảng byte trong RAM

Phần tiêu đề “7. mmap: coi file như một mảng byte trong RAM”

mmap ánh xạ file vào không gian địa chỉ của tiến trình. Hệ điều hành chỉ nạp những trang (page) bạn thực sự đọc, nên bạn có thể “mở” file lớn hơn cả RAM:

import mmap
with open("big.log", "rb") as f, \
mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
pos = mm.find(b"ERROR") # tìm kiếm ở tốc độ C trên toàn file
print(pos, mm[pos:pos + 80]) # slice của mmap trả về bytes (chỉ copy đoạn nhỏ)
view = memoryview(mm) # zero-copy nếu cần xử lý đoạn lớn

Ứng dụng:

  • Tìm kiếm/đọc ngẫu nhiên trong file nhiều GB.
  • Nhiều tiến trình cùng đọc một file dữ liệu lớn mà chỉ tốn một bản trong page cache của hệ điều hành.
  • Chia sẻ bộ nhớ giữa các tiến trình (xem thêm multiprocessing.shared_memory trong bài Multiprocessing).
  1. Viết hàm đọc một file nhị phân lớn theo khối 64 KB bằng readinto vào một bytearray dùng lại, và tính SHA-256 của file.
  2. Dùng struct đọc header của một file BMP hoặc WAV thật (tìm tài liệu định dạng trên mạng) và in ra kích thước ảnh / tần số lấy mẫu.
  3. Dùng mmap đếm số dòng chứa chuỗi ERROR trong một file log vài trăm MB, so sánh thời gian với cách đọc từng dòng.

Bạn đã đi qua những kiến thức cốt lõi của bài này:

Công cụ Dùng khi
memoryview Cắt/ghi vùng nhớ lớn mà không copy
readinto / recv_into Tái sử dụng buffer khi đọc liên tục
array.array Dãy số lớn cùng kiểu, tiết kiệm ~9 lần bộ nhớ so với list
struct Đọc/ghi định dạng nhị phân (header file, giao thức)
bytearray Xây dựng dữ liệu byte dần dần
mmap File lớn, truy cập ngẫu nhiên, chia sẻ giữa tiến trình

Bài tiếp theo: Bytecode và cách CPython thực thi code.