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

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 del mộ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 tracemallocgc.get_referrers
┌─────────────────────────────────────────────┐
│ 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ỏ.

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 sys
sys._debugmallocstats()

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 arena
del data
gc.collect()
print(f"after del {rss_mb():.0f} MB")
start 15 MB
after alloc 224 MB
after 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 a
b = [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.

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 = b
b.other = a # A -> B -> A
del 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ỷ B

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 gc
print(gc.is_tracked(1)) # False
print(gc.is_tracked("abc")) # False
print(gc.is_tracked([])) # True
print(gc.is_tracked({})) # False - dict chỉ chứa atomic được "bỏ theo dõi"
print(gc.is_tracked({"a": []})) # True
  1. Với mỗi container được theo dõi, copy refcount sang một biến tạm gc_refs.
  2. 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_refs của đích đi 1.
  3. Object nào còn gc_refs > 0 nghĩ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.
  4. Phần còn lại là rác chỉ trỏ lẫn nhau → giải phóng.

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 gc
print(gc.get_threshold()) # (2000, 10, 10) trên CPython 3.13
print(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.)

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.08s
GC 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.

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.

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).

“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 B
leak.py:13: size=1554 KiB (+1554 KiB), count=49743 (+49743), average=32 B
...
current=20.4 MB, peak=20.4 MB

Kế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:

  1. Gọi tracemalloc.start(25) (lưu 25 frame traceback) khi khởi động.
  2. Chụp snapshot định kỳ (ví dụ qua một endpoint debug).
  3. So sánh hai snapshot cách nhau vài giờ bằng compare_to(..., "traceback").
  4. Dòng nào tăng đều đặn là thủ phạm.
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.

  1. 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 tracemalloc chụp hai snapshot và tìm ra dòng gây leak.
  2. Tạo 1 triệu object Node có vòng tham chiếu cha-con, đo thời gian tạo khi GC bật và khi gc.disable(). Sau đó sửa bằng weakref để không còn vòng.
  3. Dùng gc.callbacks in ra thời gian mỗi lần GC chạy trong một chương trình tạo nhiều object.

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.