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

__slots__ và weakref: tiết kiệm bộ nhớ và tránh giữ object quá lâu

Hai công cụ trong bài này giải quyết hai vấn đề bộ nhớ rất hay gặp:

  • __slots__: bạn có hàng triệu object nhỏ (điểm dữ liệu, node, bản ghi) và RAM không đủ.
  • weakref: bạn muốn tham chiếu tới một object (cache, observer, con trỏ ngược lên cha) mà không giữ nó sống.

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

  • __slots__ tiết kiệm bộ nhớ thế nào, đo trên 1 triệu object
  • Cơ chế descriptor phía sau slots
  • Các ràng buộc và bẫy của __slots__ khi kế thừa
  • Weak reference: tham chiếu mà không giữ object sống
  • Ba ứng dụng thực tế: cache tự dọn, con trỏ ngược lên cha, observer không gây leak
  • weakref.finalize - cách dọn dẹp tài nguyên đáng tin hơn __del__
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.__dict__) # {'x': 1, 'y': 2}
p.z = 3 # thêm thuộc tính tuỳ ý lúc chạy

__dict__ cho phép thêm thuộc tính bất kỳ, nhưng phải trả giá bằng bộ nhớ cho bảng băm (dù CPython đã tối ưu rất nhiều, xem bài Dict và Set).

class SlotPoint:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y

Với __slots__, CPython không tạo __dict__. Thay vào đó, nó dành sẵn đúng 2 ô con trỏ ngay trong object, giống một struct trong C:

Point (có __dict__) SlotPoint (__slots__)
┌──────────────────┐ ┌──────────────────┐
│ refcnt, type │ │ refcnt, type │
│ (inline values / │ │ x ──► 1 │
│ __dict__ ...) │ │ y ──► 2 │
│ __weakref__ │ └──────────────────┘
└──────────────────┘
import tracemalloc
from dataclasses import dataclass
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class SlotPoint:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
@dataclass(slots=True) # Python 3.10+
class DataPoint:
x: int
y: int
def measure(cls):
tracemalloc.start()
objs = [cls(1, 2) for _ in range(1_000_000)]
current, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
return current / 1e6
for cls in (Point, SlotPoint, DataPoint):
print(f"{cls.__name__:10} {measure(cls):.1f} MB")
Point 96.5 MB
SlotPoint 56.4 MB
DataPoint 56.4 MB

(Mỗi con số đã bao gồm ~8 MB của list chứa con trỏ.) Tức là ~88 byte/object so với ~48 byte/object - giảm khoảng 45%. Trên Python 3.10 trở về trước, bản không có slots tốn tới ~152 byte/object, nên chênh lệch còn lớn hơn.

Truy cập thuộc tính qua slots cũng nhanh ngang hoặc hơn một chút so với qua dict.

print(SlotPoint.x) # <member 'x' of 'SlotPoint' objects>

Mỗi tên trong __slots__ trở thành một descriptor (member_descriptor) đặt trên class. Khi bạn viết p.x, Python tìm thấy descriptor này trên class và gọi nó để đọc ô nhớ tương ứng ở offset cố định trong object. Cơ chế descriptor được giải thích kỹ trong bài Descriptor.

p = SlotPoint(1, 2)
try:
p.z = 3
except AttributeError as e:
print(e)
# 'SlotPoint' object has no attribute 'z' and no __dict__ for setting new attributes

Đây có thể là ưu điểm: gõ nhầm p.xx = 5 sẽ báo lỗi ngay thay vì âm thầm tạo thuộc tính mới.

Không dùng được weakref (trừ khi khai báo)

Phần tiêu đề “Không dùng được weakref (trừ khi khai báo)”
import weakref
try:
weakref.ref(p)
except TypeError as e:
print(e) # cannot create weak reference to 'SlotPoint' object
class WeakSlotPoint:
__slots__ = ("x", "y", "__weakref__") # thêm ô cho weakref

Kế thừa: mọi class trong chuỗi phải có slots

Phần tiêu đề “Kế thừa: mọi class trong chuỗi phải có slots”
class Base: # không có __slots__ -> có __dict__
pass
class Child(Base):
__slots__ = ("a",)
c = Child()
c.b = 1 # vẫn được! vì Base đã có __dict__
print(hasattr(c, "__dict__")) # True -> mất hết lợi ích tiết kiệm

Quy tắc:

  • Mọi class cha cũng phải khai báo __slots__ (có thể là __slots__ = ()).
  • Class con chỉ khai báo thuộc tính mới, không lặp lại tên đã có ở cha.
  • Đa kế thừa từ hai class đều có slots không rỗng sẽ báo lỗi “multiple bases have instance lay-out conflict”.

Giá trị mặc định ở mức class không dùng được

Phần tiêu đề “Giá trị mặc định ở mức class không dùng được”
class Bad:
__slots__ = ("x",)
x = 0 # ValueError: 'x' in __slots__ conflicts with class variable

Hãy gán mặc định trong __init__, hoặc dùng @dataclass(slots=True) - nó tự xử lý chuyện này.

  • ✅ Class có rất nhiều instance (≥ hàng trăm nghìn) với tập thuộc tính cố định.
  • ✅ Muốn chặn việc thêm thuộc tính ngoài ý muốn.
  • ❌ Class chỉ có vài instance - lợi ích không đáng kể.
  • ❌ Code dùng thư viện cần __dict__ (một số ORM, pickle cũ, monkey-patching).

5. Weak reference: tham chiếu không giữ object sống

Phần tiêu đề “5. Weak reference: tham chiếu không giữ object sống”

Tham chiếu thông thường tăng refcount → object không bị huỷ chừng nào bạn còn giữ. Weak reference trỏ tới object nhưng không tăng refcount. Khi object bị huỷ, weakref tự động trả về None.

import weakref
class Image:
def __init__(self, name):
self.name = name
img = Image("cat.png")
r = weakref.ref(img)
print(r()) # <__main__.Image object ...> - gọi r() để lấy object
del img
print(r()) # None - object đã bị huỷ

Lưu ý: int, str, tuple, list, dict không hỗ trợ weakref trực tiếp. Instance của class bạn tự viết thì có (trừ khi dùng slots mà không có __weakref__).

import weakref
class Image:
def __init__(self, path):
print("tải", path)
self.path = path
_cache = weakref.WeakValueDictionary()
def load_image(path):
img = _cache.get(path)
if img is None:
img = Image(path)
_cache[path] = img
return img
a = load_image("cat.png") # tải cat.png
b = load_image("cat.png") # lấy từ cache, không tải lại
print(a is b) # True
del a, b
print(len(_cache)) # 0 - không ai dùng nữa thì tự rời cache

Chừng nào còn nơi dùng ảnh, mọi lời gọi dùng chung một object. Khi không ai dùng, ảnh tự biến mất khỏi cache - không bao giờ phình to vô hạn.

Tương tự có WeakKeyDictionary: gắn thêm dữ liệu vào object của người khác mà không kéo dài tuổi thọ của nó, và WeakSet: tập các object “đang sống”.

Ứng dụng 2: con trỏ ngược lên cha trong cây

Phần tiêu đề “Ứng dụng 2: con trỏ ngược lên cha trong cây”
import weakref
class TreeNode:
def __init__(self, value, parent=None):
self.value = value
self.children = []
self._parent = weakref.ref(parent) if parent else None
@property
def parent(self):
return self._parent() if self._parent else None
def add(self, value):
child = TreeNode(value, parent=self)
self.children.append(child)
return child
root = TreeNode("root")
child = root.add("child")
print(child.parent.value) # root

Cha giữ con bằng tham chiếu mạnh, con trỏ lên cha bằng tham chiếu yếu → không tạo vòng, cây được giải phóng ngay bằng reference counting mà không cần chờ GC.

Ứng dụng 3: observer/callback không giữ subscriber

Phần tiêu đề “Ứng dụng 3: observer/callback không giữ subscriber”
import weakref
class EventBus:
def __init__(self):
self._subscribers = weakref.WeakSet()
def subscribe(self, obj):
self._subscribers.add(obj)
def publish(self, msg):
for s in list(self._subscribers):
s.on_event(msg)

Nếu dùng set thường, một màn hình UI đã đóng vẫn bị EventBus giữ lại mãi - một nguồn leak kinh điển. Với phương thức (bound method), dùng weakref.WeakMethod.

6. weakref.finalize - dọn dẹp đáng tin hơn __del__

Phần tiêu đề “6. weakref.finalize - dọn dẹp đáng tin hơn __del__”

__del__ có nhiều vấn đề: không chắc được gọi khi thoát chương trình, có thể “hồi sinh” object, lỗi trong đó bị nuốt. weakref.finalize là lựa chọn an toàn hơn:

import weakref, tempfile, shutil
class TempWorkspace:
def __init__(self):
self.path = tempfile.mkdtemp()
# Đăng ký hàm dọn dẹp: chạy khi object bị huỷ HOẶC khi chương trình thoát
self._finalizer = weakref.finalize(self, shutil.rmtree, self.path)
def cleanup(self):
self._finalizer() # gọi chủ động; finalize chỉ chạy tối đa một lần
@property
def closed(self):
return not self._finalizer.alive
ws = TempWorkspace()
print(ws.path)
del ws # thư mục tạm bị xoá
  1. Viết lại class Point3D(x, y, z) bằng ba cách: class thường, __slots__, @dataclass(slots=True). Đo bộ nhớ của 1 triệu object bằng tracemalloc.
  2. Viết ImageCache dùng WeakValueDictionary và chứng minh ảnh bị xoá khỏi cache khi không còn tham chiếu nào.
  3. Viết class EventEmitter với on(event, callback) dùng WeakMethod cho bound method, để object lắng nghe bị huỷ thì callback tự biến mất.

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
__slots__ / @dataclass(slots=True) Nhiều instance, thuộc tính cố định → giảm ~45% bộ nhớ
weakref.ref Trỏ tới object mà không giữ nó sống
WeakValueDictionary Cache tự dọn khi không còn ai dùng
WeakKeyDictionary Gắn metadata vào object của người khác
WeakSet / WeakMethod Danh sách observer/callback không gây leak
weakref.finalize Dọn dẹp tài nguyên thay cho __del__

Bài tiếp theo: Buffer protocol, memoryview và array.