# 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.

- Published: 2026-08-04
- Tags: cpython, concurrency, performance
- Source: https://pydepth.com/blog/the-gil-is-not-a-lock-on-your-cpu/
- Language: en-US
- Author: Elliot Sayer

---
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.
