The GIL, and What It Actually Locks

Python's threads are real OS threads that are not allowed to run Python at the same time. One lock explains why threads help a program that waits and do nothing for a program that computes.

Two threads, one lock on the interpreter

This explorer needs JavaScript: every interleaving, cost and speed-up on it is computed in the page rather than downloaded as an image.

Overview

Two results follow, and almost all of it else about Python threading is downstream of them.

A CPython process has a global interpreter lock, and the rule around it is short: a thread must hold the GIL to execute Python bytecode.

One lock, and what holds it

A program that spends its time waiting gets faster with threads, because a thread that is blocked on a socket, a file or a timer is not running bytecode and has handed the GIL back. Ten threads can wait at once.

A program that spends its time computing does not get faster with threads, because computing *is* running bytecode, and only one thread may. Two threads doing arithmetic take turns, and the total time is what one thread would have taken plus the cost of the switching.

These editors run in a browser interpreter that has no OS threads at all, so Thread.start(), ThreadPoolExecutor and multiprocessing raise here rather than working. That is worth proving rather than asserting:

example_01.pyPython
Output
in this sandbox: can't start new thread
threads alive right now: 1
and this one is: MainThread

So every runnable block on this track shows a method that is real in single-threaded Python — the bytecode an increment compiles to, the lock protocol, the pickle boundary, the Future state machine. Anything that needs a second thread appears as a plain block with the output it gives on CPython, which is flagged where it happens.

Where the switch happens

The GIL is not held for the whole of a function. The interpreter drops and reacquires it at times, and the period is a setting you can read:

example_02.pyPython
Output
switch interval: 0.005 seconds
so a one-second pure-Python loop offers about 200 chances to switch

bytecodes in that loop body: 15
the loop itself: ['FOR_ITER', 'JUMP_BACKWARD']

after setswitchinterval(0.001): 0.001

Five milliseconds is the default, and it is a time interval rather than a bytecode count — that changed in Python 3.2, and older explanations that talk about "every 100 ticks" describe the interpreter as it was before then. A thread runs for up to 5 ms, then the interpreter asks it to drop the GIL so another thread can take it.

Lowering the interval makes switching more responsive and adds cost, since every switch costs work and cache locality. Raising it does the reverse. It is very rarely the right thing to change: if switching cadence is your problem, the shape of the program usually is.

The important word above is *offers*. A switch can only happen between bytecodes, and a single bytecode is atomic with respect to other threads. That is the fact the next page is built on, because it means the safety of a step depends on how many bytecodes it compiles to.

What "releases the GIL" really means

The GIL is released around steps that do not need the interpreter. There are two large categories and knowing which you are in predicts whether threads will help:

OperationGILThreads help?
time.sleepreleasedyes
socket read/write, requests.getreleased while waitingyes
file read/writereleasedyes
hashlib on a large bufferreleasedyes
numpy elementwise maths on a big arrayreleasedyes
a pure-Python for loopheldno
building or walking a dict or listheldno
json.loadsheldno

The pattern is that C code which does not touch Python objects can drop the lock for its duration. That is why a numpy-heavy program sometimes does scale with threads and an the same loop written in Python never does, and it is also why "is this library a thin wrapper over C?" is a practical question rather than a pedantic one.

Two threads on CPU work

This is the measurement that cannot run here, so here it is with the output it gives on CPython:

import time
from concurrent.futures import ThreadPoolExecutor

def work(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

N = 5_000_000

t0 = time.perf_counter()
work(N); work(N)
print(f"one after the other: {time.perf_counter() - t0:.2f}s")

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
    list(pool.map(work, [N, N]))
print(f"two threads:         {time.perf_counter() - t0:.2f}s")
one after the other: 1.18s
two threads:         1.21s

Two threads, two cores available, and the work took slightly *longer* than doing it sequentially. Nothing is broken: the two threads took turns holding the GIL, so the arithmetic was serialised anyway, and the switching added a few per cent. Swap ThreadPoolExecutor for ProcessPoolExecutor in that snippet and the time roughly halves, which is the subject of threads or processes.

Run the same check with time.sleep(1) as the work and the numbers invert fully — two seconds sequentially, one second with two threads — because sleeping releases the lock. Same threads, same pool, opposite result, and the only thing that changed is whether the work needed the interpreter.

Deciding before you commit

Before writing the threaded version, it is worth knowing which side of the table above your program is on, and there is a two-line measurement that answers it. time.perf_counter measures elapsed time; time.process_time measures CPU time really consumed by the process. Their ratio is the answer:

import time

w0, c0 = time.perf_counter(), time.process_time()
run_the_thing()
wall = time.perf_counter() - w0
cpu = time.process_time() - c0
print(f"wall {wall:.2f}s  cpu {cpu:.2f}s  ratio {cpu/wall:.2f}")
a pure-Python loop   wall 0.61s  cpu 0.61s  ratio 1.00
an HTTP request      wall 0.42s  cpu 0.01s  ratio 0.02

A ratio near 1 means the process was computing the whole time: the work is CPU-bound, it was holding the GIL, and threads will do nothing for it. A ratio near 0 means the process was mostly waiting with the lock released, and threads will overlap that waiting almost perfectly.

This one is shown rather than run because time.process_time returns 0 under the browser interpreter — it is not implemented there, so the ratio would read 0.00 for both cases and tell you nothing. On CPython it is the fastest useful measurement in this whole area.

The number also tells you your ceiling in advance. A ratio of 0.7 means 70% of the time is spent holding the GIL, so even infinite threads can only remove the other 30%: that is Amdahl's law with the serial fraction handed to you by a measurement rather than guessed, and it is drawn for all three models on choosing between threads, processes and async.

Why it is there at all

The GIL is easy to present as a historical embarrassment, and it is worth knowing what it buys, because that explains why removing it took twenty years.

It makes reference counting safe without per-object locks. Every Python object carries a count of how many things refer to it, updated constantly — on every assignment, every function call, every list append. Making those updates safe for concurrent threads means either one big lock, or atomic steps on every single one, which costs speed in the single-threaded case that most programs really are.

It also gives C extension authors a guarantee they have relied on for decades: while your extension holds the GIL, no other thread is mutating Python objects below it. A large amount of the scientific stack was written against that promise.

What changed recently is worth tracking. Python 3.12 gave each subinterpreter its own GIL, so separate interpreters in one process can run Python at once. And Python 3.13 ships an test free-threaded build with no GIL at all, which makes reference counting atomic and costs single-threaded speed to do it. Neither is the default yet, and on any interpreter you are likely to be running today the rule at the top of this page still holds.

Where it goes wrong

Reaching for threads to speed up work. The most common mistake, and the GIL means it gives no speed-up at all. CPU-bound work needs processes.

Concluding threads are useless. They are the right tool for waiting, which is what most programs spend their time doing. A web scraper, a file crawler and an API client all scale with threads.

Assuming a C library releases the lock. Many do and some do not, and the documentation often does not say. The only reliable answer is to measure the threaded version against the sequential one.

Tuning the switch interval. It almost never helps, and a lower value increases cost. Change the structure instead.

Sources and next steps

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “One lock, and what holds it”?

  2. What does this module say about “Where the switch happens”?

  3. What does this module say about “What "releases the GIL" really means”?

Cheat sheet

The GIL, and What It Actually Locks

See what Python's GIL actually protects, why threads still speed up I/O-bound programs, and why they do nothing for CPU-bound code.

CONCURRENCY · vizlearn.in/concurrency/the_gil_and_what_it_locks.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.