Filter concepts by levelShowing all levels.

Python · Concurrency and Parallelism

GIL

Concepts
2

CPython's Global Interpreter Lock — what it is, why it exists, and its measured, opposite effects on CPU-bound versus I/O-bound threaded code, which is the practical payoff of understanding it at all.

Python overview

Understanding and applying the GIL

What the lock actually is and why CPython needs it, then the concrete, measured decision it drives: which concurrency tool to reach for.

What the GIL is, and why it exists

coreintermediate

The GIL is a mutex inside CPython letting only one thread execute Python bytecode at a time — even on multiple cores, two threads never run Python code simultaneously. It exists because reference counting is not thread-safe without it.

Think of it as

Picture CPython's memory management as a shared ledger every object's reference count is written into. Without one rule about who can write at a time, two threads updating the same count simultaneously could corrupt it — an object freed too early, or never freed at all. The GIL is that rule: only the thread currently holding it may touch the ledger, so no two threads ever race on a refcount update.

python
import sys

# True on a standard build; False on a free-threaded (PEP 703) 3.13+ build
print(sys._is_gil_enabled())

What we're doing: Confirm this interpreter runs the standard, GIL-enabled build (not a free-threaded 3.13+ build) before drawing any conclusions from timing comparisons.

confirm_gil_status.pypython
import sys

print(f"Python: {sys.version.split()[0]}")
print(f"GIL enabled: {sys._is_gil_enabled()}")
4
sys._is_gil_enabled() is the reliable way to check — do not assume from the Python version number alone, since free-threading is opt-in even on 3.13+.
Output
Python: 3.14.3
GIL enabled: True

Why this works: This confirms the environment every other timing claim in this section was measured on: a standard CPython 3.14.3 build with the GIL enabled, not the experimental free-threaded build. The CPU-bound-vs-I/O-bound timings in the paired concept only make sense with this confirmed — a free-threaded build would show CPU-bound threading actually scale, which is the whole point PEP 703 exists to demonstrate.

Assuming the GIL means Python threads are pointless

Wrong

python
# "Threads don't help in Python because of the GIL, so never use them"
# -- this throws away a real, common use case

Better

python
# The GIL blocks CONCURRENT BYTECODE EXECUTION, not concurrent I/O.
# A thread blocked on time.sleep(), a network call, or disk I/O
# releases the GIL while waiting -- other threads run during that gap.
import threading, time

def fetch_simulated(delay):
    time.sleep(delay)   # GIL released here -- other threads proceed

threads = [threading.Thread(target=fetch_simulated, args=(0.3,)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
# Total time is close to 0.3s, not 0.6s -- the two sleeps overlapped

What you see: Concluding "the GIL means threads never help" and avoiding threading.Thread / ThreadPoolExecutor entirely, including for I/O-bound work where it would have helped.

Why: The GIL only blocks concurrent execution of Python BYTECODE. Blocking operations that hand control to the OS — time.sleep(), socket reads, file I/O — release the GIL for the duration of the wait, letting another thread run. The GIL's real constraint is specifically on CPU-bound Python code, covered in the paired concept.

One GIL per process — only its holder runs Python bytecode

4 threads

in one process

the GIL

held by exactly one thread at a time

1 thread runs bytecode

the other 3 wait their turn

  1. 4 threads — in one process
  2. the GIL — held by exactly one thread at a time
  3. 1 thread runs bytecode — the other 3 wait their turn

Where the GIL is, and is not, a CPython implementation detail

Where the GIL is, and is not, a CPython implementation detail
ClaimTrue?
The GIL is part of the Python language specificationfalse — it is specific to CPython, the reference implementation
Every CPython object's reference count needs GIL protection to update safelytrue — this is the reason the GIL exists
Threads never make progress on Python bytecode without holding the GILtrue — by definition, since it gates bytecode execution
The GIL prevents all forms of concurrency in Pythonfalse — I/O-bound concurrency still works well; see the paired concept

Remember: The GIL is one mutex per CPython process, letting only one thread run bytecode at a time. A CPython detail, not a language rule.

See also: reference counting · gil effects and when to use what

The GIL in practice — CPU-bound vs. I/O-bound, and what to reach for

coreintermediate

For CPU-bound work, threads do not speed things up — the GIL lets only one run bytecode at a time. For I/O-bound work, threads genuinely help, since waiting releases the GIL. Multiprocessing for CPU parallelism; threading/async for waiting.

Think of it as

One toll booth (the GIL) on a one-lane bridge (the CPU). If every car (thread) needs to be actively driving across, adding more cars just means more waiting at the booth — no faster than one car at a time, plus the overhead of switching. But if each car is happy to pull over and wait somewhere else for a while (I/O), the booth is free to let another car through during that wait — that is where threading genuinely wins in Python.

python
# Decision: what is the code actually doing while it "works"?
#   Busy computing (loops, math, parsing)     -> CPU-bound  -> multiprocessing
#   Waiting (network, disk, sleep, DB query)   -> I/O-bound   -> threading / asyncio

What we're doing: Measure the same CPU-bound function under serial execution, 2 threads, and 2 processes; then measure the same I/O-bound function under serial execution and 2 threads — to see the GIL's real, opposite effects on each.

gil_cpu_vs_io.pypython
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

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

def io_heavy(delay):
    time.sleep(delay)
    return delay

if __name__ == "__main__":
    N = 5_000_000

    t0 = time.perf_counter()
    cpu_heavy(N)
    cpu_heavy(N)
    t_1thread = time.perf_counter() - t0

    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=2) as ex:
        list(ex.map(cpu_heavy, [N, N]))
    t_2threads = time.perf_counter() - t0

    print(f"CPU-bound: serial={t_1thread:.3f}s, 2 threads={t_2threads:.3f}s")

    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=2) as ex:
        list(ex.map(cpu_heavy, [N, N]))
    t_2procs = time.perf_counter() - t0
    print(f"CPU-bound: 2 processes={t_2procs:.3f}s")

    DELAY = 0.3
    t0 = time.perf_counter()
    io_heavy(DELAY)
    io_heavy(DELAY)
    t_io_serial = time.perf_counter() - t0

    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=2) as ex:
        list(ex.map(io_heavy, [DELAY, DELAY]))
    t_io_threads = time.perf_counter() - t0

    print(f"I/O-bound: serial={t_io_serial:.3f}s, 2 threads={t_io_threads:.3f}s")
22
Both cpu_heavy calls compete for the same GIL across 2 threads -- neither makes real progress while the other holds it, plus the interpreter now pays the cost of switching between them.
23
The threaded CPU-bound run measured SLOWER than plain serial execution -- the GIL-switching overhead outweighs any benefit, because there was never any real parallelism to gain.
34
time.sleep() hands control to the OS and releases the GIL for its duration -- the second thread's sleep can run during the first thread's wait.
35
Both 0.3s sleeps overlap almost completely, so 2 threads finishes in close to 0.3s total, not 0.6s.
Output
CPU-bound: serial=0.641s, 2 threads=0.757s
CPU-bound: 2 processes=0.470s
I/O-bound: serial=0.601s, 2 threads=0.302s

Why this works: This is the GIL's effect made concrete with real numbers, not just asserted: threading a CPU-bound task made it slower (0.641s -> 0.757s) because the two threads fought over the same GIL with no real parallelism to show for it — only overhead. The same work under multiprocessing genuinely sped up (0.470s) because each process has its own GIL. And threading the I/O-bound task nearly halved the time (0.601s -> 0.302s), because sleep() releases the GIL, letting both threads' waits overlap for real.

Reaching for multiprocessing on I/O-bound work "to be safe"

Wrong

python
from concurrent.futures import ProcessPoolExecutor

# Fetching 20 URLs -- this is I/O-bound (waiting on the network),
# but using processes anyway "since more parallelism sounds better"
with ProcessPoolExecutor(max_workers=20) as ex:
    results = list(ex.map(fetch_url, urls))
# Pays real process-startup and pickling overhead for no CPU benefit --
# the bottleneck was never CPU time in the first place

Better

python
from concurrent.futures import ThreadPoolExecutor

# Threads are the right tool -- no process-startup cost, no pickling,
# and the GIL is released during every network wait anyway
with ThreadPoolExecutor(max_workers=20) as ex:
    results = list(ex.map(fetch_url, urls))

What you see: The multiprocessing version is slower to start and uses far more memory (20 separate interpreters) than the threaded version, for identical results, on work that was never CPU-bound to begin with.

Why: Multiprocessing's advantage is sidestepping the GIL for CPU-bound work — but I/O-bound work already releases the GIL during every wait, so there is no GIL contention to sidestep. Processes only add real, unnecessary cost here: interpreter startup per worker and pickling every argument and result across the process boundary.

Match the tool to the workload

CPU-bound?

busy computing, not waiting

multiprocessing

separate GILs — real parallelism

I/O-bound?

mostly waiting on network/disk

  1. CPU-bound? — busy computing, not waiting
  2. multiprocessing — separate GILs — real parallelism
  3. I/O-bound? — mostly waiting on network/disk

Measured effect of the GIL, this machine, Python 3.14.3

Measured effect of the GIL, this machine, Python 3.14.3
WorkloadSerialThreaded (2)Multiprocess (2)
CPU-bound (5M-iteration sum, twice)0.641s0.757s — slower0.470s — faster
I/O-bound (0.3s sleep, twice)0.601s0.302s — ~2x fasternot applicable

Together

python
# CPU-bound: reach for multiprocessing
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=2) as ex:
    results = list(ex.map(cpu_heavy_function, [n1, n2]))

# I/O-bound: reach for threading (or asyncio)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as ex:
    results = list(ex.map(fetch_from_network, [url1, url2]))

Remember: CPU-bound: reach for multiprocessing — threading rarely helps, can be slower. I/O-bound: reach for threading/asyncio — a blocked thread releases the GIL.

See also: what the gil is · cpu bound vs io bound workloads · multiprocessing basics

Advertisement