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.
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.ThreadPoolExecutorover 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.
ProcessPoolExecutorormultiprocessinggives 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
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min
- You run ten coroutines concurrently and one of them raises. What happens to the other nine?hardAlso on asyncio5 min
- What problem do virtual threads actually solve, and when do they not help?mediumAlso on concurrency5 min
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on concurrency4 min
- A family plan shares 100 GB across five SIMs with each SIM capped at 30 GB. How does charging enforce both limits at once?hardAlso on concurrency6 min
- A list endpoint that returns fifty orders issues more than a hundred queries, and rewriting it as an async endpoint made it slower. Explain both.hardAlso on asyncio5 min
- Design a producer-consumer pipeline with a bounded buffer. What do you do when the buffer is full?hardAlso on concurrency5 min
- Two customers try to buy the last item at the same time. How do you handle inventory?hardAlso on concurrency6 min