Skip to content

M07. The collector nobody calls - #166

Merged
tamnd merged 1 commit into
mainfrom
m07-the-collector-nobody-calls
Sep 2, 2026
Merged

M07. The collector nobody calls#166
tamnd merged 1 commit into
mainfrom
m07-the-collector-nobody-calls

Conversation

@tamnd

@tamnd tamnd commented Sep 2, 2026

Copy link
Copy Markdown
Owner

The seventh lesson of the memory part, and the first one about the cycle collector. Write a loop that makes a few hundred thousand short lived objects and never mention the gc module once, and the collector will still start and finish around two thousand times while that loop runs. This lesson is about what makes that happen, and about why the one pass that walks every object in the process is the pass CPython works hardest to avoid running.

It starts from a counter almost nobody has looked at. gc.get_count() returns three numbers, and the first one is not a count of allocations. It is a count of tracked objects that are alive right now. Make five hundred lists one at a time and drop each one before the next and the number does not move at all. Keep them and it goes up by five hundred. The lesson then watches that number cross two thousand and reset to 1, and follows what actually happens at the crossing: the interpreter does not stop and collect there, it sets a bit on the eval breaker and carries on, and the collector runs when the next bytecode instruction starts.

From there it takes the three generations apart. All three have a threshold, and the natural reading is that all three thresholds mean the same kind of thing. They do not. Only generation 0 counts objects. The other two count collections of the generation below, which is why the middle list runs once per two thousand or so young collections rather than once per two thousand objects, and why the numbers underneath the second and third thresholds move so much more slowly than the first. gc.get_objects(generation=N) makes the rest of it visible: the lesson follows one ordinary dictionary as it goes 0, 1, 1, 2, 2 and then stays in 2 for good, because nothing ever moves an object back down.

That promotion has a consequence worth spelling out, so the lesson spells it out. Two identical cycles, one made a moment ago and one that has already survived three passes, are freed by different collections. gc.collect(0) frees the young one and cannot see the old one at all, because the old one is not in the list it walks. Nothing about the two cycles differs except age.

Then the brake, which is the part I most wanted to get on paper. A threshold of 10 on generation 2 reads like a promise that a full pass happens once per ten middle passes, and the lesson sets up a run where that arithmetic predicts about two hundred of them. Seven happen. The rule at Python/gc.c:1328-1357 skips a full pass unless a quarter of the old objects are new since the last one, and that single test is what turns the most expensive thing the collector does into the rarest thing it does.

The last two sections are about the collector taking work out of its own future. A five deep nest of tuples starts with four tracked layers and loses exactly one per pass, because untracking the innermost one is what lets the layer outside it see that it has nothing trackable left to reach. And gc.freeze() empties generation 2 into a fourth list the collector never walks, which is the thing a server does before it forks its workers, so that the collector writing to an old object's header does not make the operating system copy that page into every child.

Thirty one cells, all of which run in a browser. Fourteen source references against the pin, nine claims backed by a runnable cell and two marked unobservable, six diagrams, and two new glossary terms, permanent generation and collection threshold.

Three cells print noticeably different numbers in Pyodide and each of them says so underneath rather than pretending otherwise. That build's thresholds are (2000, 10, 0), its gc.collect(0) promotes survivors straight to generation 2 with generation 1 staying empty, and it untracks every nested tuple layer in one pass instead of one per pass. Those are real differences in how that build was configured, not something the lesson can measure its way out of, so they are written down as differences.

Part of #26.

The seventh lesson of the memory part, and the first one about the cycle
collector. It runs thousands of times in a program that never mentions it, and
the lesson is about what makes it start and why the most expensive pass is the
one CPython works hardest to skip.

Thirty one cells, all of which run in a browser. Fourteen source references,
nine claims backed by a cell and two marked unobservable, six diagrams and two
new glossary terms.

What it establishes, measured on 3.14 and 3.15 and in Pyodide:

The first number gc.get_count() returns is not allocations, it is tracked
objects alive right now. Five hundred lists made and dropped one at a time
leave it where it started, five hundred kept send it to 505.

Crossing 2000 does not collect. It sets a bit on the eval breaker and the
collector runs when the next bytecode instruction starts.

All three generations have a threshold but only generation 0 counts objects.
The other two count collections of the generation below.

gc.get_objects(generation=N) makes promotion visible. One ordinary dictionary
goes 0, 1, 1, 2, 2 and stays.

Two identical cycles are freed by different collections purely because of age.
gc.collect(0) frees the young one and cannot see the old one.

Four hundred passes over generation 1 with a threshold of 2 predict two
hundred full passes. Seven happen. A full pass is skipped unless a quarter of
the old objects are new since the last one.

A five deep nest of tuples starts with four tracked layers and loses exactly
one per pass, because untracking the inside is what lets the next layer out
see it has nothing trackable to reach.

gc.freeze() empties generation 2 into a fourth list the collector never walks.

Three cells print different numbers in the browser and each says so underneath.
Pyodide's thresholds are (2000, 10, 0), its gc.collect(0) promotes survivors
straight to generation 2 with generation 1 staying empty, and it untracks every
nested tuple layer in one pass.
@tamnd tamnd added this to the M7 Memory milestone Sep 2, 2026
@tamnd tamnd added kind/lesson A chapter: prose, notebook, experiments, boss fight area/memory obmalloc, reference counting and the cycle collector labels Sep 2, 2026
@tamnd
tamnd merged commit e8cd34c into main Sep 2, 2026
16 checks passed
@tamnd
tamnd deleted the m07-the-collector-nobody-calls branch September 2, 2026 07:17
@tamnd tamnd mentioned this pull request Sep 2, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/memory obmalloc, reference counting and the cycle collector kind/lesson A chapter: prose, notebook, experiments, boss fight

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant