Repository navigation
Free-threaded build: memory grows with every foreign thread that calls an extern "Python" callback #286
Description
On a free-threaded CPython (3.14t), every thread not created by Python that calls an extern "Python" callback leaves memory behind after it exits. A program whose C library starts worker threads per call (a thread pool per batch, say) grows without bound. GIL builds are not affected, and neither are ctypes callbacks from the same threads.
There are two parts:
- Python objects, when the callback takes arguments: 25–30 bytes per thread, visible to
tracemalloc. - Resident memory, with or without arguments: 100–180 bytes per thread, not visible to
tracemalloc.
Reproducer
build.py:
import cffi
ffibuilder = cffi.FFI()
ffibuilder.cdef("""
extern "Python" void no_args(void);
extern "Python" void one_arg(int);
void run_threads(int n, int with_arg);
""")
ffibuilder.set_source("_repro", r"""
#include <pthread.h>
static void no_args(void);
static void one_arg(int);
static void *worker(void *with_arg) {
if (with_arg)
one_arg(42);
else
no_args();
return NULL;
}
/* Start n new threads, each calling a Python callback once, and join them. */
static void run_threads(int n, int with_arg) {
pthread_t threads[64];
for (int i = 0; i < n; i++)
pthread_create(&threads[i], NULL, worker, with_arg ? (void *)1 : NULL);
for (int i = 0; i < n; i++)
pthread_join(threads[i], NULL);
}
""")
if __name__ == "__main__":
ffibuilder.compile()repro.py:
import gc
import platform
import resource
import sys
import sysconfig
import tracemalloc
import cffi
from _repro import ffi, lib
@ffi.def_extern()
def no_args():
pass
@ffi.def_extern()
def one_arg(x):
pass
def spin(with_arg, threads):
for _ in range(threads // 2):
lib.run_threads(2, with_arg)
def traced(with_arg, threads=10_000):
gc.collect()
before = tracemalloc.get_traced_memory()[0]
spin(with_arg, threads)
gc.collect()
return (tracemalloc.get_traced_memory()[0] - before) / threads
def rss(with_arg, threads=200_000):
scale = 1 if sys.platform == "darwin" else 1024 # ru_maxrss unit
gc.collect()
before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * scale
spin(with_arg, threads)
gc.collect()
after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * scale
return (after - before) / 2**20
with_arg = int(sys.argv[1])
spin(with_arg, 10_000) # warm-up
peak = rss(with_arg)
tracemalloc.start()
rounds = " ".join(f"{traced(with_arg):5.1f}" for _ in range(3))
print(
f"cffi {cffi.__version__}, CPython {platform.python_version()}, "
f"Py_GIL_DISABLED={sysconfig.get_config_var('Py_GIL_DISABLED')}, "
f"{'one_arg(int)' if with_arg else 'no_args()'}: "
f"traced B/thread {rounds} | peak RSS +{peak:.1f} MiB / 200000 threads"
)python build.py && python repro.py 0 && python repro.py 1
Results
The second column is the bytes tracemalloc traces per thread, over three rounds of 10,000 threads. The third is how much the peak RSS grows over 200,000 threads.
macOS arm64, CPython 3.14.7:
| callback | traced bytes / thread | peak RSS | |
|---|---|---|---|
| cffi 2.1.1, free-threaded | no_args() |
1.6 · 0.0 · 0.0 | +29.0 MiB |
| cffi 2.1.1, free-threaded | one_arg(int) |
27.1 · 25.4 · 27.0 | +30.3 MiB |
| cffi main (ed994e0), free-threaded | no_args() |
1.6 · 0.0 · 0.0 | +28.3 MiB |
| cffi main (ed994e0), free-threaded | one_arg(int) |
27.6 · 25.8 · 25.8 | +30.0 MiB |
| cffi 2.1.1, GIL | no_args() |
0.1 · 0.0 · 0.0 | +0.0 MiB |
| cffi 2.1.1, GIL | one_arg(int) |
0.1 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, free-threaded | no_args() |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, free-threaded | one_arg(int) |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, GIL | no_args() |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, GIL | one_arg(int) |
0.0 · 0.0 · 0.0 | +0.0 MiB |
Linux aarch64 (Ubuntu 24.04), CPython 3.14.8:
| callback | traced bytes / thread | peak RSS | |
|---|---|---|---|
| cffi 2.1.1, free-threaded | no_args() |
1.6 · 0.0 · 0.0 | +33.6 MiB |
| cffi 2.1.1, free-threaded | one_arg(int) |
29.4 · 27.9 · 27.7 | +20.2 MiB |
| cffi main (ed994e0), free-threaded | no_args() |
1.6 · 0.0 · 0.0 | +30.2 MiB |
| cffi main (ed994e0), free-threaded | one_arg(int) |
29.4 · 27.9 · 27.9 | +23.0 MiB |
| cffi 2.1.1, GIL | no_args() |
0.1 · 0.0 · 0.0 | +0.0 MiB |
| cffi 2.1.1, GIL | one_arg(int) |
0.1 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, free-threaded | no_args() |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, free-threaded | one_arg(int) |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, GIL | no_args() |
0.0 · 0.0 · 0.0 | +0.0 MiB |
| ctypes, GIL | one_arg(int) |
0.0 · 0.0 · 0.0 | +0.0 MiB |
The ctypes rows come from the same thread code compiled as a plain shared library and called with ctypes.CFUNCTYPE(None) / CFUNCTYPE(None, c_int) callbacks.
Where the objects go (part 1)
When a foreign thread first calls a callback, cffi keeps its PyThreadState alive with the thread canary. When the thread exits, cffi_thread_shutdown() puts the canary on the zombie list (misc_thread_common.h#L279-L295). Later, another thread frees it in thread_canary_free_zombies() with PyThreadState_Clear(tstate) and PyThreadState_Delete(tstate) (#L146-L185).
On a free-threaded build, PyThreadState_Clear() clears the freelists of the calling thread, not those of tstate (pystate.c#L1826-L1829, the same on main):
#ifdef Py_GIL_DISABLED
// Each thread should clear own freelists in free-threading builds.
struct _Py_freelists *freelists = _Py_freelists_GET();
_PyObject_ClearFreeLists(freelists, 1);The argument tuple of a callback is freed on the worker thread, into that thread's freelist. When the zombie's state is cleared from another thread, that freelist is never cleared, so the tuple stays allocated. The C API docs require tstate to be attached, which a zombie's state is not. This fits the data: only callbacks with arguments grow in tracemalloc, and ctypes (whose PyGILState_Release() deletes the state on its own thread) does not grow.
Resident memory (part 2)
RSS also grows by 100–180 bytes per thread with no_args(), which allocates no Python object. tracemalloc does not see it, and ctypes does not show it either. I have not traced where it goes. Per-thread runtime state that is only released when a thread state is deleted on its own thread is a guess, nothing more.
Activity
fix: batch callbacks enter cffi one at a time on free-threaded CPython
gudoshnikovn commentedon Oct 14, 2026
The same mechanism can also crash the process, not only leak. On a free-threaded build, a foreign thread that enters an
extern "Python"callback segfaults insidePyThreadState_Clear(). That call isthread_canary_free_zombies()clearing the state of another thread that has already exited.Reproducer
build.py:repro.py:It crashes within a few seconds, usually in under one:
Under lldb, the faulting instruction is in mimalloc.
_PyMem_AbandonDelayed()frees the blocks queued for delayed freeing, and thePyMem_Free()it calls ends in_mi_page_retire():Results
Each row is 20 runs of the script above, or of a variant of it.
macOS arm64, CPython 3.14.7:
PYTHON_GIL=1callback(i)under onepthread_mutex_tin CThe ctypes rows use the same thread code, compiled as a plain shared library and called with a
ctypes.CFUNCTYPE(None, c_int)callback.Three things are needed for the crash:
mem_free_queue. With a fresh local list nothing is queued, and nothing crashes.usleep(10)tousleep(100)between the calls, the script crashes. Without the sleep it ran 20 out of 20 times without a crash.What happens
thread_canary_free_zombies()(misc_thread_common.h#L146-L185) is marked "This must be called with the GIL".cffi_zombie_lockcovers only taking a zombie off the list.PyThreadState_Clear()andPyThreadState_Delete()run outside that lock, and on a free-threaded build no GIL serializes them either. So several entering threads can clear several exited threads' states at the same time, each from a thread that does not own that state.On a free-threaded build,
PyThreadState_Clear()hands the exited thread's delayed frees and mimalloc heaps over from whichever thread calls it (pystate.c#L1851-L1853):_PyMem_AbandonDelayed()frees the queued blocks right away, on the calling thread (obmalloc.c#L1429-L1459). The crash is in mimalloc's local-free path (_mi_page_retire), which mimalloc takes only when it believes the calling thread owns the block's page. I have not established why it believes that here. One guess is that a new pthread can get the same mimalloc thread id as an exited one whose heaps have not been abandoned yet. Either way, the crash needs two zombies to be cleared at the same time: with the clearing serialized (below), it is gone. ctypes, whosePyGILState_Release()deletes a state on its own thread, does not crash.A fix that works here
Holding one lock across the whole of
thread_canary_free_zombies()on a free-threaded build is enough for this reproducer (the "with the patch" row above):This restores the one guarantee the function's comment asks for: zombies are cleared one at a time. It still clears each state from a thread that is not its own, so it does not address the leak above. Letting each thread delete its own state, as
PyGILState_Release()does, would fix both, but I don't know what that costs the canary design. Until cffi changes, a program can avoid the crash by entering its callbacks under one lock in C (the "under onepthread_mutex_t" row).