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 @@ -166,6 +166,7 @@ A lesson can also end with a boss fight, which is a problem the text does not so
| R05 | [Lazy imports](lessons/r05-lazy-imports/r05.ipynb) | PEP 810 adds one word to the import statement in 3.15, and lazy import json binds the name now and does the finding, reading and running of the module the first time something reads that name back. It compiles to the same IMPORT_NAME opcode as a plain import, with the name index shifted up by two bits and the two bits underneath saying lazy, forced eager or ordinary, so dis prints json, json + lazy or json + eager. What gets bound is not a module but a five field placeholder that is not in sys.modules, that you can look at without setting it off, and whose name sits in sys.lazy_modules until it resolves. Only two opcodes resolve one, reading it as a bare name and reading it as an attribute of a module, so dict lookups, membership tests and reprs all leave it alone, and a placeholder copied into another variable resolves when that variable is read rather than when it was copied. The scope rules come from the symbol table rather than the code generator, which is why a lazy import inside a try block is refused with its own message. A failure inside a deferred module arrives with a second exception attached as its cause, pointing back at the lazy import line from information the placeholder was carrying. Three recordings put the startup saving at about three quarters of a run, and show that resolution takes the interpreter wide import lock rather than a lock per module name, so on a free threaded build four threads waking four different deferred imports keep 0.98 cores busy while four ordinary imports keep 3.60 | 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/r05-lazy-imports/r05.ipynb) |
| 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) |

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 @@ -1335,6 +1335,11 @@
"first_line": "@classmethod",
"lines": 11
},
"Lib/importlib/_bootstrap.py:1196-1210@v3.15.0rc1": {
"digest": "f595e101683b44a2",
"first_line": "",
"lines": 15
},
"Lib/importlib/_bootstrap.py:1198-1223@v3.15.0rc1": {
"digest": "10232b40fdaf68e3",
"first_line": "def _find_spec(name, path, target=None):",
Expand Down Expand Up @@ -1625,6 +1630,16 @@
"first_line": "static PyObject *",
"lines": 11
},
"Modules/atexitmodule.c:102-141@v3.15.0rc1": {
"digest": "2e4925e1f91b08de",
"first_line": "static void",
"lines": 40
},
"Modules/atexitmodule.c:202-213@v3.15.0rc1": {
"digest": "447c2b5107c71614",
"first_line": "}",
"lines": 12
},
"Modules/gcmodule.c:215-240@v3.15.0rc1": {
"digest": "4052c86f7bd5bcf1",
"first_line": "gc_get_count_impl(PyObject *module)",
Expand Down Expand Up @@ -4845,6 +4860,46 @@
"first_line": "PyStatus",
"lines": 31
},
"Python/pylifecycle.c:1683-1702@v3.15.0rc1": {
"digest": "bc251e9f9fb1614a",
"first_line": "finalize_modules_delete_special(PyThreadState *tstate, int verbose)",
"lines": 20
},
"Python/pylifecycle.c:1931-1948@v3.15.0rc1": {
"digest": "ece3dfff4f4b0004",
"first_line": "// Remove all modules from sys.modules, hoping that garbage collection",
"lines": 18
},
"Python/pylifecycle.c:2272-2311@v3.15.0rc1": {
"digest": "1987a8783ab134bd",
"first_line": "static void",
"lines": 40
},
"Python/pylifecycle.c:2380-2419@v3.15.0rc1": {
"digest": "b8304b4b99bef7b6",
"first_line": "_Py_Finalize(_PyRuntimeState *runtime)",
"lines": 40
},
"Python/pylifecycle.c:2460-2485@v3.15.0rc1": {
"digest": "19310ee1282c48df",
"first_line": "/* Flush sys.stdout and sys.stderr */",
"lines": 26
},
"Python/pylifecycle.c:2604-2612@v3.15.0rc1": {
"digest": "00104a577c218a68",
"first_line": "int",
"lines": 9
},
"Python/pylifecycle.c:2843-2872@v3.15.0rc1": {
"digest": "5601b470aa385a95",
"first_line": "finalize_subinterpreters(void)",
"lines": 30
},
"Python/pylifecycle.c:3843-3862@v3.15.0rc1": {
"digest": "21c00bc96c6b61b7",
"first_line": "wait_for_thread_shutdown(PyThreadState *tstate)",
"lines": 20
},
"Python/pylifecycle.c:638-653@v3.15.0rc1": {
"digest": "3d23cdac0820f2d9",
"first_line": "static void",
Expand Down
2 changes: 2 additions & 0 deletions experiments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ So those programs run somewhere else. They run in the images this project publis
| [r06-what-leaves-the-binary-on-a-free-threaded-build](tier1/r06-what-leaves-the-binary-on-a-free-threaded-build.md) | R06 | freethreaded | Does dropping the global interpreter lock change what the C API exports? |
| [r07-what-a-build-will-load](tier1/r07-what-a-build-will-load.md) | R07 | release | What does the interpreter check before it agrees to load an extension? |
| [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? |

## The commands

Expand Down
234 changes: 234 additions & 0 deletions experiments/tier1/r08-what-the-end-leaves-behind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# The same nine endings on a build that counts what was still alive at the end

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

How much does one daemon thread leave stranded when the interpreter stops?

- Lesson: R08
- Build: debug
- Image: ghcr.io/tamnd/cpython-internals/cpython:debug@sha256:7baea8f3dd4de2e4c3b020543729b147e636494ae9758dabffb4675793e37170
- Interpreter: 3.15.0rc1 (37e98da:37e98da, Aug 29 2026, 09:24:21) [GCC 14.2.0]
- Recorded: 2026-09-06

Why this needs the debug build: it needs a debug build, because only that build has the counters that -X showrefcount prints once shutdown is over.

## The program

```python
"""Nine ways a process can end, and what each one still runs.

Shutdown is the part of the runtime with the fewest promises, and the only honest way to watch
it is from outside. So this program starts a child interpreter for each hazard and reports
what the child managed to run and what status it left behind.

Every child registers an atexit callback and keeps one object with a finalizer in a module
global. Whether those two things run is the whole question, and the answer is not always yes.

On a debug build there is one more section. `-X showrefcount` prints how many references and
how many allocated blocks were still there when the interpreter stopped, so the cost of the
one case that goes wrong can be counted rather than described.
"""

import re
import subprocess
import sys

PREAMBLE = """
import atexit, os, sys


def note(label, _w=os.write, _f=sys.is_finalizing):
_w(2, f"{label} finalizing={_f()}\\n".encode())


class Late:
def __del__(self, _note=note):
_note("finalizer")


def snooze():
import time
time.sleep(30)


atexit.register(note, "atexit")
keeper = Late()
"""

CASES = (
("an ordinary exit", ""),
("sys.exit with a status", "sys.exit(3)"),
("an unhandled exception", "raise SystemError('on purpose')"),
("os._exit, which skips everything", "os._exit(0)"),
("an atexit callback that raises", "atexit.register(lambda: 1 / 0)"),
(
"a finalizer that raises",
"class Angry:\n def __del__(self):\n 1 / 0\nbad = Angry()",
),
(
"a daemon thread inside time.sleep",
"import threading, time\n"
"threading.Thread(target=time.sleep, args=(30,), daemon=True).start()",
),
(
"a daemon thread running your code",
"import threading\nthreading.Thread(target=snooze, daemon=True).start()",
),
("a subinterpreter nobody closed", "import concurrent.interpreters as it\nkid = it.create()"),
)

ORDER = """
import atexit, os, sys, threading, time


def note(label, _w=os.write, _f=sys.is_finalizing):
_w(2, f" {label:38} finalizing={_f()}\\n".encode())


class Late:
def __init__(self, label):
self.label = label

def __del__(self, _note=note):
_note(f"finalizer of {self.label}")


def worker():
time.sleep(0.2)
note("a thread you started finishing")


atexit.register(note, "atexit registered first")
atexit.register(note, "atexit registered second")
keeper = Late("a module global")
threading.Thread(target=worker).start()
note("your last line")
"""

HELD = """
import atexit, os, sys


def snooze():
import time
time.sleep(30)


class Held:
pass


keeper = [Held() for _ in range(5000)]
"""

SHAPES = (
("nothing left behind", ""),
("five thousand objects in a module global", HELD),
(
"the same, plus a daemon thread running your code",
HELD + "import threading\nthreading.Thread(target=snooze, daemon=True).start()\n",
),
)

LEFTOVER = re.compile(r"\[(\d+) refs, (\d+) blocks\]")


def run(program, flags=()):
"""Start a child interpreter with that program and hand back what it did."""
return subprocess.run(
[sys.executable, *flags, "-c", program], capture_output=True, text=True, timeout=180
)


def leftover(program):
"""What a debug build reports was still alive after it finished shutting down."""
found = LEFTOVER.search(run(program, ("-X", "showrefcount")).stderr)
return (int(found.group(1)), int(found.group(2))) if found else None


DEBUG = hasattr(sys, "gettotalrefcount")

print("version:", sys.version.split()[0])
print("debug build:", DEBUG)
print("free threaded:", hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled())
print()

print("the order things happen in, watched from one child")
for line in run(ORDER).stderr.splitlines():
print(line.rstrip())
print()

print("what each kind of ending still runs")
print(f" {'the child':38} {'status':>6} {'atexit':>7} {'finalizer':>10} warned")
ran_atexit = 0
ran_finalizer = 0
zero = 0
for label, body in CASES:
done = run(PREAMBLE + body)
saw_atexit = "atexit finalizing" in done.stderr
saw_final = "finalizer finalizing" in done.stderr
warned = "yes" if "RuntimeWarning" in done.stderr else ""
ran_atexit += saw_atexit
ran_finalizer += saw_final
zero += done.returncode == 0
yes_atexit = "yes" if saw_atexit else "no"
yes_final = "yes" if saw_final else "no"
columns = f"{done.returncode:>6} {yes_atexit:>7} {yes_final:>10}"
print(f" {label:38} {columns} {warned}".rstrip())
print()

stranded = 0
if DEBUG:
print("what this build says was still alive when the interpreter stopped")
base = leftover("")
for label, program in SHAPES:
now = leftover(program)
stranded = max(stranded, now[0] - base[0])
print(f" {label:50} {now[0] - base[0]:+8} refs {now[1] - base[1]:+8} blocks")
else:
print("a debug build would also count what was left over, and this is not one")
print()

print(f"~ children that ran their atexit callback: {ran_atexit} of {len(CASES)}")
print(f"~ children that ran their finalizer: {ran_finalizer} of {len(CASES)}")
print(f"~ children whose exit status was zero: {zero} of {len(CASES)}")
if DEBUG:
print(f"~ references stranded by one daemon thread: {stranded}")
```

## What it printed

```text
version: 3.15.0rc1
debug build: True
free threaded: False

the order things happen in, watched from one child
your last line finalizing=False
a thread you started finishing finalizing=False
atexit registered second finalizing=False
atexit registered first finalizing=False
finalizer of a module global finalizing=True

what each kind of ending still runs
the child status atexit finalizer warned
an ordinary exit 0 yes yes
sys.exit with a status 3 yes yes
an unhandled exception 1 yes yes
os._exit, which skips everything 0 no no
an atexit callback that raises 0 yes yes
a finalizer that raises 0 yes yes
a daemon thread inside time.sleep 0 yes yes
a daemon thread running your code 0 yes no
a subinterpreter nobody closed 0 yes yes yes

what this build says was still alive when the interpreter stopped
nothing left behind +0 refs +0 blocks
five thousand objects in a module global +0 refs +0 blocks
the same, plus a daemon thread running your code +12680 refs +6506 blocks

~ children that ran their atexit callback: 8 of 9
~ children that ran their finalizer: 7 of 9
~ children whose exit status was zero: 7 of 9
~ references stranded by one daemon thread: 12680
```
Loading
Loading