Bộ cấp phát bộ nhớ, Garbage Collector và truy tìm memory leak
Bài Quản lý bộ nhớ đã giới thiệu reference counting và garbage collection. Bài này đi sâu hơn một tầng: bộ nhớ thực sự được xin từ hệ điều hành như thế nào, vì sao del một list khổng lồ mà RAM không giảm, và làm sao tìm ra “thủ phạm” khi chương trình của bạn cứ phình to dần.
Trong bài này, bạn sẽ học:
- Ba tầng cấp phát bộ nhớ của CPython và bộ cấp phát pymalloc (arena, pool, block)
- Vì sao
delmột cấu trúc dữ liệu khổng lồ mà RAM không giảm (phân mảnh) - Freelist và vì sao không nên tin
id()của object đã chết - Garbage collector phát hiện vòng tham chiếu thế nào, thế hệ là gì
- Tinh chỉnh GC trong thực tế:
gc.disable(),gc.freeze() - Quy trình tìm memory leak bằng
tracemallocvàgc.get_referrers
1. Ba tầng cấp phát bộ nhớ
Phần tiêu đề “1. Ba tầng cấp phát bộ nhớ”┌─────────────────────────────────────────────┐│ Object allocator (int, list, dict, ...) │ freelist riêng cho từng kiểu├─────────────────────────────────────────────┤│ pymalloc: object nhỏ ≤ 512 byte │ arena → pool → block├─────────────────────────────────────────────┤│ malloc của hệ thống: object > 512 byte │ libc malloc / mmap├─────────────────────────────────────────────┤│ Hệ điều hành (RAM ảo) │└─────────────────────────────────────────────┘Chương trình Python tạo và huỷ hàng triệu object nhỏ mỗi giây (số nguyên tạm, tuple trả về, frame…). Gọi malloc/free của hệ điều hành cho từng object sẽ rất chậm, nên CPython có bộ cấp phát riêng tên pymalloc cho object nhỏ.
2. pymalloc: arena, pool và block
Phần tiêu đề “2. pymalloc: arena, pool và block”Arena (1 MiB trên 64-bit, xin từ OS bằng mmap)┌──────────┬──────────┬──────────┬──────────┬─────┐│ Pool 16K │ Pool 16K │ Pool 16K │ Pool 16K │ ... │└────┬─────┴──────────┴──────────┴──────────┴─────┘ │ ▼ Pool cho size class 32 byte ┌────────┬───────┬───────┬───────┬───────┬─────┐ │ header │ block │ block │ block │ block │ ... │ mọi block trong pool cùng kích thước └────────┴───────┴───────┴───────┴───────┴─────┘- Block: đơn vị nhỏ nhất, kích thước là bội của 16 byte (16, 32, 48, …, 512). Một object 28 byte (
int) sẽ nằm trong block 32 byte. - Pool: 16 KiB (từ 3.10), chỉ chứa block cùng một kích thước. Pool giữ một danh sách block trống để cấp phát trong vài lệnh CPU.
- Arena: 1 MiB, chứa nhiều pool. Đây là đơn vị duy nhất pymalloc xin và trả cho hệ điều hành.
Bạn có thể xem thống kê chi tiết (in ra stderr):
import syssys._debugmallocstats()Hệ quả: RAM không giảm sau khi del
Phần tiêu đề “Hệ quả: RAM không giảm sau khi del”pymalloc chỉ trả một arena về hệ điều hành khi toàn bộ arena đó trống. Chỉ cần một object nhỏ còn sống trong arena là cả 1 MiB bị giữ lại. Đây là hiện tượng phân mảnh bộ nhớ (fragmentation).
import gc, os, subprocess
def rss_mb(): out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(os.getpid())]) return int(out) / 1024
print(f"start {rss_mb():.0f} MB")data = [str(i) * 3 for i in range(3_000_000)]print(f"after alloc {rss_mb():.0f} MB")
keep = data[::1000] # giữ lại chỉ 0.1% số chuỗi, rải rác khắp các arenadel datagc.collect()print(f"after del {rss_mb():.0f} MB")start 15 MBafter alloc 224 MBafter del 224 MB <- giữ 0.1% object nhưng không trả được MB nào!Nếu bỏ dòng keep = ..., RAM tụt về khoảng 41 MB. Bài học thực tế:
- Tránh để object sống lâu (cache, log, kết quả) được tạo xen kẽ với hàng triệu object tạm.
- Với tác vụ xử lý dữ liệu lớn một lần, hãy chạy trong tiến trình con (
multiprocessing) - khi tiến trình kết thúc, toàn bộ RAM được trả lại. - Trên Linux, một số dự án chuyển sang
jemalloc/mimallocđể giảm phân mảnh (object > 512 byte). Bản free-threaded của Python 3.13+ dùng sẵn mimalloc.
3. Freelist: kho tái sử dụng cho từng kiểu
Phần tiêu đề “3. Freelist: kho tái sử dụng cho từng kiểu”Ngoài pymalloc, một số kiểu có freelist riêng: float, tuple (nhỏ), list, dict, frame… Khi object bị huỷ, CPython không trả block mà cất vào freelist để lần tạo sau lấy ra ngay:
a = [1, 2]old_id = id(a)del ab = [3, 4]print(id(b) == old_id) # thường là True - tái dùng ngay vùng nhớ vừa huỷĐó là lý do bạn không bao giờ nên dựa vào id() của object đã chết - id có thể được tái sử dụng.
4. Garbage Collector: dọn vòng tham chiếu
Phần tiêu đề “4. Garbage Collector: dọn vòng tham chiếu”Reference counting giải phóng object ngay khi refcount về 0. Nhưng nó bó tay với vòng tham chiếu (reference cycle):
import gc
class Node: def __init__(self, name): self.name = name self.other = None def __del__(self): print("huỷ", self.name)
a = Node("A")b = Node("B")a.other = bb.other = a # A -> B -> Adel a, b # refcount của mỗi node vẫn là 1, KHÔNG có gì được in ra
print("gọi gc.collect()")print("thu gom được", gc.collect(), "object")# huỷ A# huỷ BGC chỉ theo dõi “container”
Phần tiêu đề “GC chỉ theo dõi “container””GC chỉ quan tâm tới object có thể chứa tham chiếu tới object khác (list, dict, instance, …). int, str, float không bao giờ tạo vòng nên không được theo dõi:
import gcprint(gc.is_tracked(1)) # Falseprint(gc.is_tracked("abc")) # Falseprint(gc.is_tracked([])) # Trueprint(gc.is_tracked({})) # False - dict chỉ chứa atomic được "bỏ theo dõi"print(gc.is_tracked({"a": []})) # TrueThuật toán phát hiện vòng (tóm tắt)
Phần tiêu đề “Thuật toán phát hiện vòng (tóm tắt)”- Với mỗi container được theo dõi, copy refcount sang một biến tạm
gc_refs. - Duyệt mọi container, với mỗi tham chiếu nội bộ (từ container này tới container khác), trừ
gc_refscủa đích đi 1. - Object nào còn
gc_refs > 0nghĩa là có tham chiếu từ bên ngoài (biến, stack…) → còn sống. Mọi thứ mà nó trỏ tới cũng còn sống. - Phần còn lại là rác chỉ trỏ lẫn nhau → giải phóng.
Thế hệ (generations)
Phần tiêu đề “Thế hệ (generations)”Quan sát thực tế: hầu hết object chết trẻ. GC chia object thành các thế hệ, thế hệ trẻ được quét thường xuyên, thế hệ già hiếm khi quét:
import gcprint(gc.get_threshold()) # (2000, 10, 10) trên CPython 3.13print(gc.get_count()) # số object đang đếm ở mỗi thế hệ- Thế hệ 0 được quét khi số lần cấp phát trừ giải phóng container vượt 2000.
- Object sống sót sau một lần quét được “lên lớp” sang thế hệ kế tiếp.
(Các con số và thuật toán chi tiết có thay đổi giữa các phiên bản - Python 3.14 thử nghiệm GC tăng dần. Đừng hard-code giả định về chúng.)
5. Điều chỉnh GC trong thực tế
Phần tiêu đề “5. Điều chỉnh GC trong thực tế”Tắt GC tạm thời khi tạo hàng loạt object
Phần tiêu đề “Tắt GC tạm thời khi tạo hàng loạt object”Khi tạo hàng triệu object container (ví dụ parse JSON lớn), GC có thể kích hoạt liên tục và quét lại những object mà chắc chắn còn sống:
import gc, time
def build(): return [{"id": i, "tags": [i]} for i in range(2_000_000)]
t = time.perf_counter(); build(); print("GC bật:", time.perf_counter() - t)
gc.disable()try: t = time.perf_counter(); build(); print("GC tắt:", time.perf_counter() - t)finally: gc.enable()GC bật: 1.08sGC tắt: 0.35s (CPython 3.13; trên 3.14 chênh lệch nhỏ hơn: 0.47s so với 0.30s)Reference counting vẫn hoạt động khi GC tắt - chỉ có vòng tham chiếu là chưa được dọn.
gc.freeze() cho server dùng fork
Phần tiêu đề “gc.freeze() cho server dùng fork”Các server như Gunicorn/uWSGI nạp ứng dụng rồi fork ra nhiều worker. Tiến trình con dùng chung trang bộ nhớ với cha theo cơ chế copy-on-write. Nhưng mỗi lần GC chạy, nó ghi vào header của object → trang bộ nhớ bị copy → mỗi worker ngốn thêm RAM.
import gc
# Trong tiến trình cha, sau khi import/khởi tạo xong:gc.disable()# ... load app, models ...gc.freeze() # chuyển mọi object hiện có vào "thế hệ vĩnh viễn", GC không bao giờ quét# fork các worker ở đây; trong worker gọi gc.enable()gc.freeze() (Python 3.7+) được đội ngũ Instagram đề xuất sau khi thấy nó giúp các worker tiết kiệm đáng kể RAM.
Tránh tạo vòng: dùng weakref
Phần tiêu đề “Tránh tạo vòng: dùng weakref”Cấu trúc cây có con trỏ “cha” là nguồn tạo vòng phổ biến. Dùng tham chiếu yếu cho chiều ngược lại (xem bài __slots__ và weakref).
6. Truy tìm memory leak với tracemalloc
Phần tiêu đề “6. Truy tìm memory leak với tracemalloc”“Memory leak” trong Python hầu như không phải do Python quên giải phóng, mà do bạn vẫn giữ tham chiếu tới object mà không để ý: cache không giới hạn, list log toàn cục, closure, listener chưa huỷ đăng ký…
tracemalloc (thư viện chuẩn) ghi lại dòng code nào đã cấp phát từng khối bộ nhớ:
import tracemalloc
_cache = {}
def get_user(user_id): if user_id not in _cache: _cache[user_id] = {"id": user_id, "name": f"user-{user_id}" * 10} return _cache[user_id]
tracemalloc.start()before = tracemalloc.take_snapshot()
for i in range(50_000): get_user(i)
after = tracemalloc.take_snapshot()for stat in after.compare_to(before, "lineno")[:3]: print(stat)
current, peak = tracemalloc.get_traced_memory()print(f"current={current/1e6:.1f} MB, peak={peak/1e6:.1f} MB")leak.py:7: size=17.9 MiB (+17.9 MiB), count=149984 (+149984), average=125 Bleak.py:13: size=1554 KiB (+1554 KiB), count=49743 (+49743), average=32 B...current=20.4 MB, peak=20.4 MBKết quả chỉ thẳng vào dòng 7 - nơi cache lớn không giới hạn. Cách sửa: dùng functools.lru_cache(maxsize=...) hoặc cache có giới hạn.
Quy trình tìm leak trên server đang chạy:
- Gọi
tracemalloc.start(25)(lưu 25 frame traceback) khi khởi động. - Chụp snapshot định kỳ (ví dụ qua một endpoint debug).
- So sánh hai snapshot cách nhau vài giờ bằng
compare_to(..., "traceback"). - Dòng nào tăng đều đặn là thủ phạm.
Tìm ai đang giữ một object
Phần tiêu đề “Tìm ai đang giữ một object”import gc
leaked = [1, 2, 3]holder = {"data": leaked}
for ref in gc.get_referrers(leaked): if isinstance(ref, dict) and ref is not globals(): print("bị giữ bởi:", ref)Thư viện ngoài objgraph có thể vẽ đồ thị tham chiếu thành ảnh - rất hữu ích khi leak phức tạp.
Bài tập
Phần tiêu đề “Bài tập”- Viết một chương trình cố ý rò rỉ bộ nhớ (ví dụ list toàn cục lưu mọi request), dùng
tracemallocchụp hai snapshot và tìm ra dòng gây leak. - Tạo 1 triệu object
Nodecó vòng tham chiếu cha-con, đo thời gian tạo khi GC bật và khigc.disable(). Sau đó sửa bằngweakrefđể không còn vòng. - Dùng
gc.callbacksin ra thời gian mỗi lần GC chạy trong một chương trình tạo nhiều object.
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:
| Khái niệm | Điều cần nhớ |
|---|---|
| pymalloc | Object ≤ 512 byte, quản lý bằng arena (1 MiB) → pool (16 KiB) → block |
| Phân mảnh | Arena chỉ được trả OS khi trống hoàn toàn → RAM có thể không giảm |
| Freelist | id() của object đã huỷ có thể bị tái sử dụng |
| GC | Chỉ dọn vòng tham chiếu, chỉ theo dõi container, chia thế hệ |
gc.disable() / gc.freeze() |
Tăng tốc tạo object hàng loạt, tiết kiệm RAM khi fork |
tracemalloc |
Công cụ số một để tìm dòng code gây leak |
Bài tiếp theo: __slots__ và weakref.