Skip to content

Sharing a dict iterator across threads double-DECREFs di_dict under free-threading #154130

Description

@devdanzin

Crash report

What happened?

On a free-threaded build, advancing a single, shared dict iterator (iter({...})) from multiple threads double-frees the underlying dict and crashes.

All three dict iterators (dict_keyiterator / dict_valueiterator / dict_itemiterator) route next() through dictiter_iternext_threadsafe (Objects/dictobject.c). Its exhaustion path drops the iterator's owning reference to the dict:

fail:
di->di_dict=NULL; /* non-atomic clear */Py_DECREF(d); /* drop the iterator's ONE owning ref to the dict */return-1;

and the caller reads that reference with no lock:

staticPyObject*dictiter_iternextkey(PyObject*self)
{
dictiterobject*di= (dictiterobject*)self;
PyDictObject*d=di->di_dict; /* plain read */if (d==NULL)
returnNULL;
PyObject*value;
if (dictiter_iternext_threadsafe(d, self, &value, NULL) <0) {
value=NULL;
}
returnvalue;
}

The iterator holds exactly one reference to the dict (di_dict). Two threads calling next() on the same near-exhausted iterator interleave as: both read d = di->di_dict (non-NULL), both reach fail:, and both run Py_DECREF(d). The second Py_DECREF has no matching reference — the dict's refcount underflows / it is freed one owner too early, and a sibling thread still walking d (and the dict freelist / keys object) then touches freed memory.

This fail: di->di_dict = NULL; Py_DECREF(d) pattern predates free-threading (it is correct under the GIL, where only one thread runs the iterator); the lock-free dictiter_iternext_threadsafe wrapper carried it over unguarded.

Reproducer

importthreadingNT=8ITERS=200_000defnewit():
returniter({k: kforkinrange(32)})
cell= [newit()]
defworker():
for_inrange(ITERS):
it=cell[0]
try:
next(it) # dictiter_iternextkey -> dictiter_iternext_threadsafeexceptStopIteration:
cell[0] =newit() # refill so the fail: (exhaustion) path is hit repeatedlyexceptException:
passthreads= [threading.Thread(target=worker) for_inrange(NT)]
fortinthreads: t.start()
fortinthreads: t.join()
print("done, no crash")

PYTHON_GIL=0 ./python repro.py on a free-threaded build:

  • Debug build → SIGABRT within seconds, ~8/8 runs, with _Py_NegativeRefcount on the dict (Objects/dictobject.c:6159), plus downstream corruption faces as the freed dict/keys object is reused (dictkeys_incref immortal-refcount assert :484; new_dict type assert :978; clear_freelistObjects/object.c:909; validate_refcounts in gc_free_threading.c).
  • Release build (-O0, no sanitizer) → SIGSEGV (use-after-free), or occasionally Fatal Python error: PyMutex_Unlock: unlocking mutex that is not locked from the corrupted dict mutex.

So it is neither debug-only nor sanitizer-only. Debug backtrace (the negative-refcount object is the dict d):

#8 _PyObject_AssertFailed (obj=0x...dict...) at Objects/object.c:3278
#9 _Py_NegativeRefcount at Objects/object.c:275
#12 Py_DECREF at ./Include/refcount.h:363
#13 dictiter_iternext_threadsafe (d=0x...dict...) at Objects/dictobject.c:6159 <-- fail: Py_DECREF(d)
#14 dictiter_iternextkey at Objects/dictobject.c:5791
#15 builtin_next at Python/bltinmodule.c:1776

Suggested fix

Consume the reference atomically so exactly one thread performs the DECREF:

fail:
PyDictObject*old=_Py_atomic_exchange_ptr(&di->di_dict, NULL);
if (old!=NULL) {
Py_DECREF(old);
}
return-1;

and keep the dict alive for the duration of the lock-free walk (the caller uses d and its keys/values across the whole dictiter_iternext_threadsafe body) so a sibling that wins the exchange cannot free it mid-iteration — take a strong reference or the dict's critical section for the walk. One fix covers keys/values/items, since all three route through dictiter_iternext_threadsafe.

Why this is not the documented value-benign iterator race

This function was written to be shared across threads, and a crash is explicitly out of contract:

(Found by fusil --tsan, a ThreadSanitizer fuzzer; crash confirmed by re-running the reproducer without a sanitizer on a plain free-threaded build. Draft and reproducer by Claude Code, minimized and reviewed by hand.)

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.16.0a0 free-threading build (heads/main:a1d580430c8, Jul 18 2026, 20:23:36) [Clang 21.1.8 (6ubuntu1)]

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions