The question this answers
Why does adding threads to a CPU-bound Python program not make it faster, and what does?
Two Python workloads: hashing 200 000 documents with SHA-256 in pure Python (CPU-bound), and fetching 200 000 URLs (I/O-bound). Both on an eight-core machine.
Under threading: the entire module namespace and every object, exactly as in any threaded language. Under multiprocessing: nothing, unless placed in a Value, an Array, a SharedMemory block or passed through a Queue.
Every document is hashed exactly once and every hash is recorded against the right document id — regardless of which concurrency mechanism is used and regardless of how the interpreter interleaves bytecode.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The same problem in four languages
Start four CPU-bound chunks and expect them to run at once. Three of these languages do exactly that. One of them does not, and the reason is worth stating precisely rather than in slogans: in standard CPython builds a single global interpreter lock serialises the execution of Python bytecode, so at most one thread is executing Python bytecode at any instant. Threads exist, they are real OS threads, they are scheduled by the kernel, and they take turns holding one lock.
What that does *not* say matters as much. It does not say Python has no concurrency — threading, asyncio and multiprocessing are all real and all useful. It does not say Python cannot use multiple cores — multiprocessing does, because each process has its own interpreter and therefore its own lock. And it does not say the lock is held during blocking operations: CPython releases it around I/O syscalls, and well-written C extensions such as NumPy release it around long computations, which is why a threaded program calling into NumPy can genuinely use several cores.
The last qualification is "in standard builds". PEP 703 introduced a free-threaded build without the global lock: experimental in CPython 3.13, officially supported (though still not the default) in 3.14. On such a build, CPU-bound threads do scale. Whether you have one is a property of the interpreter binary you are running, not of the Python language, and it must be checked rather than assumed.
1std::vector<std::future<uint64_t>> fs;2for (int i = 0; i < 4; ++i)3 fs.push_back(std::async(std::launch::async,4 hash_chunk, chunks[i]));5uint64_t total = 0;6for (auto& f : fs) total += f.get();Four OS threads, four cores, genuine simultaneous execution. Nothing in the language serialises them, and nothing protects you either: any shared mutable access here would be a data race and therefore undefined behaviour.
1const workers = chunks.map(c => new Worker('./hash.js', {2 workerData: c // structured-cloned, not shared3}))4const totals = await Promise.all(5 workers.map(w => once(w, 'message'))6)Real parallelism, but only through worker threads: each worker is a separate JS agent with its own event loop and heap. Data is cloned across the boundary unless you use a SharedArrayBuffer. Doing this on the main thread instead would run the four chunks sequentially and freeze everything else.
1const pool = new WorkerPool(os.availableParallelism())2const totals: bigint[] = await Promise.all(3 chunks.map(c => pool.run<bigint>(c))4)5// Promise.all schedules; the pool is what makes it parallel.Identical runtime semantics to JavaScript — the types add no concurrency guarantees. Worth including because the mistake it invites is universal: Promise.all over CPU-bound work is concurrency with no parallelism at all.
1# threading: four threads, ONE executing bytecode at a time2with ThreadPoolExecutor(4) as ex: # ~4x slower than you expect3 totals = list(ex.map(hash_chunk, chunks))4 5# multiprocessing: four interpreters, four GILs, four cores6with ProcessPoolExecutor(4) as ex: # actually parallel7 totals = list(ex.map(hash_chunk, chunks))CPython 3.12: the ThreadPoolExecutor version runs four threads that take turns holding the GIL, so the CPU work is serialised plus switching overhead. The ProcessPoolExecutor version gets four separate interpreters and four cores, at the cost of pickling the chunks across the boundary.
- C++, Java, Go and Rust map threads to cores directly; standard CPython serialises bytecode execution through one lock, so its threads do not add CPU throughput.
- JavaScript reaches parallelism through separate agents (worker threads or Web Workers) with message passing, not through shared-memory threads — closer to CPython's multiprocessing than to C++ threads.
- CPython releases the GIL around blocking I/O and inside C extensions that opt in, which is why threaded I/O and threaded NumPy both scale while threaded pure-Python loops do not.
- Free-threaded CPython builds (PEP 703: experimental in 3.13, supported in 3.14) remove the global lock and make CPU-bound threads scale — check
sys._is_gil_enabled()rather than assuming either way. - The correctness rules do not change with any of this: CPython threads still interleave at bytecode boundaries, so
counter += 1is still not atomic and still needs a lock.
Choosing between threading, multiprocessing and asyncio
Three tools, three shapes of work, and the choice is close to mechanical once the work is classified. asyncio for large numbers of waiting-bound operations; threading for modest numbers of waiting-bound operations, especially when the libraries you must call are blocking and synchronous; multiprocessing for CPU-bound work.
The row people get wrong is the second. Threads *are* useful in CPython, and the reason is precisely the qualification above: the GIL is released around blocking calls. Two hundred threads waiting on sockets are two hundred concurrent requests, and only one of them holds the lock at a time because only one of them is executing bytecode at a time — the rest are inside a syscall with the lock released. For I/O work, the GIL is close to irrelevant.
The other genuine option, newer and worth knowing about: per-interpreter GILs. PEP 684 gave subinterpreters their own lock in 3.12, and PEP 734 exposed them through a standard-library interpreters module in 3.14. That gives isolation and CPU parallelism within one process, at the cost of an isolation model closer to multiprocessing than to threading.
| Tool | Fits | Uses many cores? | Sharing | Cost | Characteristic failure |
|---|---|---|---|---|---|
asyncio | Thousands of waiting-bound operations | No — one thread, one event loop | Everything shared; interleaving at every await | Async colouring; needs async-native libraries end to end | One CPU-bound coroutine freezes the entire loop |
threading | Tens to low hundreds of blocking I/O operations | No for bytecode; yes for time spent inside I/O or GIL-releasing C code | Everything shared, exactly as in any threaded language | A stack per thread; the GIL is released and reacquired around every blocking call | Assuming += is atomic; unprotected shared state; oversubscription |
multiprocessing | CPU-bound work | Yes — one interpreter and one GIL per process | Nothing, unless explicitly placed in shared memory or sent through a queue | Pickling everything across the boundary; ~30 ms+ startup; memory times worker count | Unpicklable arguments; fork-in-a-threaded-parent hangs; memory exhaustion |
| Subinterpreters (PEP 684/734) | CPU-bound work needing isolation inside one process | Yes — per-interpreter GIL since 3.12 | Nothing by default; a constrained set of shareable objects | A newer, less-supported ecosystem; C extensions must be compatible | Extension incompatibility; sharing rules that differ from both other models |
| Free-threaded build (PEP 703) | CPU-bound threads, on a build that supports it | Yes — no global lock | Everything shared, and now with genuine simultaneous access | A non-default build; some single-thread performance cost; ecosystem still catching up | Assuming you have it; data races that the GIL used to make improbable |
The GIL is not a lock on your data
The most expensive misconception in Python concurrency is that the GIL makes threaded code safe. It does not. It serialises bytecode execution, and it is released between bytecodes — every 5 ms by default, and at any bytecode boundary where the interpreter checks. So a thread can lose the lock in the middle of a statement, because a statement is many bytecodes.
counter += 1 on a module global compiles to roughly four instructions: load the global, load the constant, add, store the global. A thread switch between the add and the store loses an increment. The schedule below shows it, and the empirical version is a familiar exercise: eight threads each incrementing a shared counter a million times, ending well short of eight million.
The genuine guarantee is narrower and worth knowing exactly: a single bytecode operation is not interrupted, which is why list.append(x) and dict[k] = v are individually atomic in CPython. That is an implementation detail rather than a language promise, it does not extend to a sequence of operations, and it is one of the properties a free-threaded build changes. Code that relies on it needs a lock, and always did.
| # | Thread 1 | Thread 2 | GIL | State |
|---|---|---|---|---|
| 1 | · | · | GIL acquired by T1 | counter=41 gil=T1 |
| 2 | LOAD_GLOBAL counter → 41 (onto T1's stack) | · | · | counter=41 T1.tos=41 gil=T1 |
| 3 | LOAD_CONST 1; BINARY_OP add → 42 (not yet stored) | · | · | counter=41 T1.tos=42 gil=T1 |
| 4 | · | · | switch interval elapsed (5 ms default) → GIL released at a bytecode boundary | counter=41 T1.tos=42 gil=free |
| 5 | · | · | GIL acquired by T2 | counter=41 T1.tos=42 gil=T2 |
| 6 | · | LOAD_GLOBAL counter → 41 | · | counter=41 T2.tos=41 gil=T2 |
| 7 | · | BINARY_OP add → 42; STORE_GLOBAL counter = 42 | · | counter=42 gil=T2 |
| 8 | · | · | GIL released; reacquired by T1 | counter=42 T1.tos=42 gil=T1 |
| 9 | STORE_GLOBAL counter = 42 (its stale computed value) | · | · | counter=42 ✕ Two increments completed; counter advanced by one. T1 stored a value computed from a read that predates T2's store. |
threading.Lock around the increment, or itertools.count / a per-thread counter summed at the end — the same reasoning as in any other language.Key points
- In standard CPython builds one lock serialises Python bytecode execution: at most one thread executes bytecode at a time.
- Therefore CPU-bound threads do not add throughput in standard builds — but threads, asyncio and multiprocessing are all real concurrency and all useful.
- The GIL is released around blocking I/O and by C extensions that opt in, which is why threaded I/O and threaded NumPy scale.
multiprocessinggives real CPU parallelism because each process has its own interpreter and its own GIL, at the cost of pickling and startup.- Free-threaded builds (PEP 703) remove the global lock: experimental in 3.13, supported in 3.14, not the default. Check, do not assume.
- The GIL is not a lock on your data. It is released between bytecodes, and
counter += 1is several bytecodes. - Single-bytecode operations such as
list.appendare atomic in CPython as an implementation detail — not a language guarantee, and not a substitute for a lock over a sequence. - Per-interpreter GILs (PEP 684, 3.12) and the stdlib
interpretersmodule (PEP 734, 3.14) are a third path: isolation plus core use inside one process.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Every thread must hold the GIL to execute Python bytecode; the interpreter acquires it on entry to the evaluation loop.
- • The holder releases it after a switch interval (5 ms by default) at a bytecode boundary, or immediately before a potentially blocking call.
- • Blocking calls — socket reads, file I/O,
time.sleep— release it for their duration, so waiting threads do not hold it and I/O concurrency works normally. - • C extensions may release it around long computations via
Py_BEGIN_ALLOW_THREADS, which is how NumPy and similar libraries achieve threaded parallelism. - •
multiprocessingstarts separate interpreters, each with its own GIL, so bytecode executes genuinely simultaneously and everything crossing the boundary is pickled. - • A free-threaded build removes the global lock entirely and relies on finer-grained locking and biased reference counting inside the interpreter.
- • T1 loads, adds, stores; T2 then loads, adds, stores — the correct schedule, and the one that runs in every quick test.
- • T1 loads and adds; the switch interval elapses; T2 loads, adds and stores; T1 stores its stale value — one increment lost, as above.
- • Two threads both blocked in
socket.recv: neither holds the GIL, both wait concurrently, and the I/O genuinely overlaps. This is why threaded I/O works. - • Two processes hashing separate chunks: two interpreters, two GILs, two cores, genuine simultaneous execution — and no shared memory to race on.
- • Two threads calling
list.appendon the same list: safe in CPython because the append is one bytecode operation. Two threads doingif x not in lst: lst.append(x)are not safe, because that is a sequence.
- • The GIL guarantees that Python-level object internals — reference counts, container structures — are not corrupted by concurrent bytecode execution. That is what it is for.
- • It guarantees each individual bytecode completes uninterrupted, which makes some single-operation methods atomic as an implementation detail.
- • It does not guarantee statement-level atomicity, and
+=,if x in d: d[x] += 1, and every check-then-act pattern are multi-bytecode. - • It does not prevent race conditions, deadlocks, or any logical concurrency bug. It prevents interpreter-internal corruption, nothing more.
- • Releasing the GIL around I/O guarantees that blocking threads do not hold up others; it guarantees nothing about how the OS schedules them afterwards.
- • A free-threaded build guarantees none of the incidental atomicity, so code that relied on it can break there while remaining correct on a standard build.
- • The GIL itself is the contention point for CPU-bound threads: eight threads on eight cores contend for one lock and get one core's worth of throughput plus switching overhead.
- • Convoy behaviour is real: threads repeatedly acquire and release, and a CPU-bound thread can starve an I/O thread that has just become ready — an effect studied and partially mitigated in CPython 3.2 onwards.
- •
multiprocessingmoves contention outside the process: memory, pickling bandwidth, and shared external resources such as database connections. - • Application-level locks contend exactly as they would in any language; the GIL neither adds nor removes that.
- • Lost update on a shared counter or dictionary entry, because
+=and check-then-act are multi-bytecode. - • Adding threads to a CPU-bound program and getting slightly worse performance, because the work is serialised and the switching is not free.
- • A blocking C call that never releases the GIL, freezing every other thread in the process for its duration.
- •
forkin a process that already has threads: the child inherits a lock held by a thread that does not exist, and hangs on its first allocation. This is whyspawnis becoming the safer default. - • Unpicklable arguments to a process pool — a lambda, an open connection, a local class — failing at dispatch rather than at definition.
- • Memory exhaustion from a process pool, since each worker carries a full interpreter plus imported modules.
- •
asynciofor high-concurrency network work: thousands of connections on one thread with no GIL contention, because there is one thread. - •
threadingfor modest blocking I/O concurrency, especially against synchronous libraries with no async equivalent. - •
multiprocessingfor CPU-bound batch work with coarse units, where pickling and startup are amortised. - • Threads plus a GIL-releasing C extension (NumPy, some compression and crypto libraries) for numerical work, which genuinely parallelises.
- • A free-threaded build for CPU-bound threaded workloads, once its ecosystem support is verified for your dependencies.
- • Threads for pure-Python CPU work: no throughput gain, plus context-switch and GIL-handoff overhead.
- •
multiprocessingfor small fine-grained tasks, where pickling and startup exceed the work. - •
multiprocessingwith large shared read-only data, where every worker gets its own copy and memory multiplies. - • Mixing threads and
fork— a recurring source of hangs that reproduce only under load. - • Relying on incidental atomicity, which is fragile across versions and absent on free-threaded builds.
- • Wall clock against worker count for the CPU-bound case: flat under
threadingand near-linear undermultiprocessingis the direct confirmation. - • Process CPU utilisation: a threaded CPU-bound Python program pegs at roughly one core no matter how many threads exist.
- •
sys._is_gil_enabled()on 3.13+ to determine what build you are actually running, rather than assuming. - •
sys.setswitchintervalsensitivity: if changing it changes your results, you have a GIL-handoff issue rather than an algorithmic one. - • Pickle bytes per task for a process pool, against the work per task — the direct measure of whether the boundary is in the right place.
- • Three concurrency models in one standard library, with different sharing rules, different failure modes and limited interoperability.
- •
multiprocessingconstrains what can cross the boundary to picklable objects, which shapes function signatures throughout the codebase. - • Start-method differences between platforms and versions mean code can work on Linux and hang on macOS, or vice versa.
- • A free-threaded build changes the safety properties of existing code, so a build flag becomes a correctness-relevant configuration item.
- • Mixing asyncio with threads requires explicit bridging —
run_in_executor,asyncio.to_thread,call_soon_threadsafe— and getting it wrong deadlocks quietly.
- • Move the hot loop into a C extension, Cython, or a library that releases the GIL — often a larger win than any concurrency change.
- • Vectorise with NumPy so the loop happens inside optimised native code that already releases the lock.
- • Run several single-threaded processes behind a supervisor — Gunicorn workers, one per core — which is what most Python web deployments do and is simpler than in-process parallelism.
- • Use a different tool for the compute phase entirely and keep Python as the coordinator; this is why the data ecosystem looks the way it does.
- • Use
asyncioif the work is actually I/O-bound, which — in most Python web services — it is.
CPU parallelism simulator
Amdahl’s term, a synchronisation term, an oversubscription term and a bandwidth ceiling, each one a knob you can switch off. Real curves have more causes than four and are rarely this smooth. There is no ideal core count to read off this chart.
counter++ with and without atomicity
r ← counter r ← r + 1 counter ← r
fetch_add(counter, 1) # no schedule can cut inside this
Two increments, twenty schedules: find the one that loses an update
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
if (balance >= 100) withdraw(100) — drive it until it overdraws
balance = 100
withdraw(amount): # both tasks run this concurrently
b = read(balance) # 1
if b >= amount: # 2 <- decided on a value that may already be stale
debit(amount) # 3| # | Withdrawal A (100) | Withdrawal B (100) | State |
|---|---|---|---|
| 1 | rA ← read balance | · | balance=100 paidOut=0 |
| 2 | if rA >= 100 | · | balance=100 paidOut=0 |
| 3 | debit 100 | · | balance=0 paidOut=100 |
What people believe, and what is true
Python cannot do concurrency.
Python has threads, coroutines and processes, and all three are used at large scale. What standard CPython cannot do is execute Python bytecode on several cores in one process.
The GIL makes threaded Python code thread-safe.
It is released between bytecodes. counter += 1 is several bytecodes, and eight threads incrementing a shared counter reliably lose increments.
Threads are useless in Python.
The GIL is released around blocking I/O, so threads give real I/O concurrency. They are the standard answer for modest concurrency against synchronous libraries.
The GIL was removed in 3.13.
A free-threaded *build* was added as experimental in 3.13 and made officially supported in 3.14. The default build still has the GIL, and which one you are running is a property of your binary.
Use multiprocessing and the problem goes away.
CPU parallelism arrives; pickling cost, startup cost, memory multiplication and the loss of shared memory arrive with it. See [[processes]].
Go deeper
Overview
Standard CPython runs one thread of Python bytecode at a time. Use asyncio or threads for waiting, processes for computing.
Practical
Classify the work. I/O-bound: asyncio for thousands, threads for tens. CPU-bound: processes, or push the loop into native code that releases the lock. Then lock your shared state anyway, because the GIL does not.
Advanced
The GIL is a memory-safety mechanism for interpreter internals that acquired a reputation as a concurrency policy. Removing it (PEP 703) does not make existing threaded code faster for free — it makes previously improbable data races reachable, which is why the free-threaded build is an opt-in with an ecosystem migration behind it.