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

Bên trong một object Python: PyObject, kích thước và bố cục bộ nhớ

Bài này mở đầu mục Python Nâng Cao. Mục tiêu là trả lời câu hỏi: khi bạn viết x = 1000, thực sự có gì nằm trong RAM? Hiểu được điều này, bạn sẽ giải thích được vì sao Python “tốn bộ nhớ”, vì sao list chứa số lại nặng hơn array, và vì sao một chuỗi có emoji lại to gấp 4 lần chuỗi ASCII.

Tất cả các chi tiết trong bài là của CPython (bản Python chuẩn tải từ python.org). Số liệu đo trên CPython 3.13, máy 64-bit. Các bản khác (PyPy, …) có thể khác.

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

  • Header PyObject mà mọi object đều có: refcount và con trỏ type
  • Vì sao biến trong Python chỉ là “cái tên” trỏ tới object, và id() thực chất là gì
  • Immortal object (Python 3.12+) và lợi ích của nó
  • Cách int lưu số lớn tuỳ ý bằng các “chữ số” 30-bit
  • Cách str chọn 1, 2 hoặc 4 byte cho mỗi ký tự (PEP 393) và string interning
  • Vì sao sys.getsizeof gây hiểu lầm và cách đo kích thước thật

Trong mã nguồn C của CPython, mọi object đều bắt đầu bằng cùng một “phần đầu” (header) gọi là PyObject:

typedef struct _object {
Py_ssize_t ob_refcnt; // 8 byte: số tham chiếu đang trỏ tới object
PyTypeObject *ob_type; // 8 byte: con trỏ tới type (int, list, ...)
} PyObject;

Với object có độ dài thay đổi (như int, tuple, str…), còn có thêm trường ob_size:

typedef struct {
PyObject ob_base; // 16 byte header
Py_ssize_t ob_size; // 8 byte: số phần tử
} PyVarObject;

Nghĩa là: object nhỏ nhất trong Python cũng đã tốn 16 byte chỉ cho phần header. Đây là cái giá của việc “mọi thứ đều là object”.

Biến x (trong namespace) Heap
┌─────────┐ ┌──────────────────────┐
│ x ────┼──────────────► │ ob_refcnt = 1 │ 8 byte
└─────────┘ │ ob_type = &int │ 8 byte
│ ob_size = 1 │ 8 byte
│ digit[0] = 1000 │ 4 byte
└──────────────────────┘ = 28 byte

Biến trong Python không chứa giá trị, nó chỉ là một cái tên gắn với một con trỏ (8 byte) trỏ tới object trên heap. Và id(x) trong CPython chính là địa chỉ bộ nhớ của object đó.

Ta có thể đọc trường ob_refcnt ngay tại địa chỉ id(obj):

import ctypes
a = []
b = a # thêm 1 tham chiếu
# ob_refcnt nằm ở đầu object, tức là tại địa chỉ id(a)
print(ctypes.c_ssize_t.from_address(id(a)).value) # 2

sys.getrefcount(a) cũng cho biết số này nhưng luôn lớn hơn 1, vì chính việc truyền a vào hàm đã tạo thêm một tham chiếu tạm thời.

Từ Python 3.12 (PEP 683), một số object như None, True, False, các số nguyên nhỏ… được đánh dấu là bất tử (immortal): refcount của chúng không bao giờ thay đổi và chúng không bao giờ bị giải phóng.

import sys
print(sys.getrefcount(None)) # 4294967295 (một giá trị "ma thuật", không phải số tham chiếu thật)
print(sys.getrefcount(1)) # 4294967295

Lợi ích: nhiều thread/tiến trình dùng chung các object này mà không phải ghi vào bộ nhớ của chúng (không làm “bẩn” trang bộ nhớ khi fork, không tranh chấp cache CPU).

Python cho phép số nguyên lớn tuỳ ý (2**1000 vẫn chạy). Bí mật: int được lưu dưới dạng mảng các “chữ số” 30-bit.

import sys
print(sys.int_info)
# sys.int_info(bits_per_digit=30, sizeof_digit=4, ...)
print(sys.getsizeof(0)) # 28
print(sys.getsizeof(2**30 - 1)) # 28 -> 1 chữ số 30-bit
print(sys.getsizeof(2**30)) # 32 -> 2 chữ số
print(sys.getsizeof(2**60)) # 36 -> 3 chữ số
print(sys.getsizeof(10**100)) # 72

Công thức: 24 byte header + 4 byte × số chữ số. Mỗi khi số vượt qua một bội của 2^30, object tăng thêm 4 byte.

Hệ quả thực tế:

  • Một số nguyên nhỏ trong Python tốn 28 byte, trong khi int32 trong C chỉ tốn 4 byte - gấp 7 lần.
  • Phép toán trên số lớn không còn O(1). Nhân hai số có hàng nghìn chữ số là phép toán đắt.
  • Chuyển số rất lớn sang chuỗi bị giới hạn (default_max_str_digits=4300) để tránh tấn công DoS - đó là lý do str(10**5000) báo ValueError.

CPython tạo sẵn các số từ -5 đến 256 khi khởi động và dùng lại chúng. Vì vậy mọi phép tính cho ra 100 đều trả về cùng một object. Xem thêm bài Integer caching.

import sys
print(sys.getsizeof(1.5)) # 24 = 16 header + 8 byte double
print(sys.getsizeof(1+2j)) # 32 = 16 header + 2 × 8 byte
print(sys.getsizeof(True)) # 28 - bool là lớp con của int!
print(sys.getsizeof(None)) # 16 - chỉ có header

float luôn là số thực 64-bit IEEE 754, không có “float32” trong Python thuần. Nếu cần tiết kiệm, hãy dùng array('f') hoặc NumPy.

4. str - chuỗi “co giãn” theo ký tự lớn nhất (PEP 393)

Phần tiêu đề “4. str - chuỗi “co giãn” theo ký tự lớn nhất (PEP 393)”

Đây là chi tiết ít người biết nhất. Từ Python 3.3, CPython lưu chuỗi theo 3 kiểu mã hoá nội bộ, chọn theo ký tự có mã lớn nhất trong chuỗi:

Ký tự lớn nhất trong chuỗi Mỗi ký tự tốn Ví dụ
≤ U+00FF (ASCII / Latin-1) 1 byte "hello", "café"
≤ U+FFFF (BMP) 2 byte "Việt Nam", "€"
> U+FFFF 4 byte "😀"
import sys
print(sys.getsizeof("a" * 100)) # 141 -> 1 byte/ký tự
print(sys.getsizeof("é" * 100)) # 157 -> vẫn 1 byte (Latin-1) nhưng header khác ASCII
print(sys.getsizeof("" * 100)) # 258 -> 2 byte/ký tự
print(sys.getsizeof("😀" * 100)) # 460 -> 4 byte/ký tự
# Chỉ CẦN MỘT emoji, cả chuỗi chuyển sang 4 byte/ký tự
print(sys.getsizeof("a" * 99 + "😀")) # 460

Hệ quả:

  • Tiếng Việt có dấu (như = U+1EC7) làm cả chuỗi dùng 2 byte/ký tự.
  • Một emoji trong một văn bản dài 1 MB ký tự làm chuỗi đó phình lên ~4 MB trong RAM.
  • Nhờ độ dài cố định mỗi ký tự, s[i]len(s) vẫn là O(1) - thứ mà UTF-8 không làm được.

Các chuỗi trông giống định danh (chỉ gồm chữ, số, _) xuất hiện trong mã nguồn được intern - dùng chung một object. Chuỗi tạo ra lúc chạy thì không:

import sys
s1 = "hello_world"
s3 = "".join(["hello", "_world"]) # tạo lúc chạy
print(s1 == s3) # True
print(s1 is s3) # False - hai object khác nhau
print(s1 is sys.intern(s3)) # True

Ứng dụng: khi xử lý hàng triệu bản ghi có trường lặp lại (tên thành phố, mã trạng thái…), sys.intern() giúp mọi bản ghi dùng chung một object chuỗi, vừa tiết kiệm RAM vừa giúp so sánh dict key nhanh hơn (so sánh con trỏ trước khi so sánh nội dung).

sys.getsizeof chỉ trả về kích thước của chính object đó, không tính các object nó trỏ tới:

import sys
x = [1, 2, 3]
y = [x, x]
print(sys.getsizeof(y)) # 72 - chỉ gồm header list + 2 con trỏ

y chỉ chứa 2 con trỏ tới cùng một list x. Để đo “kích thước thật” bạn phải duyệt đệ quy và tránh đếm trùng:

import sys
from collections import deque
def deep_getsizeof(obj):
"""Tổng kích thước của obj và mọi thứ nó tham chiếu (không đếm trùng)."""
seen = set()
total = 0
todo = deque([obj])
while todo:
o = todo.pop()
if id(o) in seen:
continue
seen.add(id(o))
total += sys.getsizeof(o)
if isinstance(o, dict):
todo.extend(o.keys())
todo.extend(o.values())
elif isinstance(o, (list, tuple, set, frozenset)):
todo.extend(o)
elif hasattr(o, "__dict__"):
todo.append(vars(o))
return total
data = {"names": ["An", "Bình", "Chi"], "ages": [20, 21, 22]}
print(sys.getsizeof(data)) # 184
print(deep_getsizeof(data)) # tổng thật, lớn hơn nhiều

Trong thực tế, để đo bộ nhớ của cả chương trình, tracemalloc (xem bài Bộ cấp phát bộ nhớ & GC) là công cụ chính xác hơn.

6. Ví dụ tổng hợp: vì sao list số lại nặng?

Phần tiêu đề “6. Ví dụ tổng hợp: vì sao list số lại nặng?”
import sys
from array import array
nums_list = list(range(1000))
nums_array = array("i", range(1000))
print(sys.getsizeof(nums_list)) # 8056 (chỉ là mảng con trỏ!)
print(sys.getsizeof(nums_array)) # 4200 (4 byte/số + một ít chỗ dự trữ)

Nhưng 8056 byte của list mới chỉ là mảng con trỏ. Mỗi con trỏ còn trỏ tới một object int riêng 28 byte (trừ các số -5..256 được cache). Tổng cộng list ~ 8 KB + 1000 × 28 B ≈ 36 KB, so với 4 KB của array.

list: [ptr][ptr][ptr]... array('i'): [ 0][ 1][ 2]...
│ │ │ 4 byte mỗi số, liền nhau
▼ ▼ ▼
int int int (mỗi cái 28 byte, rải rác trên heap)

Đây là lý do NumPy nhanh và gọn: nó lưu số liên tục như array, không có header cho từng phần tử, và CPU đọc bộ nhớ liền mạch hiệu quả hơn nhiều (cache-friendly).

  1. Viết hàm int_digits(n) trả về số “chữ số 30-bit” CPython dùng để lưu n, rồi kiểm tra với công thức (sys.getsizeof(n) - 24) // 4.
  2. Tạo một chuỗi tiếng Việt dài 1 triệu ký tự và một chuỗi ASCII cùng độ dài. So sánh sys.getsizeof của chúng và giải thích.
  3. Mở rộng hàm deep_getsizeof để hỗ trợ object dùng __slots__ (gợi ý: duyệt type(o).__slots__).

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

Kiến thức Ý nghĩa thực tế
Mọi object có header 16 byte (refcount + type) Object nhỏ trong Python rất “đắt”
Biến chỉ là con trỏ 8 byte Gán b = a không copy dữ liệu
int = mảng chữ số 30-bit Số lớn tuỳ ý nhưng mỗi số ≥ 28 byte
str dùng 1/2/4 byte mỗi ký tự Một emoji có thể làm chuỗi to gấp 4
sys.getsizeof không tính object con Dùng hàm đệ quy hoặc tracemalloc
Immortal objects (3.12+) None, số nhỏ… không bao giờ bị giải phóng

Bài tiếp theo: List và Tuple trong bộ nhớ.