The Global Interpreter Lock is a mutex in CPython that allows only one thread to execute Python bytecode at a time. Even on a multi-core machine, a multithreaded pure-Python program runs its bytecode on one core at a time — threads take turns holding the lock.
It exists to make CPython's memory management (especially reference counting) simple and fast: without it, every refcount update would need its own lock. The interpreter releases the GIL periodically and around blocking I/O so other threads can run.
import threading
# both threads exist, but the GIL serializes their bytecode execution
def work():
total = 0
for _ in range(10_000_000): # CPU-bound — holds the GIL
total += 1
t1 = threading.Thread(target=work)
t2 = threading.Thread(target=work)
t1.start(); t2.start(); t1.join(); t2.join() # ~no speedup vs one thread
Why it matters: the GIL is a CPython implementation detail (not in the language spec, and absent in Jython/the free-threaded 3.13+ build) that shapes when threads do and don't help.
Threads share one process and one memory space, so they're cheap to create and share data directly — but in CPython they're serialized by the GIL. Processes each have their own interpreter and memory, so they run on separate cores in true parallel, bypassing the GIL — at the cost of higher startup overhead and needing to serialize (pickle) data to communicate.
from threading import Thread
from multiprocessing import Process
Thread(target=fn) # shared memory, GIL-bound, light
Process(target=fn) # separate memory, real parallelism, heavier
Threads communicate through shared objects (guarded by locks); processes
communicate through Queue, Pipe, or shared-memory primitives because they don't
share state.
Rule of thumb: threads for I/O-bound concurrency, processes for CPU-bound parallelism.
For CPU-bound work, threads are constantly executing bytecode, so they're always contending for the GIL — only one runs at a time, and you get no parallel speedup (often a small slowdown from lock contention and context switches).
For I/O-bound work, a thread that's waiting on the network, disk, or a database is blocked outside the interpreter — and CPython releases the GIL during blocking I/O. So other threads run while one waits, giving real concurrency.
import requests, threading
def fetch(url):
requests.get(url) # blocks on the network -> GIL released here
# 10 threads overlap their waiting time -> much faster than sequential
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
Rule of thumb: if your bottleneck is waiting, use threads (or asyncio); if it's computing, use processes to get past the GIL.
A race condition occurs when two threads access shared mutable state and the
result depends on timing. Even x += 1 is not atomic — it's read, add, write,
and a thread can be switched out mid-sequence, so updates get lost.
A lock (threading.Lock) creates a critical section: only the thread
holding it can proceed, the rest wait, so the read-modify-write happens atomically.
Using with lock: guarantees the lock is always released, even on exceptions.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread in here at a time
counter += 1 # now safe from lost updates
Beware over-locking: acquiring multiple locks in different orders can cause
deadlock. Rule of thumb: guard every access to shared mutable state, keep
critical sections small, and prefer thread-safe queue.Queue for handoff.
Both come from concurrent.futures and share the same API — submit /
map returning Future objects — so you can swap them with one line. The
difference is the worker type: ThreadPoolExecutor runs tasks in threads
(shared memory, GIL-bound) and ProcessPoolExecutor runs them in separate
processes (true parallelism, pickled args).
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# I/O-bound: many network/file waits -> threads
with ThreadPoolExecutor(max_workers=20) as ex:
results = list(ex.map(download, urls))
# CPU-bound: heavy computation -> processes (one per core)
with ProcessPoolExecutor() as ex:
results = list(ex.map(crunch_numbers, datasets))
Use ThreadPoolExecutor for I/O-bound tasks (downloads, DB calls) where waiting dominates, and ProcessPoolExecutor for CPU-bound tasks to use all cores — remembering its arguments and return values must be picklable.
Rule of thumb: pick the executor by the bottleneck (waiting vs computing), and let
concurrent.futures handle the pool lifecycle and result collection.
Because CPython manages memory with reference counting, and incrementing / decrementing refcounts from multiple threads without protection would corrupt them. The GIL is a single coarse lock that makes the interpreter and refcounting thread-safe cheaply, keeps single-threaded code fast, and makes integrating C extensions simple.
object -> refcount field
thread A: incref # not atomic on their own
thread B: decref # GIL serializes these -> no corruption
Rule of thumb: the GIL is a deliberate trade — simpler, faster single-threaded execution and easy C interop, at the cost of multi-core threading for pure Python.
A thread releases the GIL periodically (driven by sys.setswitchinterval,
~5ms by default) so others can run, and around blocking I/O — file/socket
reads, time.sleep, etc. Well-written C extensions (NumPy, hashlib,
zlib) also release it during heavy native work, allowing real parallelism.
import sys
sys.getswitchinterval() # ~0.005 seconds between forced switches
Rule of thumb: pure-Python CPU loops hold the GIL; I/O and GIL-releasing C code let other threads make progress — which is why threads help those cases.
Python 3.13 introduced an experimental free-threaded build (PEP 703) that can run without the GIL, enabling true multi-core threading. It's opt-in (a separate build), still maturing, and can slow single-threaded code. The standard CPython you get by default still has the GIL.
python3.13 # GIL build (default)
python3.13t # free-threaded ("t") build, no GIL, experimental
Rule of thumb: for the foreseeable future, design as if the GIL exists; the free-threaded build is promising but not yet the default.
A Lock can be acquired once; if the same thread tries to acquire it
again before releasing, it deadlocks. An RLock (reentrant lock) can
be acquired multiple times by the thread that holds it, requiring an equal
number of releases — useful for recursive code or nested method calls that
each lock.
import threading
lock = threading.RLock()
def outer():
with lock:
inner() # re-acquires same lock -> fine with RLock
def inner():
with lock:
... # would deadlock with a plain Lock
Rule of thumb: use Lock by default; reach for RLock only when the same
thread legitimately needs to re-acquire a lock it already holds.
A deadlock happens when threads wait on each other's locks in a cycle — classically thread A holds lock 1 and wants lock 2 while thread B holds lock 2 and wants lock 1. Neither proceeds. Prevent it by always acquiring locks in a consistent global order, using timeouts, or holding fewer locks.
# both threads acquire in the SAME order -> no cycle possible
def transfer(a, b):
first, second = sorted((a, b), key=id)
with first.lock:
with second.lock:
...
Rule of thumb: impose a fixed lock-acquisition order everywhere, and keep critical sections small.
Individual operations on list, dict, and queue.Queue are protected by
the GIL so they won't corrupt, but compound operations are not atomic.
x += 1 or "check then act" sequences can interleave and race. Use a Lock
for multi-step updates, or queue.Queue, which is fully synchronized for
producer/consumer hand-off.
from queue import Queue
q = Queue() # thread-safe put/get, no manual lock needed
counter = 0
def inc():
global counter
counter += 1 # NOT atomic: load, add, store can interleave
Rule of thumb: rely on the GIL only for single C-level operations; guard
any multi-step update with a lock or use queue.Queue.
A daemon thread is a background thread the program does not wait for at
exit — when all non-daemon threads finish, the interpreter shuts down and
kills daemons abruptly (no cleanup, finally may not run). Non-daemon
threads, by contrast, keep the process alive until they complete.
import threading
t = threading.Thread(target=loop, daemon=True)
t.start() # won't block program exit
Rule of thumb: use daemon threads for fire-and-forget background work, but never for tasks that must finish or flush state cleanly on shutdown.
join() blocks the calling thread until the target thread finishes
(or an optional timeout elapses). It's how you wait for results / ensure
completion before moving on. Without joining, the main thread may continue or
exit while workers are still running.
threads = [threading.Thread(target=work, args=(i,)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join() # wait for all to complete
print("all done")
Rule of thumb: start all threads first, then join them all, so they run concurrently rather than one-at-a-time.
threading.local() creates an object whose attributes are per-thread —
each thread sees its own independent values on the same object. It's used to
hold thread-specific state (a DB connection, a request context) without
passing it around or locking shared state.
import threading
ctx = threading.local()
def handler():
ctx.user = get_current_user() # private to this thread
...
Rule of thumb: use thread-locals to avoid sharing mutable state at all — no shared data means no locks and no races.
No. The GIL guarantees only that a single bytecode operation won't be
interrupted mid-way — it does not make multi-step logic atomic. A thread
can be suspended between bytecodes, so counter += 1 (load, add, store) can
still race and lose updates.
# with two threads each doing 100000 increments,
# the final value is often LESS than 200000 without a lock
counter = 0
def work():
global counter
for _ in range(100_000):
counter += 1 # interleaves -> lost updates
Rule of thumb: never assume "the GIL protects me" — guard shared mutable state with explicit locks regardless.
More Concurrency & Parallelism interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.