26. Memory: reference counting, the cycle collector, and __slots__
26.1 Every object is a counted heap allocation
Every CPython object starts with a header: a reference count and a pointer to its type. When a name, container slot, or argument refers to the object, the count goes up; when the reference goes away, it goes down; at zero, the object is freed immediately.
import sys
x = []
print(sys.getrefcount(x)) # 2: `x` plus the temporary argument reference
y = x
print(sys.getrefcount(x)) # 3
del y
print(sys.getrefcount(x)) # 2
That immediacy is why this works without with:
def write_report():
f = open("report.txt", "w")
f.write("done")
# f's refcount hits zero here; the file is flushed and closed at once (in CPython)
Do not rely on it. It is a CPython implementation detail (PyPy has a
tracing GC), and it fails as soon as anything else holds a reference, for
example a traceback that keeps the frame alive. Use with.
Across the series: reference counting is Rust's Rc/Arc applied to
every object (Rust §22),
done by the interpreter instead of the programmer. Go uses a tracing GC
(Go §27).
26.2 Cycles and the cyclic garbage collector
Reference counting cannot free a cycle: two objects that refer to each other keep each other's count above zero. CPython adds a cycle collector that periodically scans container objects for groups only reachable from each other:
import gc
class Node:
def __init__(self):
self.other = None
a, b = Node(), Node()
a.other, b.other = b, a # a cycle
del a, b # refcounts are 1, not 0: not freed yet
print(gc.collect()) # >= 2: the collector found and freed the cycle
The collector is generational (new objects are scanned more often) and, in 3.14, incremental, which shortens its pauses. For most programs it is invisible. Two cases where it is not:
- Large heaps of long-lived objects (a web server that loaded a big
model or cache). Each full collection walks all of them. Instagram
famously disabled the GC, then switched to
gc.freeze()after start-up to move the start-up objects out of the collector's sight. - Forking workers. Updating a refcount writes to the object's memory
page, so after
fork()merely reading objects in a child copies the pages (copy-on-write, OS Ch 6). A "shared" 4 GB dataset slowly becomes 4 GB per worker.gc.freeze()before forking reduces this; storing data in NumPy arrays or shared memory (Ch 37) avoids it.
26.3 How big is an object?
import sys
from dataclasses import dataclass
print(sys.getsizeof(1), sys.getsizeof(1.0), sys.getsizeof(""), sys.getsizeof([]))
# 28 24 41 56 (bytes; 64-bit CPython)
class P:
def __init__(self, x, y): self.x, self.y = x, y
@dataclass(slots=True)
class S:
x: int
y: int
import tracemalloc
tracemalloc.start()
ps = [P(i, i) for i in range(100_000)]
p_mem = tracemalloc.get_traced_memory()[0]
del ps
tracemalloc.reset_peak()
tracemalloc.stop(); tracemalloc.start()
ss = [S(i, i) for i in range(100_000)]
s_mem = tracemalloc.get_traced_memory()[0]
print(f"dict-based: {p_mem / 1e6:.1f} MB, slots: {s_mem / 1e6:.1f} MB")
__slots__ (or @dataclass(slots=True)) removes the per-instance
__dict__ and stores attributes in fixed positions. For millions of small
objects it saves 40–60% of memory and speeds up attribute access. Since
3.12–3.13, regular instances are also more compact than they used to be,
so the gap is smaller than old blog posts claim. Measure.
26.4 Finding memory leaks
Python "leaks" are almost always references you forgot about: a growing
module-level list or dict, an unbounded cache (@cache on a method,
Ch 14.3), callbacks registered and never removed, or exceptions stored with
their tracebacks (which keep every frame's locals alive).
import tracemalloc
tracemalloc.start(25)
snap1 = tracemalloc.take_snapshot()
leak = [bytes(1000) for _ in range(10_000)] # pretend this grew over hours
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:3]:
print(stat) # file:line size=+10.x MiB count=+10000
weakref lets you refer to an object without keeping it alive, which is
the right tool for caches keyed by objects and for observer lists:
import weakref
class Model: pass
m = Model()
registry = weakref.WeakValueDictionary()
registry["current"] = m
print("current" in registry) # True
del m
print("current" in registry) # False: the entry disappeared with the object