R09: writing a C extension properly - #191
Merged
Merged
Conversation
The last lesson in M8, and the first one that writes C rather than reading it. Four rules an extension owes the runtime, each broken on purpose and then fixed. The error path comes first, because the other three are versions of it. A function packs a tuple, hashes it, and returns NULL without dropping the tuple. A thousand failed calls leave two thousand references behind and nothing in the process complains. The fix is a single cleanup block reached by goto, which is the shape CPython's own source uses everywhere. Then the collector. One box type is compiled three ways: no GC flag, the flag with a tp_traverse that reports its type and forgets its field, and the flag with a complete tp_traverse. Only the third is ever freed out of a cycle, because subtract_refs takes one reference away per visit landing inside the candidate set and treats whatever is left as held from outside. The table puts gc.get_referents next to whether the box was freed, which is the closest thing to a test you can write for a traverse handler. Then teardown: tp_clear breaks links for the collector, tp_finalize is where your cleanup goes, and it runs exactly once whether the object died on its count or inside a cycle. Then the one line at the bottom of the file, Py_MOD_GIL_USED or Py_MOD_GIL_NOT_USED, which a free threaded build reads and acts on. Everything that needs a compiler is guarded, so the notebook still reads in a browser and the two recordings carry the parts a reader cannot run. Two Tier 1 experiments. On the debug build, CPython's own leak hunter over four small tests that all pass an ordinary run and two of which it fails, including the cache filling case it deliberately excuses. On the free threaded build, one shared object from CPython's test suite loaded three ways, where the middle load turns the lock back on and says so, and PYTHON_GIL=0 turns the warning off with it. Also three glossary terms: traverse function, clear function and module state.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
R09 is the last lesson in M8, and the first one in the whole set that writes C rather than reading it. Everything up to here has watched CPython from the Python side. This one crosses over, because the rules you have been reading about stop being descriptions the moment you are the one writing the extension.
There are four rules and none of them is hard. Hand back what you own and let go of what you borrowed. If your object can hold another object, tell the collector. Do your cleanup in the slot meant for it. Say honestly whether your code is safe without the lock. What makes them worth a lesson is that breaking any of the four produces no error, no warning and no crash. It produces a program that works and slowly stops working.
What the notebook runs
The error path first, because the other three are versions of it. Two nearly identical C functions are compiled side by side. Both pack their argument into a tuple twice over and then hash it, which works for a number and fails for a list. One of them drops the tuple on the way out through the failure and the other does not. A thousand failing calls later, one has left two thousand references behind and the other none, and nothing in the process has said a word. The fix is one line, and the habit behind it is a single cleanup block at the bottom reached by
goto, which is the shape CPython's own source uses for exactly this reason.Then the collector, which needs a container to show. A box holding one object is compiled three ways.
Loosehas no GC flag at all.Halfhas the flag and atp_traversethat reports its type and forgets its field.Trackedreports both. Each one goes into a cycle, the collector runs, and onlyTrackedis ever freed. That is worth sitting with:Halfis registered with the collector, gets asked what it is holding, gives an answer that is merely incomplete, and leaks exactly as thoroughly as the type that was never registered at all. The reason issubtract_refs, which takes one reference off per visit landing inside the candidate set and treats whatever is still above zero as held from outside. An incomplete answer is indistinguishable from a live reference.The table in that section puts
gc.get_referentsnext to whether the box was freed, which is the closest thing to a unit test you can write for a traverse handler: if the referents do not match the fields your object owns, you have theHalfbug.Then teardown.
tp_clearbelongs to the collector and only breaks links.tp_finalizeis where your cleanup goes, and the cell shows it running exactly once on both routes to death, once for an object whose count hit zero and once for an object the collector found in a cycle.Then the line at the bottom of the file. The
boxesmodule in this lesson declaresPy_MOD_GIL_USED, and that is honest rather than modest:state->freed++really would lose counts under two threads. The lesson says what keeping the other promise would have cost.The last section is a leak test, because four rules that fail silently need one. It is CPython's
-Rflag with the interesting parts removed, about a dozen lines, and it catches both broken boxes and clears the correct one.Recordings
Two Tier 1 experiments, both on builds a reader does not have.
r09-what-the-leak-hunter-catchesruns on the debug build. Four small tests, run once the way anybody runs a test suite and once under-R 3:3. All four pass the ordinary run. Two fail the hunter, and a third gets a note without failing, because it left something behind on the first measured run and then stopped, which is a cache filling rather than a leak. That last case is why the hunter only fails something when every measured run leaked.r09-what-a-module-must-declareruns on the free threaded build. CPython's own test suite ships a shared object exporting two init functions that differ only in whether they declare the module safe without the lock, so the recording loads each one in a child of its own and printssys._is_gil_enabled()before and after. One of the three loads turns the lock back on for the whole process and prints the warning naming the module. The same load underPYTHON_GIL=0leaves it off and says nothing.Running it in a browser
Every cell that needs a compiler is guarded the way C07 does it, so a reader on Pyodide gets a line saying there is nothing to build rather than a traceback, and the prose and the two recordings still carry the lesson. The probe confirms all eight cells run end to end.
The rest
Six diagrams, three new glossary terms (traverse function, clear function, module state), rows in all three READMEs, and sixteen citations into the pinned tree covering
Py_VISIT,Py_CLEAR,Py_TPFLAGS_HAVE_GC,visit_decref,subtract_refs,delete_garbage,PyObject_GC_Track,_PyObject_GC_New,PyObject_CallFinalizerFromDealloc,subtype_traverse,PyType_GetModuleByDef,PyModule_GetState, the two halves of the refleak runner, and the alternate init function in_testmultiphase.The
_DuringGCfamily is new in 3.15, and the list of what atp_traversehandler may call now includes it, so that got a diagram of its own.