arthrop0d

Brief articles on varied topics.

CPython’s Cyclic Garbage Collector

pythoncomputer science

CPython’s cyclic garbage collector finds groups of objects that still reference one another but can no longer be reached by the running program, then reclaims them. It supplements CPython’s primary memory-management mechanism: reference counting.

Why reference counting is not enough

Every CPython object has a reference count: the number of references currently pointing to it. When that count reaches zero, CPython can usually destroy the object immediately and release its memory.

That handles ordinary code well:

value = make_value()
use(value)
del value  # the object may be destroyed immediately

But objects can keep each other alive forever. A reference cycle is a loop of references in which each object is reachable from another object in the same loop:

first = []
second = [first]
first.append(second)

del first
del second

After the del statements, no program variable points into the two lists. However, the lists still point at each other. Each therefore has a nonzero reference count, so reference counting alone cannot destroy either one.

Without another collector, such cycles would remain allocated until the process exited. Long-running programs that repeatedly created cycles could gradually consume memory even though they had dropped all their apparent references.

Reference counting answers “does anything point directly to this object?” The cyclic collector answers “can the program still reach this group of objects at all?”

Which objects the collector examines

The cyclic collector does not inspect every allocation. It focuses on container objects, meaning objects that can hold references to other objects, such as lists, dictionaries, sets, tuples containing references, and most user-defined instances. Simple values such as many integers and strings cannot form a cycle by themselves.

CPython keeps track of containers that might participate in cycles. This tracking has a cost, so some containers can be omitted when CPython knows their contents cannot create a cycle. The exact list of tracked types and optimizations is an implementation detail.

How an unreachable cycle is identified

The key question is whether references into a group come from outside the group.

Imagine two objects, A and B, that point at each other. During a collection, CPython conceptually starts with their reference counts and subtracts references that come from other objects in the candidate group:

  1. A points to B, so that internal reference does not prove that A is alive.
  2. B points to A, so that internal reference does not prove that B is alive.
  3. If no reference remains from outside the group, the group is unreachable.

The actual implementation uses object traversal functions and temporary bookkeeping rather than literally changing the objects’ visible reference counts, but this subtraction model is a useful way to understand it.

If some live object outside the group points to A, that outside reference remains. The collector can follow references from A and discover B, so both objects are considered reachable and are preserved. This prevents the collector from deleting a cycle that is still connected to live application state.

After identifying unreachable objects, CPython handles weak references and finalization as needed, clears references inside the unreachable objects, and lets normal deallocation release them. A weak reference is a non-owning reference that does not keep its target alive. Objects with finalizers, such as a user-defined __del__ method, require additional care because finalization can run code and can even resurrect an object. Modern CPython can generally collect cycles containing finalizers, but resurrection and other finalization behavior can still make cleanup surprising.

When collection happens

Cycle detection is more expensive than decrementing a reference count, so CPython does not run it after every reference change. It runs periodically according to a generational policy. A generation is an age-based group: objects that survive collections are treated as more likely to survive future collections too.

In the traditional three-generation model, new tracked objects start in generation 0. A collection checks young objects; survivors are promoted to older generations. Generation 1 is checked less often, and generation 2—the oldest generation—is checked least often. This makes most collections relatively cheap while still eventually examining long-lived cycles.

CPython uses thresholds to decide when automatic collection should start. The important signal is roughly the number of tracked allocations minus deallocations since the last collection. When that activity passes the young-generation threshold, CPython performs a collection. Other thresholds control how much of the older generations is examined and how often a full collection occurs. The values can be inspected or changed with the gc module:

import gc

print(gc.get_threshold())
gc.collect()       # request a collection now

gc.collect() is an explicit request, not a guarantee that every byte of process memory immediately returns to the operating system. CPython’s allocator may retain memory for reuse, even after the objects occupying it have been freed.

Generation layouts and trigger details have changed across CPython releases, so the exact threshold interpretation is version-dependent. The stable idea is that young objects are checked more frequently, old objects less frequently, and an explicit collection can be requested when an application needs a predictable cleanup point. gc.disable() can turn off the cyclic collector, but it does not turn off reference counting; using it safely requires confidence that the program cannot accumulate cycles.

Where this shows up in practice

The gc module, gc.collect(), gc.get_threshold(), and garbage-collection debug output are the interfaces you may encounter in a memory investigation. A program that grows over time despite dropping its normal references may be retaining a cycle, or it may simply be creating objects faster than the collector runs. Profiling tools and gc inspection functions can help distinguish those cases.

The important division of labor is simple: reference counting reclaims most objects immediately; cyclic garbage collection periodically finds unreachable loops that reference counting cannot see through.

← All articles