Lesson 04

The Cycle Collector

Reference counting cannot free a cycle. CPython bolts on a tracing collector to handle that case, and it only works if your type tells it the truth about what it points to.

Learning objective

You can decide whether a type you are writing needs cycle-collection support, implement tp_traverse and tp_clear correctly, get the deallocation order right, and explain why tp_traverse is called at moments you did not expect.

The problem

Consider two objects that reference each other:

a = Node(); b = Node()
a.peer = b
b.peer = a
del a, b        # both refcounts are still 1, from each other

Nothing outside can reach them, but neither count reaches zero, so neither deallocator ever runs. Pure reference counting leaks every cycle. Since cycles arise constantly in real programs — parent/child links, caches, closures capturing themselves, an exception traceback referring to the frame that holds the exception — CPython adds a second mechanism.

How CPython finds unreachable cycles

The collector maintains lists of tracked objects — only objects of types that opt in. To find garbage it uses a subtraction trick rather than a mark-from-roots walk:

  1. Copy each tracked object's refcount into a scratch field (gc_refs).
  2. Ask every tracked object to enumerate the objects it references, and for each reference that points at another tracked object, decrement that object's gc_refs.
  3. After this pass, gc_refs counts only references from outside the candidate set. Any object left with gc_refs > 0 is reachable from outside.
  4. Mark those reachable, propagate reachability to everything they point at, and whatever remains at zero is unreachable garbage — a cycle with no external owner.
  5. Break the cycles by asking each garbage object to drop its references, at which point ordinary reference counting finishes the job.

Step 2 is tp_traverse. Step 5 is tp_clear. Those two functions are the entire contract, and if your type gets them wrong the collector either leaks or frees live memory.

The collector is generational: newly created tracked objects go in generation 0, survivors are promoted to 1 and then 2, and younger generations are scanned far more often. That is why a collection can fire at an arbitrary allocation, which matters below.

Does my type need this?

Ask one question: can an instance of my type hold a reference to an object that could, through any chain, refer back to my instance?

If in doubt, opt in. The cost is a small header on each instance and a traversal function; the cost of not opting in is a silent, unfixable leak that only shows up in long-running processes.

Implementing the contract

Three things go together and none may be omitted: the flag, tp_traverse, and (in practice) tp_clear.

typedef struct {
    PyObject_HEAD
    PyObject *payload;    /* arbitrary object from the caller */
    PyObject *callback;   /* arbitrary callable */
    long      counter;    /* plain C, never traversed */
} NodeObject;

static int
Node_traverse(PyObject *op, visitproc visit, void *arg)
{
    NodeObject *self = (NodeObject *)op;
    Py_VISIT(Py_TYPE(self));     /* required for heap types, 3.9+ */
    Py_VISIT(self->payload);
    Py_VISIT(self->callback);
    return 0;
}

static int
Node_clear(PyObject *op)
{
    NodeObject *self = (NodeObject *)op;
    Py_CLEAR(self->payload);
    Py_CLEAR(self->callback);
    return 0;
}

static void
Node_dealloc(PyObject *op)
{
    PyTypeObject *tp = Py_TYPE(op);
    PyObject_GC_UnTrack(op);     /* 1. stop the collector seeing us */
    (void)Node_clear(op);        /* 2. drop object references       */
    tp->tp_free(op);             /* 3. release the memory           */
    Py_DECREF(tp);               /* 4. heap types only              */
}

The canonical shape. Deviating from this order causes crashes, not leaks.

Py_VISIT(x) expands to roughly: if x is not NULL, call visit(x, arg), and if that returns nonzero, return it immediately. It handles the NULL check and the early return so you cannot forget them. Always use it rather than calling visit by hand.

The rules for tp_traverse

The rules for tp_clear

The three ordering mistakes

Allocation and tracking

If your tp_new allocates the normal way — type->tp_alloc(type, 0), which resolves to PyType_GenericAlloc — then for a HAVE_GC type the object is allocated with a GC header and tracked immediately. You do not call PyObject_GC_Track yourself.

You only need the lower-level API if you are managing memory by hand:

PyObject_GC_New(TYPE, typeobj)Allocate a GC-capable object (untracked).
PyObject_GC_NewVar(TYPE, typeobj, n)Variable-size variant.
PyObject_GC_Track(op)Start tracking. Call only once all object fields are in a traversable state.
PyObject_GC_UnTrack(op)Stop tracking. First statement of tp_dealloc.
PyObject_GC_Del(op)Free memory allocated by PyObject_GC_New.
PyObject_GC_IsTracked(op)Query, useful in assertions.

Finalization: tp_finalize versus tp_dealloc

These are different events and conflating them causes real bugs.

tp_dealloc
Runs when the refcount reaches zero. Its job is to free memory. By the time it runs the object is already doomed; it must not make the object reachable again.
tp_finalize — the C slot behind __del__
Runs before deallocation, while the object is still fully valid, and is guaranteed to run at most once per object. It is where user-visible cleanup belongs: closing files, cancelling timers, emitting a ResourceWarning.

PEP 442 introduced tp_finalize precisely so that objects in cycles could still be finalized safely. Before it, CPython refused to collect cycles containing objects with __del__, and they piled up in gc.garbage. Today they are collected: the collector calls tp_finalize on every object in the garbage cycle first, then clears them.

If you implement tp_finalize, the standard shape is:

static void
Node_finalize(PyObject *self)
{
    /* Finalizers can run with an exception in flight. Save and restore it,
       or you will clobber the caller's exception state (Lesson 05). */
    PyObject *exc = PyErr_GetRaisedException();

    ... close resources, call back into Python if you must ...

    PyErr_SetRaisedException(exc);
}

And register it with Py_tp_finalize. Because a finalizer runs while the object is alive, it can technically store self somewhere and resurrect it; CPython handles that correctly, but it is a sharp edge to avoid.

Module state has the same obligations

A module's per-module state (Lesson 08) is memory attached to the module object, and if it holds PyObject * fields the module itself needs m_traverse, m_clear and m_free in its PyModuleDef, written exactly like tp_traverse/tp_clear. A module that caches type objects and forgets m_traverse produces an uncollectable module — the classic "my extension leaks on reimport" bug.

Observing the collector

import gc

gc.collect()                      # returns the number of unreachable objects found
gc.get_count()                    # allocations since last collection, per generation
gc.get_threshold()                # (700, 10, 10) by default
gc.freeze()                       # move everything to a permanent generation

gc.set_debug(gc.DEBUG_LEAK)       # verbose: report everything found unreachable
gc.get_referrers(obj)             # who points at obj  -- invaluable for leak hunting
gc.get_referents(obj)             # what obj points at -- this is tp_traverse, exposed
A test for tp_traverse correctness

gc.get_referents(obj) calls your tp_traverse and returns the list of everything it visited. If that list does not exactly match the set of object fields your instance owns, your traverse function is wrong. This is a two-line unit test and it catches the worst bug in this lesson.

Hands-on
import gc

class Node:
    __slots__ = ("peer",)
    def __init__(self): self.peer = None

gc.collect()
a, b = Node(), Node()
a.peer, b.peer = b, a
del a, b
print("cycle objects collected:", gc.collect())   # expect 2

# Now watch the generational behaviour.
gc.set_debug(gc.DEBUG_STATS)
for _ in range(2000):
    x, y = Node(), Node(); x.peer, y.peer = y, x
gc.set_debug(0)

# And inspect a real extension type's traversal:
print(gc.get_referents({"k": [1, 2]}))
print(gc.is_tracked(1), gc.is_tracked([]), gc.is_tracked((1, 2)), gc.is_tracked(("a",)))

The last line is worth staring at: CPython untracks tuples that are found to contain only untrackable objects, because such a tuple can never be part of a cycle. That optimization is exactly the "does it need GC?" question from earlier, answered dynamically.

Further reading