The GIL, threads and processes
What the GIL does and does not protect, why threads help I/O but not CPU-bound Python, when to reach for processes, and how executors order their results.
After this lesson you can
- Explain what the GIL is and which workloads threads speed up
- Show why the GIL does not make n += 1 thread-safe, and protect shared state with a Lock
- Choose between threads, processes and asyncio for a given workload
- Get results back in input order or in completion order from an executor, and keep one failure from losing the rest
CPython has a global interpreter lock (GIL): within one process, only one thread executes Python bytecode at any moment. Threads take turns; the interpreter asks the running thread to hand over every few milliseconds (the switch interval), and a thread also releases the GIL whenever it blocks — sleeping, waiting on a socket, reading a file — and inside C code written to release it.
Two consequences carry most interview answers:
- I/O-bound work (HTTP calls, database queries, disk) speeds up with threads, because a waiting thread does not hold the GIL.
- CPU-bound pure-Python work (parsing, number crunching in loops) does not: the threads only take turns on one core. Use processes.
Python 3.13 added an optional, experimental free-threaded build (PEP 703) that runs without the GIL, and 3.14 made that build officially supported. It is still a separate build you choose to install: the default CPython, and the Python 3.12 these examples run on, has the GIL.
The GIL is not a lock for your data
The GIL keeps the interpreter's own structures consistent; it promises
nothing about yours. n += 1 is several bytecodes — load n, add,
store n — and the language does not guarantee they run as one step.
If a thread is paused after its load and another thread updates n
meanwhile, the first thread's store writes back a stale value and that
update is lost. Any read-modify-write on shared state needs a
threading.Lock, used with with lock:.
The bug is easy to miss. Current CPython only switches threads at
certain points in the bytecode, so an unlocked += 1 loop often loses
nothing in a quick test (it lost nothing in the run below), and on the
free-threaded build nothing serialises the threads at all. The example
forces the bad interleaving with a barrier, so the lost update shows
every time, and then puts the same steps under a lock.
import contextlibimport disimport sysimport threading count = 0 def increment(): global count count += 1 def two_deposits(use_lock): account = {"balance": 100} lock = threading.Lock() both_read = threading.Barrier(2, timeout=1) def deposit(): with lock if use_lock else contextlib.nullcontext(): seen = account["balance"] # read try: both_read.wait() # until the other thread has read too except threading.BrokenBarrierError: pass # it never could: the lock kept it out account["balance"] = seen + 10 # write back threads = [threading.Thread(target=deposit) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() return account["balance"] def run(): box = [0] def unlocked(): for _ in range(100_000): box[0] += 1 threads = [threading.Thread(target=unlocked) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() return { "switch_interval_seconds": sys.getswitchinterval(), "bytecode_for_increment": [i.opname for i in dis.get_instructions(increment)], "unlocked_quick_test": box[0], "two_deposits_without_lock": two_deposits(False), "two_deposits_with_lock": two_deposits(True), }Executors
concurrent.futures gives threads and processes the same interface.
ThreadPoolExecutor(max_workers=n) runs submitted callables on at most
n threads. executor.map(fn, items) returns results in input
order, whichever finishes first; submit() plus as_completed()
yields futures in completion order (futures that had already
finished when you call it come first, in no particular order). A
task's exception is re-raised when you ask for its result — and with
map that ends the iteration, so the results after the failed one are
never handed to you. To keep every result, submit each task and
handle each future's exception on its own.
import threadingimport timefrom concurrent.futures import ThreadPoolExecutor, as_completed def fetch(name, seconds): time.sleep(seconds) # stands in for a network call return name def fetch_after(name, earlier): earlier.result() # finish only once `earlier` has finished return name def run(): jobs = [("slow", 0.3), ("fast", 0.1), ("medium", 0.2)] with ThreadPoolExecutor(max_workers=3) as pool: in_order = list(pool.map(lambda job: fetch(*job), jobs)) # completion order fixed by construction: fast, then medium, then slow fast = pool.submit(fetch, "fast", 0.3) medium = pool.submit(fetch_after, "medium", fast) slow = pool.submit(fetch_after, "slow", medium) by_completion = [f.result() for f in as_completed([slow, fast, medium])] met = threading.Barrier(3, timeout=5) def meet(name): met.wait() # returns only once three threads are waiting at once return name together = list(pool.map(meet, ["a", "b", "c"])) got = [] try: for value in pool.map(int, ["1", "x", "3"]): got.append(value) except ValueError as e: got.append(f"ValueError: {e}") return { "map": in_order, "as_completed": by_completion, "three_threads_at_once": together, "map_with_a_failure": got, }Processes
multiprocessing and ProcessPoolExecutor run work in separate
processes, each with its own interpreter, its own GIL and its own
memory, so CPU-bound work really runs in parallel. The price: starting
processes costs time, and a process pool pickles every task (the
function and its arguments) and every result to move them between
processes. A function is pickled by its importable name, so it must be
defined at module level — not a lambda, not a nested function — and
the arguments must be picklable. A global changed in a child process is
invisible to the parent; results come back through return values,
queues or pipes.
import multiprocessing as mpfrom concurrent.futures import ProcessPoolExecutor counter = 0 def bump(queue): global counter counter += 100 # changes the child's copy only queue.put(counter) # the way back to the parent def run(): ctx = mp.get_context("fork") queue = ctx.Queue() child = ctx.Process(target=bump, args=(queue,)) child.start() from_child = queue.get(timeout=5) child.join() def in_pool(fn, arg): with ProcessPoolExecutor(max_workers=1, mp_context=ctx) as pool: try: return pool.submit(fn, arg).result(timeout=10) except AttributeError as e: # how 3.12 reports a local function return f"AttributeError: {e}" return { "child_reported": from_child, "parent_counter": counter, "child_exit_code": child.exitcode, "pool_with_builtin_abs": in_pool(abs, -7), "pool_with_lambda": in_pool(lambda x: x * 2, 7), }For thousands of concurrent network waits, asyncio (next lesson)
usually beats threads: one thread, no locks around every shared value,
and very cheap tasks.
Common mistakes
- Adding threads to speed up a CPU-bound loop. With the GIL, the total time stays about the same, or gets worse.
- Assuming the GIL makes
counter += 1or check-then-set on a dict safe. Nothing promises the read and the write happen together; use a lock, and do not take a passing test as proof. - Collecting results with
as_completedand then treating them as being in input order. - Passing a lambda to a process pool, or expecting a child process to update the parent's globals.
What interviewers ask
- What is the GIL, and why does Python still have threads? One thread runs bytecode at a time per process; threads still overlap waiting, so they suit I/O-bound work.
- Threads, processes or asyncio for this job? CPU-bound: processes. Blocking I/O with a moderate number of tasks: threads. Many concurrent network waits with async libraries: asyncio.
- Is
x += 1atomic in Python? Not guaranteed: it is a load, an add and a store, and another thread's update can land in between; CPython rarely splits them in practice, which is why the bug hides in tests.
Exercise · Pro
A bounded pool that keeps order and survives failures
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade