PyDepth

The GIL Is Not a Lock on Your CPU

Eight threads of pure-Python arithmetic run at 0.98x the speed of one. The same eight threads doing IO run at 7.90x. The GIL is a scheduler, and it has a dial.

5 min read

The GIL is usually described as a lock that stops Python using more than one core. That description gets the consequence right and the mechanism wrong, and the mechanism is where the useful decisions are.

What it actually is

The global interpreter lock protects interpreter state — reference counts, the object allocator, the internal caches — and a thread must hold it to execute bytecode. It is released around anything that blocks: a read, a write, a sleep, a socket wait, a call into a C extension that declares it is safe to. It is also released periodically while a thread is doing nothing but running bytecode, so that another thread gets a turn. That period is the switch interval, it defaults to 5 milliseconds, and it is a settable number.

So the GIL is not a lock on your CPU. It is a lock on the interpreter, released constantly, with a round-robin timer attached. Two of the three things people want from threads survive that; one does not.

The one that does not survive

A thread doing arithmetic in pure Python holds the GIL for its whole time slice. Adding more such threads adds no throughput:

$ python3 experiments/gil/scaling.py
python 3.13.2 on darwin, 12 cores, GIL enabled
switch interval: 5 ms

best of 5 runs per row

CPU-bound: 6,000,000 additions per thread
threads        wall   speedup
1            0.091s      1.00x
2            0.183s      0.99x
4            0.367s      0.99x
8            0.741s      0.98x

IO-bound: 0.25s sleep per thread
1            0.252s      1.00x
2            0.252s      1.99x
4            0.255s      3.95x
8            0.255s      7.90x

The CPU-bound rows do not move — the work is perfectly serialised, and the wall time grows linearly with the number of threads. Eight threads came out at 0.98x the throughput of one, which is 1.00x to within the couple of percent this workload can resolve.

Each row is the best of five runs, and that is not decoration. A single timing of this workload moves by about 8% between consecutive runs of the script, which is four times the effect being reported. An earlier version of the harness divided by a separate one-thread run taken before the loop, and that 8% appeared as a constant 0.92x in every CPU row and read convincingly as the cost of handing the lock around. It was a warm-up artefact.

The IO rows are the same pool doing the same handover and reaching 7.90x on eight threads, because time.sleep releases the lock for its entire duration. Nothing about the thread pool changed between the two halves of that output. Only whether the work holds the GIL.

This is the whole of the practical rule. Threads are for waiting; processes are for computing. A thread that spends its life inside requests, psycopg or open() is doing what threads are for. A thread that spends its life in a for loop over a list is not.

The dial nobody turns

The more interesting number is not throughput but latency, and it is the one the switch interval controls. Put one CPU-bound thread on the interpreter and one thread that wants to wake every millisecond, then measure how late the second one actually is:

$ python3 experiments/gil/switchinterval.py
python 3.13.2 on darwin
one CPU-bound thread, one thread waking every 1 ms

  interval   median late    p95 late    max late   spin rate
     5.0ms        6.52ms      6.55ms      7.04ms       1.00x
     1.0ms        1.52ms      1.53ms      1.56ms       1.05x
     0.2ms        0.52ms      0.53ms      0.58ms       1.06x

Median lateness tracks the interval almost exactly. At the 5 ms default, a thread asking to be woken in 1 ms is woken 6.5 ms late — its own millisecond plus a full slice waiting for the spinner to give the lock back. Drop the interval to 0.2 ms and the same thread is 0.52 ms late. The tail moves with it: p95 sits within hundredths of the median in all three rows, and the max wanders only when something else on the machine takes a turn.

That is a 12x improvement in a latency figure from one line of configuration, and it is invisible in every throughput benchmark, which is presumably why it is rarely mentioned. The last column is what it costs: the spinning thread’s own rate, against its rate at the default. Going from 5 ms to 0.2 ms is twenty-five times as many opportunities to hand the lock over, and the spinner came out at 1.06x — which is to say unchanged, inside the run-to-run noise of this workload rather than measurably slower.

The shape of the problem it solves is specific: a process that is doing something CPU-intensive in Python while also needing to answer something promptly. A metrics thread. A health-check endpoint. A heartbeat to a coordinator that will evict the worker if it goes quiet. All of those are latency problems caused by a throughput-shaped thread, and none of them are fixed by a bigger machine.

What this does not tell you

The measurements above are a standard GIL build of CPython 3.13.2 on a 12-core machine. On a free-threaded build the CPU-bound table is the one that changes, and the switch-interval table stops describing anything at all, because there is no single lock to hand over. That build is not what most production runs today, and it is not what was measured here, so nothing above should be read as a claim about it.

There is also a limit to what the dial can do. The switch interval controls how long a thread holds the lock voluntarily; it has no effect on a thread that holds it inside a long C call that never releases it. A regex against a pathological input, a large pickle.loads, a json.dumps of something enormous — those block every other thread for their full duration regardless of the setting.

Where to stop

Before reaching for sys.setswitchinterval, check whether the CPU-bound work belongs in the process at all. Moving it to a subprocess, a worker pool or a queue removes the contention instead of scheduling around it, and does not require anyone to remember why an unusual interpreter setting is in the startup path.

Reach for the dial when the work genuinely cannot leave — an in-process model, a hot cache that would cost more to serialise than to compute — and when you have a latency number that is bad. Then change it, measure the same number again, and write the reason down next to the line.

Frequently asked

Does this mean threads are useless in Python?
Only for pure-Python CPU work. Threads doing IO, or calling into C extensions that release the GIL, scale close to linearly — the IO measurement here reached 7.90x on eight threads.
Should I lower the switch interval in production?
Only if you have measured a latency problem caused by a CPU-bound thread starving a responsive one, and only after trying to move that work out of the process. It buys latency and costs a little throughput.
What about the free-threaded build?
Everything above was measured on a standard GIL build of 3.13.2, so it says nothing about 3.13t or 3.14t. The CPU-bound row is the one that would change; the switch-interval row would stop existing.
Share

Related posts

Arrow keys to move, Enter to open.