Under the hood: internals and concurrency · about 45 min

Memory: references, reference counting and the garbage collector

Why this matters

You never call free() in Python, yet memory is reclaimed. Knowing how explains real behavior: why a file closes as soon as its last reference disappears, why a cache can leak memory forever, why del sometimes appears to do nothing, and why a program that builds object graphs (trees with parent pointers, observers, caches) may grow until the cycle collector runs. It also clears up folklore about is, small integers, and sys.getsizeof.

Objects and references, recap

Every value in CPython is an object in heap memory, and every name, list slot, attribute and dict value holds a reference (a pointer) to an object, never the object itself:

names            heap
a  ---------->  +-----------------+
                | list            |
b  ---------->  | refcount: 2     |
                | items: [*, *, *]|---> 1, 2, 3
                +-----------------+

Each object header contains a reference count: how many references point at it right now. CPython adjusts it constantly. Binding a name, putting an object into a list, passing it as an argument: increment. Rebinding the name, removing it from the list, the function returning and its local variables going away: decrement.

Reference counting

When the count drops to zero, nothing can reach the object any more, so CPython frees it immediately: it runs the object's cleanup (for a file, that closes it; for a class with __del__, that method is called), decrements the counts of everything it referred to (which may cascade), and returns the memory to its allocator.

You can peek at the count:

import sys

x = []
print(sys.getrefcount(x))   # 2
y = x
print(sys.getrefcount(x))   # 3
del y
print(sys.getrefcount(x))   # 2

Why 2, not 1? Passing x to getrefcount creates a temporary reference for the duration of the call. Treat the absolute number as a curiosity (it differs between versions and for objects such as small integers, which are "immortal" since 3.12); the changes are what is instructive.

del removes a reference, not an object.

class Resource:
    def __init__(self, name):
        self.name = name
    def __del__(self):
        print("closing", self.name)

r = Resource("a")
keep = [r]
del r
print("after del r")
keep.clear()
print("after clear")
after del r
closing a
after clear

The object survives del r because the list still refers to it, and dies the instant the list drops its reference. This deterministic, immediate cleanup is a CPython implementation detail; other implementations (PyPy) free objects later. Portable code closes files with with, not by hoping the last reference vanishes on time.

Strengths and costs. Reference counting frees memory promptly and predictably, with no long pauses. The price is that every reference operation touches a counter (one reason the GIL exists, as the next lesson shows) and that it cannot handle cycles.

Cycles

class Node:
    pass

a = Node()
b = Node()
a.other = b
b.other = a
del a, b
   +--------+  other  +--------+
   | Node 1 | ------> | Node 2 |
   | rc: 1  | <------ | rc: 1  |
   +--------+  other  +--------+
       (no names point here any more)

Each node's count is 1, held by the other, so neither ever reaches zero. They are garbage (unreachable from the program) but reference counting will never free them. Cycles are common: a tree whose children point to their parent, a doubly linked list, an object that stores a bound method of itself, an exception holding a traceback that references the frame that holds the exception.

The cycle collector: gc

CPython adds a cyclic garbage collector, the gc module. It tracks only container objects (those that can refer to others: lists, dicts, instances, and so on) and periodically does the following for a group of them:

  1. Copy each object's refcount into a scratch counter.
  2. For every reference between objects in the group, subtract one from the target's scratch count.
  3. Objects whose scratch count is still above zero are referenced from outside the group, so they are reachable; so is everything they reach.
  4. Everything else is unreachable cycles: free it.

Collection is triggered by allocation counts. Objects are sorted into generations: new objects are checked often; objects that survive a collection are promoted and checked less often, based on the observation that most objects die young. You can control it:

import gc
gc.collect()        # run a full collection now, returns number of unreachable objects found
gc.disable()        # stop automatic collection (reference counting still works)
gc.enable()
gc.get_count()      # allocation counters per generation

Some programs disable the collector or raise thresholds for speed, which is safe only if they create no cycles worth reclaiming.

Weak references

Sometimes you want to refer to an object without keeping it alive, for example a cache of objects that should disappear when nobody else uses them, or a child that points back to its parent. That is a weak reference:

import weakref

class Big:
    pass

obj = Big()
ref = weakref.ref(obj)
print(ref() is obj)   # True: calling the ref returns the object
del obj
print(ref())          # None: the object was freed

A weakref does not count toward the reference count. weakref.WeakValueDictionary and weakref.WeakSet build caches and registries on this idea, and weakref.finalize(obj, func) registers a cleanup callback more reliably than __del__.

Sizes and folklore

sys.getsizeof returns the size in bytes of one object, shallowly:

import sys
print(sys.getsizeof([]) < sys.getsizeof([1, 2, 3]))  # True

A list's size covers its header and pointer array, not the objects in it; a dict's covers its tables, not the keys and values. Exact numbers vary by version and platform, so never hard-code them. To find where memory goes in a real program, use tracemalloc, which records allocations by source line.

Small integers and interning. CPython pre-creates the integers -5 to 256 and reuses them, and it interns many strings (identifiers, short literal strings), keeping one shared copy. So:

a = 256
b = 256
print(a is b)        # True: same cached object

but the same test with larger numbers or computed strings may print False, and may differ between the REPL and a script, because constants in one compiled unit can be shared. This is an optimization you must never rely on: compare values with ==, and use is only for identity checks like x is None.

Memory goes back slowly. CPython's small-object allocator (pymalloc) grabs memory from the operating system in large arenas and reuses freed blocks internally. After you delete a huge list of small objects, the process size seen by top may not shrink much; the memory is reused by Python, not necessarily returned to the OS.

Common mistakes

Recap

Going further: read the "Design of CPython's Garbage Collector" document in the CPython developer guide, and try tracemalloc.start() followed by tracemalloc.take_snapshot().statistics("lineno")[:5] on a program of your own.

Exercises

Exercise 1 · quiz

What does del delete?

a = [1, 2, 3]
b = a
del a

What is true after these lines run?

Answer
  • Only the name a is removed; the list lives on because b still refers to it

del a unbinds a name. It decrements the list's reference count from 2 to 1. Objects are freed only when their count reaches zero (or the cycle collector finds them unreachable), so the list is alive as long as b refers to it. Python never leaves you with a dangling reference.

Exercise 2 · write code

Watch an object die

The starter defines a Resource class whose __del__ method prints closing <name> when CPython frees the object. Write the rest of the program (after the class) so the output is exactly:

made a
deleted name r
closing a
end

Steps: create r = Resource("a"), print made a, make a second name b = r, del r, print deleted name r, then del b (this frees the object, so __del__ prints closing a), and finally print end.

File: main.py

Starting point

class Resource:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print("closing", self.name)


r = Resource("a")
print("made a")
del r
print("deleted name r")
print("end")

Example

expected output

made a
deleted name r
closing a
end
Hint 1

In the starter the object dies at del r, too early, because nothing else refers to it.

Hint 2

Add b = r before del r, and del b before print("end").

Reference solution
class Resource:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print("closing", self.name)


r = Resource("a")
print("made a")
b = r
del r
print("deleted name r")
del b
print("end")

Exercise 3 · write code

A cycle only the collector can free

In solution.py, write a function cycle_demo() that returns a tuple of two booleans (alive_after_del, alive_after_collect):

  1. Call gc.disable() so the automatic collector cannot run in the middle of your demo.
  2. Create two Node() objects a and b and make them refer to each other (a.other = b, b.other = a).
  3. Make a weak reference to a with weakref.ref(a).
  4. del a, b. Record whether the weak reference still returns an object (ref() is not None).
  5. Call gc.collect(). Record again.
  6. Call gc.enable() and return the two booleans.

The expected result is (True, False): reference counting alone cannot free the cycle, the cycle collector can.

File: solution.py

Starting point

import gc
import weakref


class Node:
    pass


def cycle_demo():
    return (False, False)
Hint 1

A weak reference does not keep its target alive; calling it returns the object, or None once the object is gone.

Hint 2

After del a, b each Node still has a reference count of 1, held by the other Node.

Hint 3

Do not keep the result of ref() in a variable: that would be a strong reference keeping a alive.

Reference solution
import gc
import weakref


class Node:
    pass


def cycle_demo():
    gc.disable()
    try:
        a = Node()
        b = Node()
        a.other = b
        b.other = a
        ref = weakref.ref(a)
        del a, b
        alive_after_del = ref() is not None
        gc.collect()
        alive_after_collect = ref() is not None
    finally:
        gc.enable()
    return (alive_after_del, alive_after_collect)

Exercise 4 · quiz

Shallow sizes

big = "x" * 1_000_000 makes a string of about 1 MB. Roughly how does sys.getsizeof([big]) compare with sys.getsizeof([1])?

Answer
  • The same (both about 64 bytes): getsizeof counts the list object and its array of pointers, not the objects pointed to

A list stores references (8-byte pointers on a 64-bit build), not the objects themselves. sys.getsizeof reports the shallow size of one object: the list header plus its pointer array. To measure everything reachable you have to walk the references yourself, or use tracemalloc to measure allocations.