Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion GLOSSARY.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ A lesson can also end with a boss fight, which is a problem the text does not so
| R06 | [The C API tiers](lessons/r06-the-c-api-tiers/r06.ipynb) | The C API is three directories and two macros. Include is open to any extension, Include/cpython needs Py_LIMITED_API to be undefined, and Include/internal starts nearly every file with three lines that stop the compiler unless you define Py_BUILD_CORE. More than half the header lines are in that third directory. Defining Py_LIMITED_API hides 186 of the 766 functions the public headers declare and all 974 in the other two, and the part that costs is not the functions but the struct layouts, because with no fields to read Py_TYPE becomes a call and Py_DECREF becomes a call to _Py_DecRef. The naming convention nearly matches the directories and the exceptions have a reason: a private name has to be exported when a public macro expands to it, which is what the 17 underscore names in the public tier are. None of it survives the build. Every tier resolves through ctypes.pythonapi, and calling _PyDict_SizeOf by hand gives the same number dict.__sizeof__ does, 16 bytes short of sys.getsizeof because that adds the collector header. Two recordings show the split is deliberate: inside the internal headers 93 percent of the names spelled PyAPI_FUNC resolve against 0.4 percent of the ones spelled plain extern, with 168 comments naming which bundled extension needs each export | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/r06-the-c-api-tiers/r06.ipynb) |
| R07 | [The stable ABI](lessons/r07-the-stable-abi/r07.ipynb) | An extension has to get past two gates before it is a module, and both of them can be driven from Python. The first is the file name, read before anything is opened: the tag has to be one of the handful in a C array compiled into the interpreter, and a name that is wrong is not rejected but simply never looked at. Put empty files in a directory and ask the finder what it sees and the whole rule falls out, including that a free threaded build takes abi3t and refuses abi3, an ordinary 3.15 build takes both, and 3.14 has never heard of abi3t. The 3 in abi3 is not a Python version, it is PYTHON_ABI_VERSION, a counter that stopped moving in 2010, and sys.api_version has been 1013 since 2006. The second gate is PyABIInfo, twelve bytes new in 3.15, and because PyABIInfo_Check is a plain exported function the struct can be built in ctypes and handed to the real check, which refuses four of eight sample extensions and gives a different reason for each. The stable ABI itself is dated by the preprocessor gates around the declarations, 173 functions added since 3.2 with the busiest releases being the recent ones. Two recordings run both gates on a release build and on a build made with --disable-gil, where six tags become four and four refusals become six | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/r07-the-stable-abi/r07.ipynb) |
| R08 | [When the interpreter stops](lessons/r08-when-the-interpreter-stops/r08.ipynb) | Shutdown is one C function read top to bottom, and every cell here watches it happen in a child interpreter because a notebook cannot watch its own ending. The first thing _Py_Finalize does is call into Python: threading._shutdown joins your non daemon threads, then the atexit callbacks run, and only then does the finalizing flag go up, which is why sys.is_finalizing() is False in a callback and True in a late __del__. Callbacks come back newest first because register inserts at the front of a list, and because that list is copied and then emptied, a callback registered from inside another one is a silent no op. Once teardown starts sys.meta_path is cleared, so a finalizer can still read its own module globals but any import raises ImportError and says why, and both a failing callback and a failing finalizer are printed and ignored while the exit status stays zero. The case worth knowing is the daemon thread: pass time.sleep and your finalizers run, pass a function of your own and its stack frame holds your module globals, the module dict is never cleared and nothing in it is freed. Two recordings run nine endings on a release build and on a debug build, where that one thread leaves 12680 references alive against zero for the same objects held any other way | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/r08-when-the-interpreter-stops/r08.ipynb) |
| R09 | [Writing a C extension properly](lessons/r09-writing-a-c-extension-properly/r09.ipynb) | Four things a C extension owes the runtime, each one broken on purpose and then fixed in front of you. The first is the error path: a function that packs a tuple and then returns NULL without dropping it leaks two references on every failed call, so a thousand failures leave two thousand references behind while every test still passes. The second is the collector: the same little container is compiled three ways, one with no GC flag, one with a tp_traverse that reports its type and forgets its field, and one that reports both, and only the third is ever freed out of a cycle, because the collector subtracts a reference for every visit landing inside its candidate set and treats whatever is still above zero as held from outside. The third is teardown, where tp_finalize runs at most once whether the object died on its count or inside a cycle, tp_clear only breaks links and tp_dealloc frees. The fourth is the single 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. Two recordings close it: CPython's own leak hunter run over four tests that all pass an ordinary run and two of which it fails, and one shared object loaded three ways where exactly one of the loads turns the lock back on and says so | M8 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/tamnd/cpython-internals/blob/main/lessons/r09-writing-a-c-extension-properly/r09.ipynb) |

More are landing in order. [lessons/README.md](lessons/README.md) explains how one is put together and how to run them locally.

Expand Down
55 changes: 55 additions & 0 deletions citations.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,11 @@
"first_line": "",
"lines": 6
},
"Include/object.h:520-530@v3.15.0rc1": {
"digest": "bf1a330c8709fac8",
"first_line": "/* Set while the type is being 'readied', to prevent recursive ready calls */",
"lines": 11
},
"Include/object.h:580-583@v3.15.0rc1": {
"digest": "323df69ccd90ebf9",
"first_line": "// Flag values for ob_flags (16 bits available, if SIZEOF_VOID_P > 4).",
Expand Down Expand Up @@ -1155,6 +1160,11 @@
"first_line": "#define _Py_IMMORTAL_INITIAL_REFCNT (3ULL << 30)",
"lines": 4
},
"Include/refcount.h:483-500@v3.15.0rc1": {
"digest": "54d3f1d1d6989131",
"first_line": "#define Py_CLEAR(op) \\",
"lines": 18
},
"Include/refcount.h:527-538@v3.15.0rc1": {
"digest": "4b12e8e078a88f82",
"first_line": "// Create a new strong reference to an object:",
Expand Down Expand Up @@ -1515,6 +1525,11 @@
"first_line": "def check_rc_deltas(deltas):",
"lines": 14
},
"Lib/test/libregrtest/refleak.py:214-236@v3.15.0rc1": {
"digest": "227d5cb7c44aee81",
"first_line": "failed = False",
"lines": 23
},
"Lib/test/libregrtest/results.py:18-22@v3.15.0rc1": {
"digest": "2e112c114c5a2a06",
"first_line": "EXITCODE_BAD_TEST = 2",
Expand Down Expand Up @@ -2190,6 +2205,11 @@
"first_line": "/* For int multiplication, use the O(N**2) school algorithm unless",
"lines": 6
},
"Objects/moduleobject.c:1027-1035@v3.15.0rc1": {
"digest": "f6011b09ee3ae4fa",
"first_line": "void*",
"lines": 9
},
"Objects/moduleobject.c:1330-1360@v3.15.0rc1": {
"digest": "b890e50fe279a649",
"first_line": "PyObject*",
Expand Down Expand Up @@ -2275,6 +2295,11 @@
"first_line": "int",
"lines": 37
},
"Objects/object.c:595-630@v3.15.0rc1": {
"digest": "d74c897f83ac2a9b",
"first_line": "PyObject_CallFinalizerFromDealloc(PyObject *self)",
"lines": 36
},
"Objects/obmalloc.c:1396-1427@v3.15.0rc1": {
"digest": "8bbbd57375b14355",
"first_line": "",
Expand Down Expand Up @@ -2485,6 +2510,11 @@
"first_line": "_PyType_AllocNoTrack(PyTypeObject *type, Py_ssize_t nitems)",
"lines": 20
},
"Objects/typeobject.c:2607-2645@v3.15.0rc1": {
"digest": "b894ed89d3d54d12",
"first_line": "subtype_traverse(PyObject *self, visitproc visit, void *arg)",
"lines": 39
},
"Objects/typeobject.c:2794-2815@v3.15.0rc1": {
"digest": "66d90d9277fe53a4",
"first_line": "has_finalizer = type->tp_finalize || type->tp_del;",
Expand Down Expand Up @@ -2540,6 +2570,11 @@
"first_line": "if (ctx->add_weak) {",
"lines": 5
},
"Objects/typeobject.c:5973-6005@v3.15.0rc1": {
"digest": "e5c739ab6a2a7086",
"first_line": "PyType_GetModuleByDef(PyTypeObject *type, PyModuleDef *def)",
"lines": 33
},
"Objects/typeobject.c:6144-6180@v3.15.0rc1": {
"digest": "a16a8c9e914d75f2",
"first_line": "static int",
Expand Down Expand Up @@ -4380,6 +4415,11 @@
"first_line": "static void",
"lines": 29
},
"Python/gc.c:1083-1120@v3.15.0rc1": {
"digest": "7663d57dedc7bbab",
"first_line": "delete_garbage(PyThreadState *tstate, GCState *gcstate,",
"lines": 38
},
"Python/gc.c:1221-1236@v3.15.0rc1": {
"digest": "dc27d9361032699d",
"first_line": "/* Handle objects that may have resurrected after a call to 'finalize_garbage', moving",
Expand Down Expand Up @@ -4450,6 +4490,11 @@
"first_line": "void",
"lines": 21
},
"Python/gc.c:2026-2044@v3.15.0rc1": {
"digest": "a6ecf3b2252cc226",
"first_line": "_PyObject_GC_New(PyTypeObject *tp)",
"lines": 19
},
"Python/gc.c:2118-2123@v3.15.0rc1": {
"digest": "35669478a7a4e47e",
"first_line": "GCState *gcstate = get_gc_state();",
Expand All @@ -4460,11 +4505,21 @@
"first_line": "/* Set all gc_refs = ob_refcnt. After this, gc_refs is > 0 and",
"lines": 20
},
"Python/gc.c:438-465@v3.15.0rc1": {
"digest": "5a6bfa74ee2f5f63",
"first_line": "/* A traversal callback for subtract_refs. */",
"lines": 28
},
"Python/gc.c:485-501@v3.15.0rc1": {
"digest": "2e571c593e6b01da",
"first_line": "/* Subtract internal references from gc_refs. After this, gc_refs is >= 0",
"lines": 17
},
"Python/gc.c:490-515@v3.15.0rc1": {
"digest": "fd15d95adf539c91",
"first_line": "subtract_refs(PyGC_Head *containers)",
"lines": 26
},
"Python/gc.c:566-583@v3.15.0rc1": {
"digest": "ec0b2854fab4f304",
"first_line": "/* Move the unreachable objects from young to unreachable. After this,",
Expand Down
2 changes: 2 additions & 0 deletions experiments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ So those programs run somewhere else. They run in the images this project publis
| [r07-what-a-build-will-load-without-the-lock](tier1/r07-what-a-build-will-load-without-the-lock.md) | R07 | freethreaded | How much of the stable ABI does dropping the global interpreter lock rule out? |
| [r08-what-the-end-still-runs](tier1/r08-what-the-end-still-runs.md) | R08 | release | Which of the things you registered actually run when the interpreter stops? |
| [r08-what-the-end-leaves-behind](tier1/r08-what-the-end-leaves-behind.md) | R08 | debug | How much does one daemon thread leave stranded when the interpreter stops? |
| [r09-what-the-leak-hunter-catches](tier1/r09-what-the-leak-hunter-catches.md) | R09 | debug | What does CPython's own leak hunter see that an ordinary test run walks straight past? |
| [r09-what-a-module-must-declare](tier1/r09-what-a-module-must-declare.md) | R09 | freethreaded | What is the line at the bottom of an extension module that declares itself safe worth? |

## The commands

Expand Down
113 changes: 113 additions & 0 deletions experiments/tier1/r09-what-a-module-must-declare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# One shared object, three ways to load it, and what each one does to the lock

Generated by `just build-tier1`. Do not edit by hand, the change will be overwritten.

What is the line at the bottom of an extension module that declares itself safe worth?

- Lesson: R09
- Build: freethreaded
- Image: ghcr.io/tamnd/cpython-internals/cpython:freethreaded@sha256:db72284e3a49f43c38b96bec2baed1380b8348e27ea6f54f6e8d0810b59c3144
- Interpreter: 3.15.0rc1 free-threading build (37e98da:37e98da, Aug 29 2026, 09:25:00) [GCC 14.2.0]
- Recorded: 2026-09-06

Why this needs the freethreaded build: it needs a free threaded build, because a build that always has the lock has nothing to turn back on.

## The program

```python
"""What a module has to declare before a free threaded build will trust it.

CPython's own test suite ships a shared object that exports several init functions, two
of which differ only in one line: whether they say the module can run without the lock.
Loading each one in a child of its own shows what that line is worth.
"""

import os
import subprocess
import sys
import textwrap

CHILD = """
import importlib.machinery, importlib.util, sys, _testmultiphase

name = sys.argv[1]
print(f" the lock before the import: {sys._is_gil_enabled()}")
loader = importlib.machinery.ExtensionFileLoader(name, _testmultiphase.__file__)
spec = importlib.util.spec_from_loader(name, loader)
loader.exec_module(importlib.util.module_from_spec(spec))
print(f" the lock after the import: {sys._is_gil_enabled()}")
"""

CASES = (
("_test_from_modexport", "which declares Py_MOD_GIL_NOT_USED", {}),
("_test_from_modexport_gil_used", "which declares Py_MOD_GIL_USED", {}),
("_test_from_modexport_gil_used", "the same one, in a child started with PYTHON_GIL=0", {}),
)

OVERRIDE = {"PYTHON_GIL": "0"}


def load(name, extra):
"""Import one init function out of that shared object, in a child of its own."""
return subprocess.run(
[sys.executable, "-c", CHILD, name],
capture_output=True,
text=True,
timeout=180,
env=os.environ | {"PYTHON_COLORS": "0"} | extra,
)


print("what a compiled module has to declare before this build will trust it")
print()
print(f" the abi flags on this build: {sys.abiflags!r}")
print()

turned_on = 0
for position, (name, what, _) in enumerate(CASES):
done = load(name, OVERRIDE if position == 2 else {})
print(f" {name}, {what}")
print(done.stdout, end="")
turned_on += "the lock after the import: True" in done.stdout
said = [one for one in done.stderr.splitlines() if "RuntimeWarning" in one]
if not said:
print(" it went through without a word")
for one in said:
print(" on the way it warned, at some length:")
text = one.split("RuntimeWarning: ")[1]
print(textwrap.indent(textwrap.fill(text, 74), " "))
print()

print(f"~ ways of loading the same shared object: {len(CASES)}")
print(f"~ of those that turned the lock back on: {turned_on}")
```

## What it printed

```text
what a compiled module has to declare before this build will trust it

the abi flags on this build: 't'

_test_from_modexport, which declares Py_MOD_GIL_NOT_USED
the lock before the import: False
the lock after the import: False
it went through without a word

_test_from_modexport_gil_used, which declares Py_MOD_GIL_USED
the lock before the import: False
the lock after the import: True
on the way it warned, at some length:
The global interpreter lock (GIL) has been enabled to load module
'_test_from_modexport_gil_used', which has not declared that it can run
safely without the GIL. To override this behavior and keep the GIL
disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.

_test_from_modexport_gil_used, the same one, in a child started with PYTHON_GIL=0
the lock before the import: False
the lock after the import: False
it went through without a word

~ ways of loading the same shared object: 3
~ of those that turned the lock back on: 1
```
Loading
Loading