Without the GIL: the races it was hiding
The same code on 3.14.10 and 3.14.10t. With the GIL, c.n += 1 across four threads did not lose a single increment out of four million in our measurement; without it, it lost 26 to 40%. The race in this code was always there: the GIL simply kept it from happening.
Full technical treatment
TL;DR
- In ordinary Python, threads take turns holding a single marker and write on a shared whiteboard one at a time. In the new "free-threaded" build everyone has a marker of their own, and they all write at once.
- Code that kept a count on the shared whiteboard worked without errors for years in the ordinary build. In the free-threaded one it can lose part of the count — it lost 26 to 40% in the measurement.
- The bug in this code was always there. The old one-marker rule simply kept it from happening — and you can see that by changing the code a little: then the ordinary build loses count too.
- The fix is not going back to the ordinary build but giving each thread its own count, using a queue, or adding an explicit lock.
- A single old library can quietly bring back "one marker for everyone" for the whole program. That has to be checked at startup.
One whiteboard, one marker
Picture doorkeepers at the four doors of one building. On the wall hangs a shared whiteboard, and on it a number: how many people have come in. When someone enters, a doorkeeper walks up to the board, reads the number, adds one in their head and writes the new number over the old one.
The doorkeeper here is a thread, and the board is the program's shared data.
In ordinary Python the doorkeepers have one marker between them. Only the one holding it can write. The marker does not change hands at just any moment, only during pauses — between two people coming in, or when a doorkeeper gets distracted, say, goes off to call someone. While a doorkeeper is reading the number, adding one and writing, there are no pauses, and nobody takes the marker away from them.
So for an action this short the count on the board comes out right. Not because that was promised, but because here, between "read" and "wrote", the doorkeeper never once gets distracted.
In Python the "one marker for everyone" rule is called the GIL. In the diagrams below, the places where the marker can change hands are marked as "checkpoints".
In the free-threaded build of Python everyone has a marker. Four doorkeepers walk up to the board at the same time. Two of them read the same number, both add one, both write — and one of the people who came in is lost.
This was measured
Four threads, each adding one a million times. The result should be 4,000,000.
import threading
class Counter:
pass
c = Counter()
c.n = 0
def work():
for _ in range(1_000_000):
c.n += 1
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(c.n) # measured: one marker for everyone: 4000000; a marker each: lessIn the measurement for the article (the same loop, with the threads starting at the same moment) ordinary Python 3.14.10 gave exactly 4,000,000 in five runs out of five. The free-threaded 3.14.10t build lost 26 to 40% of the count, and a different amount each time. How much the next run will lose cannot be said in advance — and a single run with no losses proves nothing.
The bug was there before
The most important thing in this story: the ordinary build was not correct. It simply kept the bug from happening.
That is easy to check. Let the doorkeeper get distracted for a second between
"read" and "wrote" — call someone over. In code that means calling a function
in the middle of the addition: c.n = c.n + one(). Now the marker has time to
pass to another doorkeeper, who writes their own numbers, and then the first one
comes back and writes their long-outdated number over the top.
In this variant the ordinary build lost 50 to 69% of the count — about as much as the free-threaded one. So there was no protection. It was luck: in the shortest variant the doorkeeper never got distracted.
Two signs for one name
Things made only when first needed run into the same trouble. Say a sign with a guest's name has to be hung at the entrance if there is not one there yet. A doorkeeper looks: no sign, so they go and make one. While they are making it, the marker sometimes passes to another doorkeeper — who looks in, sees no sign, and also goes to make one. Two signs end up on the wall, and each doorkeeper is sure that theirs is the right one. With one marker this happens rarely; with a marker each, all the time.
In a program the place where the signs hang is called a cache: finished things are put there so they do not have to be made twice. Two different signs for one name is a bug nobody can see. If the cache holds locks (things that let only one thread at a time get at the shared data), two threads hold two different locks "for the same person", and the lock protects nothing.
The longer a sign takes to make, the more often this happens without a shared marker. With five thousand names and slow sign-making, the ordinary build got it wrong in 20–24 cases, the free-threaded one in several thousand.
One old library, and it is one marker again
The free-threaded build has a safety net against old libraries that are not ready for everyone having a marker. If you plug in such a library, Python brings back the "one marker for everyone" rule without any error — for the whole program at once — and prints a single line of warning about it, which is easy to miss.
The program keeps working; it just stops getting what the free-threaded build was installed for: two threads no longer count twice as fast as one, but at the same speed.
What follows from this
- Give each thread its own count and add them up at the end. That is both correct and the fastest option in the measurement.
- If something shared really is needed, agree that the doorkeepers come up to
the board one at a time: cover "read — modify — write" with a lock
(
threading.Lock). - Hand out work to threads through a queue (
queue.Queue) rather than through one shared pile of tasks that everyone pulls from on their own: in the free-threaded build the same task goes to several threads at once. - At program startup, check that "a marker each" really is in effect:
sys._is_gil_enabled()should returnFalse.
The detailed breakdown — with interpreter instructions, iterators that yield numbers outside the range, and two settings that change the result without any race at all — is in the "In depth" tab.
TL;DR
- The GIL never guaranteed that
c.n += 1was atomic. It did something else: a thread gave up control only at certain checkpoints, and in our experiment — a plain attribute, a built-inint, CPython 3.14.10 — there is no such point between reading the attribute and writing it. So the race was there, but the losses were not: 0 out of 4,000,000 in all five runs. That is a result of the experiment, not a promise of the language. - Without the GIL the same code lost 26 to 40% of its increments on 3.14.10t, and
d["n"] += 1lost 56 to 67%. - A check that there was never any protection: put a function call between the read and the write —
c.n = c.n + one(). The GIL-enabled build then loses 50 to 69% — comparable to the free-threaded one. - A check-then-write lazy cache: out of 5,000 keys, with the GIL threads ended up with different objects for the same key in 20–24 cases, without it in 2,646–4,035 on 3.14.10t.
setdefaultremoves the split; a lock removes the wasted work as well. - One iterator shared by four threads hands out elements more than once, and on 3.14.10t a
rangeiterator even yields numbers past the end of the range. That is fixed on 3.15.2t; the duplicates from a list iterator remain. - One extension that has not declared free-threading support (with the
Py_mod_gilslot or aPyUnstable_Module_SetGILcall) turns the GIL back on for the whole process: the two-thread speedup drops from ×1.96 to ×0.92, and the only notice of it is a single warning at import time.
Why know this?
Since 3.14 the free-threaded build has been officially supported, and moving services onto it is no longer an experiment. The usual expectation of such a move is that the code will get faster or stay as it was. In practice, some code changes its result.
What breaks is not new code. It is code that has passed tests and production load for years — counters, lazy caches, work handed out through a shared iterator. It was wrong before, too, but the GIL made its bug rare or outright impossible, and nobody saw it. In our measurement without the GIL the same bug fired in a large share of cases, tens of percent.
Below are five measurements. Each is the same script run on three interpreters (where that makes sense): 3.14.10 with the GIL, 3.14.10t without it, and 3.15.2t, the next version, also without the GIL. The machine has two cores; the measurements use four threads (two in the extension measurement).
The mental model: the GIL never made code atomic
A common belief: as long as there is a GIL, a line of Python runs to completion with no interference from other threads. That is wrong. The GIL guarantees that only one thread executes bytecode at any given moment, not that a thread will finish the line it has started.
A thread does not give up the GIL just anywhere. It checks for a request to
yield in specific instructions: in Python/bytecodes.c the check
(_CHECK_PERIODIC, or in RESUME its variant _CHECK_PERIODIC_IF_NOT_YIELD_FROM)
is part of RESUME (function entry), JUMP_BACKWARD (the jump back to the top
of a loop) and CALL (after a call to a C function); for a Python function the
check fires in RESUME on entry into it. Between two such points a
thread runs without interruption, however many instructions lie between them —
as long as none of them releases the GIL on its own (I/O, waiting on a lock) and
none of them runs someone else's code. Even an innocent-looking instruction can:
LOAD_ATTR on a property, BINARY_OP on a type with its own __add__,
STORE_ATTR after which the old value dies and runs its __del__ — all of these
execute Python code, with its own RESUME and CALL.
So under the GIL, the question "will an increment be lost" was really the
question "is there a checkpoint on the execution path" — for specific code,
specific types and a specific build. That is a property of the CPython
implementation, not a language guarantee: Python does not promise that += is
atomic, with the GIL or without it. Without the GIL the question is a
different one: two threads execute bytecode at the same time on different cores,
and any sequence of several instructions can interleave with another thread's.
The free-threading documentation puts this briefly. The page on the thread
safety of built-in types says in its very first paragraph: When the GIL is enabled, most operations are implicitly serialized
, and further down
it says: Operations that involve multiple accesses, as well as
iteration, are never atomic
.
First: +=, a race nobody could see
Four threads, each adding one to the same attribute a million times. The invariant to preserve is simple: the total equals the number of increments made, that is, 4,000,000. The correct version is known in advance — read, add and write under a single lock:
lock = threading.Lock()
def work():
for _ in range(1_000_000):
with lock:
c.n += 1The lock changes nothing in += itself: it keeps a second thread out of the gap
between the read and the write while the first one is in it — provided every
thread takes the same lock. What it
costs is below, in "The fix and what it costs". Now the version without it — and
why under the GIL it lost nothing.
The conditions of the experiment: c is an instance of an empty class Box,
n is a built-in int, with no properties and no custom __add__ or
__del__; the interpreter is CPython 3.14.10, and the checkpoints are taken
from Python/bytecodes.c at the same tag. Here is what lies between the read
and the write:
1. INCREMENT WITH NO CALL BETWEEN READ AND WRITE
------------------------------------------------
LOAD_ATTR n
LOAD_SMALL_INT
BINARY_OP +=
SWAP
STORE_ATTR n
checkpoints between the read and the write: 0
Five instructions, and under these conditions not one of them checks for a
request to give up the GIL or runs Python code. So in this experiment another
thread has no place to wedge itself in between LOAD_ATTR and STORE_ATTR in
the GIL-enabled build. (The script marks checkpoints by instruction name in the
body of this one function — an illustration of the experiment, not an analysis
of all the code that executes.) And that is exactly what happens:
| operation | 3.14.10, with GIL | 3.14.10t, without GIL | 3.15.2t, without GIL |
|---|---|---|---|
c.n += 1 | 0 in all five runs | 26.1 to 40.4% | 25.2 to 32.9% |
d["n"] += 1 | 0 in all five runs | 56.3 to 67.0% | 28.8 to 67.0% |
c.n = c.n + one() | 49.6 to 69.2% | 51.3 to 57.4% | 38.8 to 53.4% |
Share of increments lost out of 4,000,000 across five runs, bench/free-threading-races/lost_update.py.
The first two rows are what you see when you migrate: code that lost not a
single increment in this measurement loses roughly a quarter to two thirds of
them on 3.14.10t. The losses themselves vary from run to run: by half again for
c.n += 1 on 3.14.10t, by more than double for d["n"] += 1 on 3.15.2t. That is
a result too: a bug like this does not reproduce as a stable number, and a run
with no losses does not prove that losses cannot happen.
The race was always there
The third row is the main point of this section. The form c.n = c.n + one()
does the same thing, but with a call between the read and the write:
2. INCREMENT WITH A CALL BETWEEN READ AND WRITE
-----------------------------------------------
LOAD_ATTR n
LOAD_GLOBAL one + NULL
CALL <- checkpoint
BINARY_OP +
LOAD_FAST_BORROW c
STORE_ATTR n
checkpoints between the read and the write: 1
Once there is a checkpoint, the GIL-enabled build loses 50 to 69% — comparable
to the free-threaded one (51 to 57% on 3.14.10t). The mechanism is this: the
thread read c.n, went into one(), gave up the GIL there at a waiting
thread's request, and while it waited for its turn the other threads kept
adding. Then
the first thread came back and wrote its old value plus one. All the other
threads' work in the meantime vanished in a single write.
So the GIL did not fix this race. It narrowed the window in which the race can
happen down to the places where a thread gives up control — and c.n += 1
simply happened to contain no such place. Any change that adds a call between
the read and the write (logging, a property instead of an attribute, __add__
on a type of your own) brings the race back into the GIL-enabled build too.
The fix and what it costs
Measured by bench/free-threading-races/lost_update.py, block 4: five runs
of four threads each, the losses and the best time of the five.
| build | fix | losses over five runs | best time, s |
|---|---|---|---|
| 3.14.10 | lock around += | 0; 0; 0; 0; 0 | 0.538 |
| 3.14.10 | per-thread counter | 0; 0; 0; 0; 0 | 0.090 |
| 3.14.10t | lock around += | 0; 0; 0; 0; 0 | 1.005 |
| 3.14.10t | per-thread counter | 0; 0; 0; 0; 0 | 0.051 |
| 3.15.2t | lock around += | 0; 0; 0; 0; 0 | 0.873 |
| 3.15.2t | per-thread counter | 0; 0; 0; 0; 0 | 0.049 |
In the 3.14.10t build the lock does eliminate the losses, but four threads queue for a single lock four million times: 1.005 s against 0.538 s for the same lock in the GIL-enabled build. A counter of its own in each thread, summed at the end, is not only correct but also the fastest of all the variants measured: 0.051 s without the GIL against 0.090 s with it. There is no shared mutable state, so there is nothing to contend for. (Part of the gain comes from the fact that in the measurement such a counter is a local variable, with no attribute access.)
Second: check, then act
Lazy caches have been written like this for years:
def get(key):
if key not in cache:
cache[key] = make(key)
return cache[key]Between the check and the write there is a call to make(), so there are
checkpoints even in the GIL-enabled build — for the whole time make() runs.
The window is the same in both builds. The difference is how often a second
thread lands in it. With the GIL that takes a switch. In this measurement the
threads neither wait nor block, so the GIL changes hands at a waiting thread's
request, and that request is timed by the switch interval, 5 ms by default.
The interval is a scheduling target, not a lower bound: a thread that blocks on
I/O or waits on a lock gives up the GIL sooner, and the actual interval can also
be longer. Without the GIL, threads inside the window run at the same time: two threads on different cores check
the key, both miss, both build.
Four threads, 5,000 keys. With an empty make() there is almost no window in
either build. With a make() of a thousand iterations there are 20–24
redundant make() calls with the GIL and 2,682–4,038 without it, on 3.14.10t.
And nearly all of them are keys for which the threads got different
objects — a split: 20–24 with the GIL and 2,646–4,035 without it.
A split is counted on the objects themselves: each thread keeps everything it
got until the end of the run, and only then are the objects compared by id().
Keeping bare id values would not do: an id is unique only for the object's
lifetime, and the address of an object that died after being pushed out of the
cache can go to a new one — then two different objects would look like one.
A split is more dangerous than wasted work. If the cache holds per-key locks —
locks[user_id] — a split means two threads are holding two different locks for
the same user, which means there is no mutual exclusion. And no error shows up:
each lock was acquired properly.
Two fixes, on the widest window:
| fix | redundant make() calls, with GIL | without GIL (3.14.10t) | keys with a split |
|---|---|---|---|
cache.get(key), on a miss cache.setdefault(key, make(key)) | 15–24 | 4,288–4,734 | 0 in both builds |
| lock with a second check | 0 | 0 | 0 in both builds |
bench/free-threading-races/check_then_act.py, five runs each, 5,000 keys.
setdefault is on the list of safe dictionary operations on the thread-safety
page: it inserts only if the key is missing and returns whatever ended up in
the dictionary. After it there is no split, but make() is still called
needlessly — without the GIL that is thousands of redundant calls per 5,000 keys. If building the value is expensive
or has side effects (it opens a connection, writes to a database), you need a
lock: it removes both the split and the wasted work.
Third: one iterator for several threads
A simple way to hand out work to threads is to give them one iterator:
it = iter(tasks)
def worker():
for task in it:
handle(task)With the GIL this works: next() on a built-in iterator is a single operation
in C, and the threads take the elements without duplicates. The free-threading
documentation warns about this case in a section of its own: threads may see duplicate or missing
elements
. The measurement: four threads, a million elements:
| source | 3.14.10, with GIL | 3.14.10t | 3.15.2t |
|---|---|---|---|
iter(range(N)) | no duplicates | 9,207–54,405 duplicates, 954–57,022 values past the end of the range | no duplicates |
iter(list(...)) | no duplicates | 2.32–2.64 million handed out instead of 1 million | 2.30–2.78 million handed out instead of 1 million |
bench/free-threading-races/shared_iterator.py, five runs each.
On 3.14.10t the range iterator produces values that do not exist in the
source at all: range(1_000_000) yielded numbers from a million upwards — up
to 57,022 of them in a run, and not a single negative one. That
is not the "duplicate or missing" the documentation warns about but a race
inside the iterator itself: the next value and the number of remaining items
are stored in two fields that are not updated together. In the CPython tracker
this is cpython#129068; it is fixed in 3.15 and not backported to 3.14 — the
measurement on 3.15.2t is clean.
The list iterator behaves the same way in 3.15 too — exactly as the
documentation warns: a shared iterator without synchronization can hand out
elements more than once. In the measurement each element was received by two or
three of the four threads on average. Work should be handed out through a
queue: the same measurement with queue.SimpleQueue gives exactly a million
with no duplicates on all three builds.
What the free-threaded build guarantees, and what it does not
After three sections about breakage it is easy to decide that everything is dangerous without the GIL. It is not: the built-in containers are protected from the inside. According to the thread-safety page:
- single operations do not corrupt a container:
lst.append(x),lst.pop(),d[key] = value,del d[key],d.pop(key),d.setdefault(key, v),s.add(x); - reading a single element is atomic:
lst[i],d[key],d.get(key),key in d; - “Operations that involve multiple accesses” are never atomic —
d[k] += 1,c.n += 1, check-then-act, iteration while another thread is making changes.
It matters what kind of page this is. In the documentation that matches our
3.14.10 build, the free-threading HOWTO points to it for the guarantees of the
built-in types. These are documented guarantees for specific CPython operations,
not a rule of the Python language, and they do not turn a check–compute–store
sequence into one atomic operation — that is what the sections above are about.
The guarantees come with caveats of their own: dict operations compare keys with
__eq__, and if a key's __eq__ is written in Python, another thread may modify
the dict during the comparison. The HOWTO's advice is to use threading.Lock
and other synchronization primitives where possible rather than rely on the
internal locks of the built-in types.
Fourth: one extension brings the GIL back for the whole process
The free-threaded build has a safety net for C extensions that are not ready to
run without the GIL. An extension has to declare its readiness explicitly, and how depends on the
way the module is initialized: with multi-phase initialization, the Py_mod_gil
slot set to Py_MOD_GIL_NOT_USED; with single-phase initialization, a call to
PyUnstable_Module_SetGIL. If it does not, then with normal GIL selection
(no PYTHON_GIL=0 or -X gil=0) the import will raise a warning and enable the GIL at runtime
.
To show this without third-party packages, the measurement uses two tiny C extensions, both using multi-phase initialization. They differ only in one entry of the slots table and in the module name. Speedup of pure-Python counting with two threads over one, before and after the import:
| import | before the import | after the import |
|---|---|---|
extension with the Py_mod_gil slot | GIL off, ×1.97 | GIL off, ×1.90 |
| extension without the slot | GIL off, ×1.96 | GIL on, ×0.92 |
the same with PYTHON_GIL=0 | GIL off, ×1.88 | GIL off, ×1.93 |
bench/free-threading-races/gil_reenable.py, 3.14.10t, best of five runs.
In the first and third rows the GIL was not switched on, and the difference
between "before" and "after" is within the spread of the measurement: ×1.97 and
×1.90, ×1.88 and ×1.93, in opposite directions. On 3.15.2t the ready extension
gave ×1.82 before the import and ×1.89 after it.
What matters is that the GIL is turned on for the whole process, for the
rest of its life: one dependency somewhere deep in the import tree, and the
service you installed the free-threaded build for runs like an ordinary build,
only roughly 5–10% slower on a single thread (that is how What's New in Python 3.14
estimates the price of the free-threaded build). The only notice of this is a
single RuntimeWarning at import time, which is easy to lose in the startup
logs.
PYTHON_GIL=0 keeps the GIL off. The warning itself points to this way out —
with the caveat at your own risk
. An extension that has not declared its readiness may
have been relying on the GIL to protect its state — with PYTHON_GIL=0 that
protection is gone. So the third row of the table is a diagnostic experiment,
not a fix: an extension that is not ready gets fixed in the extension itself
(declaring support once it is ready) or replaced.
Fifth: same code, different result
The last group of changes is not about races but about two flags whose
defaults are different in the free-threaded build. What's New in Python 3.14
describes each of them in identical words: defaults to true for the free-threaded build and false for the GIL-enabled build
.
| what is checked | 3.14.10, with GIL | 3.14.10t, without GIL |
|---|---|---|
ContextVar in a new thread | (unset) | req-42 |
Decimal(1) / Decimal(3) in a thread started inside localcontext(prec=6) | 0.3333333333333333333333333333 | 0.333333 |
warnings.warn in another thread while the main one holds simplefilter("error") | exception | warning |
bench/free-threading-races/context_flags.py.
The first two rows are thread_inherit_context: a thread starts with a copy of
the context of whoever called start(). That explains both the request_id and
the decimal precision: a setting made in the main thread travels into the
worker. The third is context_aware_warnings: in the GIL-enabled build
catch_warnings changes the filter list shared by the whole process, and one
thread's filter applies to all the others.
Here the free-threaded build behaves more sensibly: the context is inherited,
and filters do not leak between threads. But code that relied on the old
behavior gets a different result without a single race. You can bring the old
behavior back with two options:
-X thread_inherit_context=0 -X context_aware_warnings=0 — with them 3.14.10t
prints exactly what the GIL-enabled build prints.
What to do about it
- Check that the GIL really is off. Log
sys._is_gil_enabled()after the extensions have been loaded, or export it as a metric. With normal GIL selection a single extension that has not declared free-threading support (Py_mod_gilorPyUnstable_Module_SetGIL) turns the GIL on for the whole process, and the only notice is a warning. - Look for read-modify-write on shared state.
+=on an attribute, a dictionary item, a global variable. With the GIL such code lost nothing in the measurement as long as there was no call between the read and the write — but it was not correct even then; without the GIL it loses. - Lazy caches go through
setdefaultor a lock.setdefaultif a redundant build is cheap and has no side effects; a lock with a second check if building is expensive or if the cache holds locks. - Hand out work through a queue, not a shared iterator. An iterator shared by several threads without synchronization hands out elements more than once — on 3.14 and on 3.15 alike.
- Give each thread its own counter. That is not only correct but faster: there is no shared mutable state, so there is nothing to contend for.
- Before migrating, run your tests on the free-threaded build with several
threads. Some of the races in this article do not show up on the
GIL-enabled build at all:
c.n += 1gives zero losses out of four million, five times in a row; others show up in fractions of a percent. And a clean run on the free-threaded build is no proof either: a race does not show up in every run.
What measured this
The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The GIL never guaranteed that
c.n += 1was atomic. It did something else: a thread gave up control only at certain checkpoints, and in our experiment — a plain attribute, a built-inint, CPython 3.14.10 — there is no such point between reading the attribute and writing it. So the race was there, but the losses were not: 0 out of 4,000,000 in all five runs. That is a result of the experiment, not a promise of the language. - Without the GIL the same code lost 26 to 40% of its increments on 3.14.10t, and
d["n"] += 1lost 56 to 67%. - A check that there was never any protection: put a function call between the read and the write —
c.n = c.n + one(). The GIL-enabled build then loses 50 to 69% — comparable to the free-threaded one. - A check-then-write lazy cache: out of 5,000 keys, with the GIL threads ended up with different objects for the same key in 20–24 cases, without it in 2,646–4,035 on 3.14.10t.
setdefaultremoves the split; a lock removes the wasted work as well. - One iterator shared by four threads hands out elements more than once, and on 3.14.10t a
rangeiterator even yields numbers past the end of the range. That is fixed on 3.15.2t; the duplicates from a list iterator remain. - One extension that has not declared free-threading support (with the
Py_mod_gilslot or aPyUnstable_Module_SetGILcall) turns the GIL back on for the whole process: the two-thread speedup drops from ×1.96 to ×0.92, and the only notice of it is a single warning at import time.
In fact
- The GIL guarantees that only one thread executes bytecode at any given moment, not that a thread will finish the line it has started. A thread can give up control at checkpoints — on entry into a function, after a call, at the end of a loop body. In the article's experiment — a plain attribute, a built-in
int, CPython 3.14.10 — there is no such point inc.n += 1between the read and the write, which is why the measurement gives zero losses out of 4,000,000. That explains the experiment but does not make+=atomic: a property or a custom__add__adds a checkpoint. Inc.n = c.n + one()there is one — and the same GIL-enabled build loses 50 to 69% of its increments. - The code in this article was wrong under the GIL too: the race in it is always there, and the GIL only narrowed the window in which it can happen. The lazy cache shows it: with the GIL, threads got different objects for the same key in 20–24 cases out of 5,000 — rarely, but not zero. Without the GIL on 3.14.10t, in 2,646–4,035 cases. The free-threaded build does not create the race; it stops hiding it.
- Single operations on built-in containers are protected by the containers' own locks: the thread-safety page lists
lst.append(x),d[key] = value,d.setdefault(key, v)ands.add(x)as safe from multiple threads. What is not safe are operations made of several accesses:+=, check-then-act, iteration while another thread is making changes. These are documented guarantees for specific operations in CPython 3.14.10, not a rule of the language, and they come with caveats: for example, a key whose__eq__is written in Python can let another thread modify the dict during the comparison. Compound invariants are protected explicitly — the free-threading HOWTO advisesthreading.Lockand other synchronization primitives. - It solves the split: after it, every thread gets the same object for a key — zero keys with a split in both builds. But
make(key)is still evaluated beforesetdefaultis called, and on 3.14.10t, across 5,000 keys, there were 4,288–4,734 redundantmake()calls whose results were thrown away. If building is expensive or has side effects, you need a lock with a second check: in the measurement it has neither a split nor redundant builds. - The import goes through. With normal GIL selection the free-threaded build turns the GIL back on — for the whole process and for the rest of its life — and prints a single
RuntimeWarning. Measured on 3.14.10t: before such an extension is imported, counting with two threads gives ×1.96, after it ×0.92, andsys._is_gil_enabled()returnsTrue. The program still works, but now like an ordinary build. - In 3.14 two flags are on by default only in the free-threaded build:
thread_inherit_contextandcontext_aware_warnings. The same code gives0.3333333333333333333333333333on 3.14.10 and0.333333on 3.14.10t: a thread started insidedecimal.localcontext(prec=6)inherits the precision. The old behavior can be restored with the options-X thread_inherit_context=0 -X context_aware_warnings=0.
What is covered
- Why know this?
- The mental model: the GIL never made code atomic
- First: `+=`, a race nobody could see
- Second: check, then act
- Third: one iterator for several threads
- What the free-threaded build guarantees, and what it does not
- Fourth: one extension brings the GIL back for the whole process
- Fifth: same code, different result
- What to do about it
- What measured this
Take to work
- Check that the GIL really is off after the extensions have been loaded: log
sys._is_gil_enabled()or export it as a metric. With normal GIL selection, importing an extension that has not declared free-threading support (with thePy_mod_gilslot for multi-phase initialization orPyUnstable_Module_SetGILfor single-phase) turns the GIL on for the whole process. - Do not treat
x += 1on a shared object as safe just because it never lost an increment under the GIL: in our measurement the same code lost tens of percent without the GIL, and a run with no losses does not establish correctness. - Write a lazy cache with
setdefaultor a lock with a second check, not as check-then-write: otherwise threads end up with different objects for the same key. - Hand out work to threads through a queue, not a shared iterator: without synchronization the iterator hands out elements more than once, on 3.14 and on 3.15 alike.
- Before moving to the free-threaded build, run your multithreaded tests on that build specifically: some of the races in this article do not show up on the GIL-enabled build at all — zero losses out of four million, five times in a row.
Common misconceptions
“As long as there is a GIL, a line of Python runs atomically.”
The GIL guarantees that only one thread executes bytecode at any given moment, not that a thread will finish the line it has started. A thread can give up control at checkpoints — on entry into a function, after a call, at the end of a loop body. In the article's experiment — a plain attribute, a built-in int, CPython 3.14.10 — there is no such point in c.n += 1 between the read and the write, which is why the measurement gives zero losses out of 4,000,000. That explains the experiment but does not make += atomic: a property or a custom __add__ adds a checkpoint. In c.n = c.n + one() there is one — and the same GIL-enabled build loses 50 to 69% of its increments.
“The free-threaded build breaks correct code.”
The code in this article was wrong under the GIL too: the race in it is always there, and the GIL only narrowed the window in which it can happen. The lazy cache shows it: with the GIL, threads got different objects for the same key in 20–24 cases out of 5,000 — rarely, but not zero. Without the GIL on 3.14.10t, in 2,646–4,035 cases. The free-threaded build does not create the race; it stops hiding it.
“Without the GIL nothing at all is safe: list.append and writing to a dict also need a lock.”
Single operations on built-in containers are protected by the containers' own locks: the thread-safety page lists lst.append(x), d[key] = value, d.setdefault(key, v) and s.add(x) as safe from multiple threads. What is not safe are operations made of several accesses: +=, check-then-act, iteration while another thread is making changes. These are documented guarantees for specific operations in CPython 3.14.10, not a rule of the language, and they come with caveats: for example, a key whose __eq__ is written in Python can let another thread modify the dict during the comparison. Compound invariants are protected explicitly — the free-threading HOWTO advises threading.Lock and other synchronization primitives.
“setdefault fully solves the lazy-cache race.”
It solves the split: after it, every thread gets the same object for a key — zero keys with a split in both builds. But make(key) is still evaluated before setdefault is called, and on 3.14.10t, across 5,000 keys, there were 4,288–4,734 redundant make() calls whose results were thrown away. If building is expensive or has side effects, you need a lock with a second check: in the measurement it has neither a split nor redundant builds.
“If an extension does not support the free-threaded build, the import will fail with an error.”
The import goes through. With normal GIL selection the free-threaded build turns the GIL back on — for the whole process and for the rest of its life — and prints a single RuntimeWarning. Measured on 3.14.10t: before such an extension is imported, counting with two threads gives ×1.96, after it ×0.92, and sys._is_gil_enabled() returns True. The program still works, but now like an ordinary build.
“The free-threaded build differs only in speed; the program's result stays the same.”
In 3.14 two flags are on by default only in the free-threaded build: thread_inherit_context and context_aware_warnings. The same code gives 0.3333333333333333333333333333 on 3.14.10 and 0.333333 on 3.14.10t: a thread started inside decimal.localcontext(prec=6) inherits the precision. The old behavior can be restored with the options -X thread_inherit_context=0 -X context_aware_warnings=0.
Knowledge check
A request counter in a threaded web service: self.count += 1. On the GIL-enabled build there has not been a single discrepancy in a year. What can be said about it on the free-threaded 3.14t build?
Sources & further reading
7 SOURCES
- Python support for free threading (HOWTO), CPython 3.14.10Official documentation. The text at tag v3.14.10, the same version as the measured builds. The Thread safety section: the built-in dict, list and set are protected by internal locks; for the guarantees the section points to the Thread Safety Guarantees page, and it advises using threading.Lock where possible rather than relying on the internal locks. The Iterators section: with a shared iterator, “threads may see duplicate or missing elements”. CPython tag 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Doc/howto/free-threading-python.rst
- Thread Safety Guarantees — built-in types in the free-threaded build, CPython 3.14.10Official documentation. Documented CPython guarantees for specific list, dict and set operations (in v3.14.10 the page lives under Doc/builtins/). Three statements in this article come from here: “When the GIL is enabled, most operations are implicitly serialized”, d.setdefault(key, v) on the list of safe operations, and “Operations that involve multiple accesses, as well as iteration, are never atomic”. The same page carries a caveat: dict operations compare keys with __eq__, and for keys whose __eq__ is written in Python another thread may modify the dict during the comparison. CPython tag 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Doc/builtins/threadsafety.rst
- C API Extension Support for Free Threading (HOWTO)Official documentation. An extension must declare explicitly that it can run without the GIL — with the Py_mod_gil slot or a call to PyUnstable_Module_SetGIL; “otherwise importing the extension will raise a warning and enable the GIL at runtime”.https://docs.python.org/3.14/howto/free-threading-extensions.html
- What's New In Python 3.14 — Free-threaded mode improvementsOfficial documentation. The context_aware_warnings and thread_inherit_context flags: each is described as “defaults to true for the free-threaded build and false for the GIL-enabled build”. The same page gives the price of the free-threaded build on single-threaded code, “roughly 5-10%”.https://docs.python.org/3.14/whatsnew/3.14.html
- Python/bytecodes.c — _CHECK_PERIODIC in RESUME, CALL and JUMP_BACKWARDCPython source code. Tag v3.14.10, the same version as the measured builds. The places where a thread checks for a request to give up the GIL: the _CHECK_PERIODIC micro-op is part of CALL (after a call to a C function) and JUMP_BACKWARD (the end of a loop body), and its variant _CHECK_PERIODIC_IF_NOT_YIELD_FROM is part of RESUME (entry into a function). The LOAD_ATTR, BINARY_OP and STORE_ATTR instructions do not contain it. That is a property of the implementation, not of the language: such an instruction can still run Python code (a property, a custom __add__, __del__ on the value being replaced), and then the checkpoint is inside that code. CPython tag 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Python/bytecodes.c
- sys.setswitchinterval — the thread switch intervalOfficial documentation. The interval sets “the ideal duration of the "timeslices"” allocated to threads, and “the actual value can be higher”. It is a scheduling target, not a minimum time between GIL hand-offs: a thread that blocks on I/O or waits on a lock gives up the GIL earlier.https://docs.python.org/3.14/library/sys.html#sys.setswitchinterval
- cpython#129068 — Race in concurrent iteration over range iteratorsSource. A race in the range iterator: the next value and the number of remaining items are stored in two fields that are not updated together. Fixed on the main branch, that is, in 3.15; not backported to 3.14. This article's measurement confirms it: on 3.14.10t the iterator yields values past the end of the range, on 3.15.2t it does not.https://github.com/python/cpython/issues/129068