Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
The Object Model  ›  Lesson

What Keeps an Object Alive

Garbage Collection 24 minObjects live while they're reachable; reference counting frees most garbage at once, and the cycle collector frees what counting can't
You're building a piece ofQuarry — the inspector
This piece — find_cycles(): Builds a web of objects, lets go of it, and reports what died at once, what only the collector could free, and what is still reachable.
Scenario Quarry was killed overnight for using too much memory, although no line of its code ever stores a batch anywhere.
Your task
Write find_cycles(links, roots). links maps each name to the names it refers to. Build a real object per name, wire the references, keep strong references only to the roots, and let go of the rest with the collector disabled. Report sorted names in "freed_at_once" (dead before any collection), "cycle_garbage" (alive until gc.collect()), and "alive" (still alive after). Example: find_cycles({"a": ["b"], "b": ["a"], "c": []}, []) → {"freed_at_once": ["c"], "cycle_garbage": ["a", "b"], "alive": []}.

What Keeps an Object Alive

In short: there's no free in Python — an object lives for exactly as long as something can still reach it, from a local variable, a global, or a chain of references leading back to either. del name only removes a name; the object survives if anything else still points at it. CPython frees most garbage the instant its reference count hits zero, which is why cleanup usually happens right where you'd expect — but two objects that point at each other keep each other's count above zero forever, so a separate cycle collector (gc) has to notice and sweep them. A Python "leak" is never the runtime's fault: it's always something still reachable that you forgot about — an unbounded cache, a registry, a closure.

Objects x and y pointing at each other, each showing refs: 1 even after the outside root is deleted — neither count reaches 0 on its own, so gc.collect() has to sweep the pair, checking that nothing outside it refers in

An object is alive while some chain of references leads to it from a root — a running function's local variables, or a loaded module's globals. Here's that idea for a batch of rows and a cycle of nodes at once. Step through it:

watch it move · graph

An object lives while a chain of references leads to it from a root. Reference counting frees most garbage the moment it's cut loose, but not a cycle.

The roots: the running function's local variables, and the module's globals. 8 nodes, 8 edges, directed. Settled: frame locals, module globals.

1 / 5

The last two steps are the whole story of this lesson. Most garbage disappears the instant it's cut loose. A cycle waits for a second mechanism.

del removes a name, not an object

python

To watch an object actually die, you need a way to point at it that doesn't count as a reference — otherwise watching it would keep it alive. That's a weak reference:

python

A weak reference only reports the death. It says nothing about why an object stayed alive — that depends entirely on what else still points at it.

A registry that quietly leaks

A module-level dict that remembers "batches currently in use" sounds harmless. It's Quarry's actual overnight leak:

broken — fix it

The registry should let you look batches up without keeping any of them alive forever. Run it, notice the batch survives del, then change one line.

A WeakValueDictionary drops an entry automatically the moment its value dies everywhere else — exactly the shape a "look up what's still in use" registry needs. A plain dict, a module-level list, or a closure that captured an object by accident all leak the same way: not because Python forgot to free something, but because something still, technically, reaches it.

Two nodes that can't free each other

Reference counting frees an object the instant nothing points at it anymore. Two objects that point at each other break that, because neither count ever reaches zero on its own:

What does this print?
import gc
import weakref

gc.disable()                # keep the cycle collector out of it, for now

class Node:
    pass

x = Node()
y = Node()
x.other = y
y.other = x
probe = weakref.ref(x)
del x, y
before = probe() is not None
gc.collect()
after = probe() is not None
print(before, after)
gc.enable()

Step through the same code, and watch probe. Its display changes to dead only after the collector runs:

step through it
1import gc
2import weakref
3gc.disable()
4class Node:
5 pass
6x = Node()
7y = Node()
8x.other = y
9y.other = x
10probe = weakref.ref(x)
11del x, y
12alive = probe() is not None
13freed_some = gc.collect() > 0
14alive = probe() is not None
15gc.enable()

gc only tracks container objects — gc.is_tracked([]) is True, gc.is_tracked(1) is False — since only containers can form a cycle in the first place. It runs on its own periodically, and gc.collect() forces it.

One-liner: weak back-references break the cycle on purpose

A tree where every child points back at its parent is a cycle at every level. The standard fix: decide which side owns, and make the other direction weak.

python

Quick practice

What does this print?
import weakref

class Batch:
    pass

a = Batch()
b = a
probe = weakref.ref(a)
del a
print(probe() is None)
python

gc.get_referents(obj) is the collector's own view of what obj points to — exactly the walk it does when it checks whether a group is reachable from outside itself.


🎯 Your turn

Write find_cycles(links, roots). links maps each object's name to the names it refers to, e.g. {"a": ["b"], "b": ["a"], "c": []}. Build a real object for each name, wire up the references, keep strong references only to the names in roots, then let go of everything else and report:

  • "freed_at_once" — names already dead before any collection (reference counting alone got them)
  • "cycle_garbage" — names still alive until gc.collect() runs
  • "alive" — names still alive afterwards (reachable from a root)

Each list sorted. find_cycles({"a": ["b"], "b": ["a"], "c": []}, []) → {"freed_at_once": ["c"], "cycle_garbage": ["a", "b"], "alive": []}.

Hint: keep the collector disabled while you build and check, so it can't run early and blur the first two lists — re-enable it before you return. Build inside a helper function so its locals (including any leftover loop variable) go out of scope when it returns.

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upWhich holders keep it alive?

Write survives(holders). Make one Batch, then for each entry in holders store it one more way, keeping every holder in a local list: "list" means [batch], "dict" means {"k": batch}, and "weak" means weakref.ref(batch). Delete the original name and return whether the batch is still alive, checking with a weak probe.

memory-managementweakref
DrillA registry that doesn't hold on

Write live_after(names, keep). Load a Batch for each name into a registry, holding a strong reference only to those whose name is in keep. Return the sorted names still in the registry. The starter's registry is a plain dict, so nothing is ever freed, and even a weak registry has one more trap: the loop variable.

memory-managementweakref
BuildFolders that point up without holding on

Quarry's yard is a tree of Folders. Each folder keeps its children and can report its parent, but with a strong parent attribute every parent-child pair is a cycle, and dropping the root frees nothing until the collector runs. Change Folder so the parent is held weakly, while folder.parent still returns the parent folder (or None). drop_chain(depth) (given) builds a chain, drops it with the collector switched off, and reports what's left.

memory-managementweakrefreference-cycle
BossThe collector's walk, over real objects

Write reachable(links, roots): build an Obj for every name and wire up its refs, then find every Obj reachable from the root objects by following gc.get_referents itself, the same view the collector has. Don't read obj.refs directly. Follow only referents that are Obj, list or dict instances (an instance's references pass through its attribute storage). Return the sorted names reached.

memory-managementreachabilitygarbage-collection
CapstoneA cache that forgets on time

Write cache_session(ops), a session against Quarry's batch cache. Each op is ["load", name] (make a batch, cache it, and hold it strongly), ["link", a, b] (set a's partner to b), ["drop", name] (stop holding it), ["collect"] (run the collector) or ["get", name] (record "hit" if the cache still has that batch, else "miss"). The cache must never keep a batch alive by itself. Return the list of get results. The collector stays off except during collect.

memory-managementweakrefreference-cyclegarbage-collection
ExplainWhy CPython needs two mechanisms

Explain why CPython uses reference counting AND a cycle collector, rather than just one of them. What does each do well, what can't counting handle, and what does that mean for how you design a class with back-references?

find_cycles.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
find_cycles(links, roots) → dictBuild a web of real objects, let go of it, and report what died at once, what only the collector freed, and what is still reachable.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.