Python GIL & concurrency
What the GIL actually locks
The GIL (Global Interpreter Lock) is a single mutex inside CPython. To run Python bytecode, a thread must hold it, and only one thread can hold it at a time. So even on a 16-core box, your Python threads take turns executing bytecode. They are real OS threads, scheduled by the kernel, but the interpreter funnels them through one lock.
Two things the GIL does not do. It does not stop a thread from running C code or waiting on the operating system: a thread releases the GIL before a blocking call (reading a socket, a file, time.sleep) and reacquires it after. And it is a CPython implementation detail, not part of the language. Jython and (in its own way) the new free-threaded build behave differently.
Choosing threads, processes, or asyncio
The choice comes down to one question: is the work I/O-bound or CPU-bound?
- I/O-bound (network calls, disk, database queries): the program spends its time waiting, and the GIL is released during every wait. Use
threading/ThreadPoolExecutor, orasynciofor very high connection counts. The waits overlap and you get real concurrency. - CPU-bound (parsing, math, image work in pure Python): the work holds the GIL the whole time, so threads serialize and you get no speedup. Use
multiprocessing/ProcessPoolExecutor. Each process has its own interpreter and its own GIL, so they run on separate cores in parallel.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# many slow HTTP calls -> threads, they overlap while waiting
with ThreadPoolExecutor(max_workers=32) as pool:
results = list(pool.map(fetch_url, urls))
# heavy pure-Python computation -> processes, one GIL each
with ProcessPoolExecutor() as pool:
results = list(pool.map(crunch, chunks))
The cost of processes is real: separate memory, and arguments and results are pickled across the boundary. Passing huge objects back and forth can erase the parallelism you bought.
The GIL does not make your code thread-safe
Wrong "The GIL serializes everything, so I don't need locks."
The GIL guarantees one thread runs bytecode at any instant. It does not guarantee that a whole statement finishes before another thread runs. counter += 1 is three steps under the hood (load, add, store), and the interpreter can hand the GIL to another thread between them. Two threads read the same value, both add one, and an update is lost. Shared mutable state still needs a threading.Lock. The race conditions and mutex mechanics from concurrency-fundamentals apply here unchanged. The GIL narrows when a switch can happen; it does not remove the need for the lock.
What the GIL protects and why it exists
CPython manages memory with reference counting: every object carries a count, incremented and decremented as names bind and drop, freed at zero. Those counter updates happen constantly and would corrupt under concurrent access. Making each refcount operation individually atomic (a locked instruction or per-object lock) would tax every single object touch, including the single-threaded case that is most programs. The GIL is the cheap alternative: one lock around the interpreter, so refcounts and other internal state (the free lists, the current frame) are safe by construction. That framing is what interviewers want when they ask "why does the GIL exist". It is a performance tradeoff for the common case, not an accident.
How GIL switching works
Pre-3.2, a thread gave up the GIL every 100 bytecode instructions. Since 3.2 it is time-based: a thread holds the GIL until a 5 ms switch interval elapses (default, read/set via sys.setswitchinterval), at which point a request flag is set and the running thread drops the GIL at the next bytecode boundary.
import sys
sys.getswitchinterval() # 0.005
sys.setswitchinterval(0.001) # hand off sooner; more switching overhead
Two consequences worth carrying into an interview. First, the boundary is between bytecodes, so a multi-bytecode operation like x += 1 can be interrupted mid-sequence, which is why the GIL never gave you atomicity. Second, a CPU-bound thread and an I/O-bound thread compete badly: the CPU thread keeps reacquiring the GIL, and the I/O thread's latency suffers. Long-running C work sidesteps all of this by releasing the GIL: NumPy, hashlib, zlib, and compression libraries wrap their heavy loops in Py_BEGIN_ALLOW_THREADS, so threads calling into them do run on multiple cores.
The free-threaded build
PEP 703 added a build with no GIL. It shipped experimental in 3.13 as python3.13t, and 3.14 promoted the free-threaded build to officially supported but still optional (PEP 779); the default build on every version keeps the GIL.
Wrong "no-GIL Python is just faster." Removing the GIL means refcounting has to become thread-safe on its own (biased and deferred reference counting), which cost about 40% single-threaded overhead in 3.13, trimmed toward 5 to 10% by 3.14. And code that was accidentally safe because the GIL hid its races now exposes those bugs. The win is genuine parallel threads for CPU-bound Python without the pickling tax of multiprocessing; the price is per-thread overhead and a C-extension ecosystem that has to be audited for thread safety.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.