Free-threaded Python (PEP 703) support
mode supports free-threaded ("no-GIL") CPython. This page records what
was wrong before that was true, how each defect was fixed, and how to
re-check the work.
Everything here was measured on CPython 3.14.0rc2 free-threading build
(python3.14t, sys._is_gil_enabled() == False), with a GIL-enabled
CPython 3.14.0rc2 used as the control. The reproducers live in
tests/freethreading/stress.py; the regression tests that keep the fixes
honest live in tests/functional/test_thread_safety.py and run on every
leg of the CI matrix.
The "before" numbers are races, so the failure rates move between runs —
they are representative single runs, not stable constants. On repeated runs
the free-threaded cached_property figure ranged from 104/300 to 164/300,
and the cold-import figure from 14/25 to 18/25. What did not move is which
side of each table failed.
Status
mode is pure Python, so there was never anything to port — it installed,
imported and passed its test suite on a free-threaded interpreter from the
start. What free threading changed is that four latent thread-safety defects
stopped being theoretical. One of them crashed the interpreter.
All four are fixed.
| Free-threaded (before) | Free-threaded (after) | GIL | |
|---|---|---|---|
pip install mode-streaming |
works (py3-none-any) |
works | works |
Import every mode module |
GIL stays disabled | GIL stays disabled | n/a |
pytest tests/unit tests/functional |
passes | passes | passes |
LRUCache under 16 threads |
SIGSEGV | clean | clean |
cached_property under 16 threads |
duplicate objects | one object | one object |
concurrent cold import mode |
fails 14/25 runs | 0/25 | 0/25 |
Signal under 16 threads |
raises 30/30 | 0/30 | 0/30 |
mode[uvloop] |
GIL stays disabled | GIL stays disabled | n/a |
mode[gevent] |
GIL re-enabled | GIL re-enabled | n/a |
mode[gevent] is the one item that is not fixed, because it cannot be
fixed here — see below.
What already worked
No packaging work was required. mode ships no C extensions, so the
existing py3-none-any wheel already installs and runs on 3.13t/3.14t.
Importing every module in the package leaves the GIL disabled, and the core
dependencies (colorlog, croniter, mypy_extensions) are pure Python.
These were stress-tested with 16 concurrent OS threads and found safe as they stood:
Servicesubclass creation —__init_subclass__writing the sharedcls._tasksmapping (mode/services.py)ServiceThreadstart/stop from many threads concurrentlyget_event_loop()— thethreading.localcache inmode/utils/loops.pycorrectly gives each thread its own loop with no cross-thread leakageNode/beacon tree traversal concurrent with mutationManagedUserDict/FastUserDictmutationannotations()/eval_type()LocalStack— alreadyContextVar-based, so correct by construction
The four defects, and their fixes
1. LRUCache could segfault the interpreter
Was: critical. Free-threading-specific.
LRUCache.data was a collections.OrderedDict and thread_safety
defaulted to False, which made the mutex a nullcontext. So eviction in
__setitem__ and iteration in keys() ran with no lock at all.
Under the GIL this was benign: 0/20 stress trials raised. On 3.14t the
same code first raised RuntimeError: OrderedDict changed size during
iteration and then segfaulted — 4 of 5 runs of a 60-trial loop exited
with SIGSEGV, and a 5th hung.
The cause was OrderedDict itself. Repeating the identical concurrent
mutate-and-iterate loop against a bare container:
| container | free-threaded 3.14t |
|---|---|
collections.OrderedDict |
SIGSEGV / SIGABRT, 3/3 runs |
plain dict |
survives, 3/3 runs |
Free-threaded CPython gives plain dict per-object locking; OrderedDict's
C implementation did not get the same treatment, so concurrent mutation
corrupts its internal linked list.
Fixed in mode/utils/collections.py by:
- Making the mutex mandatory on free-threaded builds.
thread_safetydefaults to the newmode.utils.collections.FREE_THREADEDflag, checked at runtime rather than build time soPYTHON_GIL=1is respected, and passingthread_safety=Falseon such a build now raisesValueErrorrather than handing back a structure that can take the interpreter down. - Snapshotting in
_keys/_values/_itemsinstead of holding the mutex acrossyield. The old code kept the lock held for as long as the consumer took to iterate — and forever if the consumer abandoned the generator, since the lock was only released when the generator was closed. That hazard was latent while the lock defaulted to off; turning the lock on by default would have made it real. - Taking the mutex in every operation that reaches
data, not just the onesLRUCachealready defined.LRUCacheinherits fromFastUserDict, whose methods useself.datadirectly, sodel cache[k],clear(),copy(),len(cache),k in cacheandrepr(cache)all reached theOrderedDictwith the mutex released — the same unguarded access the segfault came from, through the ordinary mapping API. Read-only operations are no safer here than writes: alen()racing an eviction reads a linked list mid-relink.pop,setdefaultandgetare overridden as well; the primitives they are inherited as were each locked, but the lock was dropped between the lookup and the store, which is a surprising place for a class advertising thread safety to lose an invariant. - Enforcing the same invariant when unpickling.
__setstate__is a second construction path, and it restored the pickled state verbatim — so a cache pickled on a GIL build (wherethread_safety=Falseis both legal and the historical default) came back on a free-threaded interpreter with anullcontextfor a mutex, exactly the configuration__init__refuses. It now upgradesthread_safetytoTrueinstead, which keeps old pickles loadable where raising would not. - Evicting with
popitem(last=False)instead of the historicalpop(next(iter(data))). Under the mutex they are equivalent (and it is the very operation the table below keepsOrderedDictfor), but GIL builds still permit — and default to — sharing an unlocked cache, and there the three-call form races: a switch betweeniterandnextwhile another thread inserts raises "OrderedDict mutated during iteration", and two threads resolving the same oldest key make the loser'spopraiseKeyError. CI caught the first flavor on a stock 3.13 run; at a 1µs switch interval it reproduces in almost every trial, and the single-call form takes both windows away on CPython, where the Cpopitemis atomic under the GIL (PyPy's is Python-level and can itself raise mid-iteration unlocked — the mutex is the only fix there). This narrows the unlocked race, it does not close it: the check-then-act around the call can still over-evict, andpopitemstill raisesKeyErrorif another thread empties the cache between the check and the call. Thread safety remains the mutex's job — which is why the concurrency tests hammerthread_safety=Trueexplicitly rather than the default: on free-threaded builds that is the same configuration the default resolves to, and on GIL builds the unlocked default makes no promise under concurrent mutation for a test to assert. The eviction mechanism itself has a deterministic guard (test_eviction_does_not_iterate_the_data), since on a locked cache no stress test can tell the two forms apart.
Why not just swap OrderedDict for dict?
That was the first fix, and it was wrong. dict has preserved insertion
order since 3.7 and is memory-safe under free threading, so it looks like a
free win — but LRUCache's hot path is evicting the oldest entry, and
that is the one thing dict cannot do in O(1). OrderedDict.popitem(last=
False) unlinks a node; the dict equivalent, d.pop(next(iter(d))), has
to scan past every slot vacated since the last resize.
Steady-state evict-and-insert, 100k operations:
| cache size | OrderedDict |
dict |
|---|---|---|
| 1,000 | 0.043s | 0.089s |
| 10,000 | 0.046s | 0.448s |
| 100,000 | 0.052s | 2.447s |
The gap grows linearly with the cache, because the eviction itself became
O(n). Periodically rebuilding the dict to compact it only softens this to
O(√n) — still ~24x at 100k — so there is no cheap repair. OrderedDict is
the right data structure here; the concurrency hazard belongs to the mutex,
not to the choice of container.
LRUCache is not used inside mode itself; it is exported utility surface
(faust is a consumer), so the blast radius was downstream.
2. cached_property handed different objects to different threads
Was: high. Free-threading-specific.
cached_property.__get__ was a check-then-act on obj.__dict__: try the
key, catch KeyError, compute, store. Nothing made that atomic.
| duplicate-object trials | computes per 300 properties | |
|---|---|---|
| GIL 3.14 | 0/300 | 300 |
| free-threaded 3.14t | 104/300 | 419 |
This was not merely wasted work. ServiceProxy documents
@cached_property _service as the way to build the proxied service — it
is how the Faust App is constructed at module level. Racing 16 threads on
proxy._service:
trials that built/returned >1 Service |
|
|---|---|
| GIL 3.14 | 0/200 |
| free-threaded 3.14t | 198/200 |
So one thread could start() one Service instance while another held a
different instance, and the later stop() never reached the one that was
started.
Fixed in mode/utils/objects.py with double-checked locking: the
already-cached lookup stays lock-free (a plain dict hit), and only the miss
path takes a per-descriptor RLock and re-checks after acquiring.
Contention is therefore limited to first-time initialisation.
Note that stdlib functools.cached_property deliberately dropped its lock
in 3.12 and accepts duplicate computation. That trade-off is fine for a
pure value cache; it is not fine for a singleton service handle.
3. Concurrent first import mode could hand back a half-built module
Was: high. Pre-existing, but much worse under free threading. This one broke the most ordinary thing a user does.
mode/__init__.py used the Werkzeug lazy-import trick: define a _module
subclass whose __getattr__ resolves the lazily-exported names, then swap
it into sys.modules at the end of the module body.
If thread B ran import mode while thread A was still executing
mode/__init__.py, B could be handed the original, pre-swap module object —
which has no __getattr__ — so every lazily-exported name raised:
AttributeError: module 'mode' has no attribute 'Service'
Racing 16 threads on a cold import mode followed by attribute access:
| runs with at least one failing thread | |
|---|---|
| GIL 3.14 | 3/25 |
| free-threaded 3.14t | 14/25 |
Instrumenting a failing thread confirmed the mechanism: the object it
imported was a plain module while sys.modules["mode"] was already the
_module instance — the thread held the stale pre-swap object. The
replacement module also carried no __spec__, which deprived the import
machinery of the _initializing flag it would otherwise use to make the
second thread wait.
Fixed by dropping the sys.modules swap entirely in favour of a
:pep:562 module-level __getattr__ (plus a module __dir__). PEP 562
landed in 3.7 and mode's floor is 3.10, so the _module class existed only
for compatibility that is no longer needed. With no swap, the race cannot
happen — and sys.modules["mode"] keeps its real __spec__.
4. Signal mutated its receiver set during iteration
Was: medium. Pre-existing, not a free-threading regression — it raised
RuntimeError: Set changed size during iteration in 30/30 trials on both
builds, so Signal had never been thread-safe.
_get_live_receivers iterated self._receivers (a plain set) while
connect/disconnect added to and discarded from it — and the caller then
discarded dead refs from the same set using the result.
Fixed in mode/signals.py by iterating a snapshot.
The snapshot must be list(r), not tuple(r). This is not stylistic:
| snapshot of a set being mutated by 4 threads | free-threaded 3.14t |
|---|---|
tuple(s) |
8 failures — Set changed size during iteration |
list(s) |
0 failures |
set(s) |
0 failures |
s.copy() |
0 failures |
frozenset(s) |
0 failures |
list(), set() and set.copy() take the source set's per-object lock for
the duration of the copy; tuple() falls back to the generic iterator
protocol and does not, so tuple(r) raises the very error the snapshot
exists to prevent. The first attempt at this fix used tuple(r) and the
stress harness caught it.
Half of that race turned out to be unreachable, which made the fix look
better tested than it was. disconnect(fun) never removed a receiver
connected with the default weak=False: connect stored lambda: fun and
disconnect built a second lambda to look it up, and two lambdas never
compare equal, so the discard matched nothing. The receiver set only ever
grew, and the "connect/disconnect churn" being raced was churn in one
direction. Sender-specific disconnects were worse than a no-op — they used
set.remove, which raises KeyError for a receiver that is not there,
under an except ValueError that could not catch it.
Also fixed in mode/signals.py by storing a strong receiver as the
handler itself, with nothing wrapped around it. A handler already hashes
and compares the way disconnect needs — functions by identity, bound
methods by (__func__, __self__), so owner.handler matches even though
attribute access builds a fresh object every time. The sender-specific
path uses discard now, and the concurrency test asserts the receiver set
is empty at the end rather than only that nothing raised.
The first attempt at this stored a _StrongRef wrapper instead, holding
the handler and defining __eq__/__hash__ in terms of it. It made
disconnect work and it passed on every CPython build — and it wedged
PyPy. Defining those two methods in Python means set.add and
set.discard re-enter the interpreter partway through, which releases the
GIL and lets another thread mutate the same set while the operation that
called out is still walking it. The receiver set is mutated from several
threads by design, so test_iter_receivers_while_connecting would either
finish in a second or never finish at all; in CI it burned the job's
six-hour limit. Entries in that set have to hash and compare in the
interpreter, which is a constraint on any future change to how receivers
are represented, not just on the wrapper that ran into it.
Two things bound the damage from that class of mistake now, since the
symptom is silence rather than a failure: every wait in the concurrency
tests is bounded (see race in tests/functional/test_thread_safety.py),
and the test jobs carry a timeout-minutes.
Not fixable here: the gevent extra re-enables the GIL
| extra | result on 3.14t |
|---|---|
mode[uvloop] |
uvloop 0.22.1 imports and runs, GIL stays disabled |
mode[eventlet] |
imports, GIL stays disabled (eventlet prints its own migrate-away notice) |
mode[gevent] |
GIL re-enabled at import |
Installing mode[gevent] silently downgrades a free-threaded interpreter
back to GIL semantics:
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load
module 'gevent.libev.corecext', which has not declared that it can run
safely without the GIL.
This is upstream in gevent, not something mode can fix. It is flagged in
pyproject.toml next to the extra, and mode/loop/gevent.py now warns at
import time on a free-threaded build — the degradation is otherwise silent,
since you keep running and simply are not free-threaded any more. That check
uses the build flag (sysconfig.get_config_var("Py_GIL_DISABLED")) rather
than sys._is_gil_enabled(), which by then already reads True.
Separately: mode.loop.use("gevent") is currently broken on every build.
This has nothing to do with free threading — it fails identically on
GIL-enabled 3.10 and 3.14 with gevent 26.7.0:
ImportError: Cannot import 'Loop' from <module 'mode.loop._gevent_loop'>
The cause is a self-referential import. mode/loop/gevent.py sets
GEVENT_LOOP=mode.loop._gevent_loop.Loop, but mode/loop/_gevent_loop.py
imports gevent.core at module scope in order to subclass
gevent.core.loop. Importing it therefore builds a gevent hub, which
resolves GEVENT_CONFIG.loop, which imports mode.loop._gevent_loop — a
module whose body has not yet reached class Loop. Pre-importing the module
does not help, because the cycle is inside its own import.
gevent itself is fine: gevent.monkey.patch_all() plus
asyncio_gevent.EventLoopPolicy runs an asyncio coroutine correctly. Only
mode's custom GEVENT_LOOP hook fails. Presumably gevent used to resolve
that setting lazily and no longer does.
mode.loop has no test coverage, which is how this went unnoticed. Fixing
it would mean building Loop lazily rather than at module scope.
Rather than repair a backend that cannot work on free-threaded builds
anyway, the gevent loop is deprecated: selecting it raises a
DeprecationWarning naming the breakage and pointing at aio/uvloop,
and it is slated for removal in a future major release. Nothing is removed
yet, so this is not a breaking change.
CI
3.14t is part of the tests.yml matrix, so the suite — including
tests/functional/test_thread_safety.py — runs with the GIL disabled on
every push. ruff and mypy both run clean on the free-threaded build.
The package advertises
Programming Language :: Python :: Free Threading :: 2 - Beta.
A note on pytest-run-parallel
pytest-run-parallel installs and runs on 3.14t, but pointing
--parallel-threads at the existing suite is not useful: it reports ~33
failures in tests/functional/utils/test_collections.py alone that are
artifacts of tests sharing mutable fixtures and Mock objects, not mode
bugs. For example test_AttributeDictMixin::test_set_get fails with "DID
NOT RAISE AttributeError" purely because a sibling thread already set the
attribute on the shared object.
Use it selectively on purpose-written thread-safety tests rather than across the whole suite.
Reproducing
uv python install 3.14t
uv venv --python 3.14t .venv-ft
VIRTUAL_ENV=.venv-ft uv pip install -e . -r requirements-tests.txt
.venv-ft/bin/python -m pytest tests/unit tests/functional
.venv-ft/bin/python tests/freethreading/stress.py
tests/freethreading/ is deliberately outside the testpaths configured in
pyproject.toml, so the heavier probabilistic reproducers are never
collected by a normal pytest run. Run the same file under a GIL-enabled
interpreter to see the control numbers.