Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

What does the GIL actually prevent, and how do you decide between threads, processes, and asyncio?

The GIL lets only one thread execute Python bytecode at a time, but it is released around blocking I/O and inside many C extensions, so threads still scale I/O-bound work; use processes for pure-Python CPU work and asyncio for very high connection counts.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether you say the GIL serialises bytecode execution rather than vaguely claiming Python cannot do concurrency
  • Whether you know the GIL is released around blocking syscalls and inside some C extensions, which is why threads help I/O-bound code
  • Whether you can state a decision rule keyed on whether the workload is CPU-bound or I/O-bound instead of naming a favourite library
  • Whether you account for the cost of processes, namely pickling arguments and results and having no shared mutable state
  • Whether you recognise that the GIL does not make your code thread-safe, so shared mutable state still needs locks

Answer

What the GIL actually is

The Global Interpreter Lock is a mutex inside the CPython interpreter that guards interpreter state, most visibly the reference counts every object carries. Any thread that wants to execute Python bytecode must hold it, so at any instant exactly one thread is running bytecode in a given interpreter. That is the whole of the restriction, and stating it that precisely is most of the answer. The GIL is an implementation detail of CPython, not a rule of the language, which is why Jython and IronPython never had one.

The consequence people remember is that pure-Python CPU-bound work does not get faster with threads. Four threads each spinning through arithmetic in Python code will finish in roughly the time one thread would take, plus the overhead of the interpreter switching between them every few milliseconds. Adding threads can even make it slightly slower, because you have paid for context switching and lock handoff and bought no parallelism.

Where the GIL is released

The part candidates miss is that the GIL is not held continuously. A thread releases it before entering a blocking operation and reacquires it afterwards, so while one thread is parked in a read, a send, or a database driver's socket wait, the other threads are free to run. This is why ThreadPoolExecutor genuinely speeds up fetching two hundred URLs or querying a dozen tables: the waiting overlaps, and waiting is where the time goes.

The same escape hatch exists in C. An extension that has released the GIL can run compute in parallel on real threads, and the numerical stack uses this deliberately. NumPy releases it around many array operations, and compression, hashing, and image libraries commonly do the same. So "Python cannot use more than one core" is wrong twice: it can for I/O, and it can for work that happens below the bytecode layer.

The decision rule

Classify the workload before choosing a primitive.

  • I/O-bound, modest concurrency (tens to low hundreds of in-flight operations): threads. concurrent.futures.ThreadPoolExecutor over ordinary blocking libraries is the least invasive option, and it works with the synchronous drivers you already have.
  • I/O-bound, very high concurrency (thousands of connections), or a codebase already async: asyncio. Coroutines cost far less memory than an OS thread apiece and the scheduling is cooperative, so you avoid thread-stack bloat. The price is that every library on the hot path must be async-aware, because one blocking call stalls the entire event loop.
  • CPU-bound in pure Python: processes. ProcessPoolExecutor or multiprocessing gives each worker its own interpreter and its own GIL, so the cores are actually used. Arguments and results must be picklable and are copied across a pipe, so this pays off for coarse-grained tasks and loses badly for fine-grained ones.
  • CPU-bound inside a library that releases the GIL: threads are fine, and cheaper than processes. Vectorising with NumPy or pushing the loop into a compiled extension often beats parallelising the Python-level loop at all.
# Same interface, opposite constraint. Choose by workload, not by taste.
import requests
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor(max_workers=32) as pool:      # waits overlap: good
    pages = list(pool.map(requests.get, urls))

# expensive_pure_python must be importable at module level: the callable is
# pickled to reach the worker, so a locally defined function raises here.
with ProcessPoolExecutor() as pool:                   # separate GILs: real cores
    hashes = list(pool.map(expensive_pure_python, big_inputs))

The trap

The trap is treating the GIL as a correctness guarantee. Because only one thread runs bytecode at a time, candidates conclude that shared state is implicitly safe, and it is not. The interpreter can switch threads between bytecodes, and counter += 1 compiles to a load, an add, and a store, so two threads can read the same value and both write back the same increment. Any invariant spanning more than one bytecode needs a Lock, a Queue, or an atomic primitive, exactly as it would in a language with no GIL at all. The GIL protects the interpreter's internals; it protects nothing you wrote.

The secondary trap is reaching for multiprocessing reflexively for anything slow. If the slowness is network latency you have added process startup, pickling, and memory duplication to solve a problem threads would have solved for free.

Where CPython is heading

PEP 703 introduced a free-threaded build of CPython that removes the GIL entirely. It shipped as an experimental option in 3.13 and, under PEP 779, became an officially supported build in 3.14, still alongside the default GIL-enabled build rather than replacing it. Mentioning it shows you follow the runtime, but frame it accurately, because the widely repeated version of this fact is now out of date. It remains opt-in, and extensions must be rebuilt and declare Py_GIL_DISABLED or they force the GIL back on. Single-threaded code still pays a penalty, but in 3.14 that penalty is roughly 5 to 10 percent, and it comes from atomic reference counting and lost container optimisations — not from a disabled specialising interpreter, which PEP 659 specialisation was re-enabled for in the free-threaded build in 3.14. Do not present it as the current default.

The GIL serialises bytecode, not waiting. Threads for I/O, processes for pure-Python CPU work, asyncio for very high connection counts, and locks regardless.

Likely follow-ups

  • If the GIL serialises bytecode, why does a pure-Python CPU-bound function still get slower when you add threads?
  • How would you call a blocking library from inside an asyncio event loop without stalling it?
  • Why is a race condition still possible on `counter += 1` even with the GIL held?
  • When would you reach for a C extension or a subinterpreter instead of multiprocessing?

Related questions

Further reading

gilthreadingmultiprocessingasyncioconcurrency