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.arraycho dãy số lớn gọn như C- Đọc/ghi định dạng nhị phân với
structvà 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 MBchunk = data[1:] # tạo MỘT OBJECT MỚI 10 MB, copy từng byteNế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²).
2. Buffer protocol là gì?
Phần tiêu đề “2. Buffer protocol là gì?”Đâ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 |
có |
array.array |
có |
memoryview |
tuỳ object gốc |
mmap.mmap |
tuỳ chế độ mở |
numpy.ndarray, PIL.Image (qua numpy)… |
có |
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.
3. memoryview: cắt lát không sao chép
Phần tiêu đề “3. memoryview: cắt lát không sao chép”import timeit
data = bytes(10_000_000)mv = memoryview(data)
print(timeit.timeit("data[1:]", globals=globals(), number=100)) # ~0.026sprint(timeit.timeit("mv[1:]", globals=globals(), number=100)) # ~0.000006smv[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.
Ghi qua memoryview
Phần tiêu đề “Ghi qua memoryview”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Ứng dụng: gửi dữ liệu qua socket
Phần tiêu đề “Ứng dụng: gửi dữ liệu qua socket”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ớiprint(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) # OKKhi đ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).
4. array.array: dãy số gọn như C
Phần tiêu đề “4. array.array: dãy số gọn như C”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 bytem = memoryview(a)print(m.format, m.itemsize, m.nbytes) # i 4 16print(m.tolist()) # [1, 2, 3, 4]
# Nhìn cùng vùng nhớ dưới dạng từng byteprint(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.
5. struct: đọc/ghi định dạng nhị phân
Phần tiêu đề “5. struct: đọc/ghi định dạng nhị phân”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' uint32HEADER = struct.Struct("<4sII")
raw = HEADER.pack(b"IMG0", 1920, 1080)print(raw, HEADER.size) # 12 byteprint(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)Bẫy: padding căn lề
Phần tiêu đề “Bẫy: padding căn lề”import structprint(struct.calcsize("BI")) # 8 - chế độ native, chèn 3 byte padding để căn lề Iprint(struct.calcsize("<BI")) # 5 - chỉ định byte order thì KHÔNG paddingKhi đọ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.
6. bytearray - chuỗi byte có thể thay đổi
Phần tiêu đề “6. bytearray - chuỗi byte có thể thay đổi”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ần7. 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_memorytrong bài Multiprocessing).
Bài tập
Phần tiêu đề “Bài tập”- Viết hàm đọc một file nhị phân lớn theo khối 64 KB bằng
readintovào mộtbytearraydùng lại, và tính SHA-256 của file. - 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. - Dùng
mmapđếm số dòng chứa chuỗiERRORtrong một file log vài trăm MB, so sánh thời gian với cách đọc từng dòng.
Kết luận
Phần tiêu đề “Kết luận”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.