Processes, Threads & Tasks

Python: Threads, Processes and the GIL

In standard CPython builds, one lock serialises bytecode execution, so CPU-bound threads do not scale across cores — while I/O-bound threads and asyncio work fine, because the lock is released around blocking calls. Processes have separate interpreters and do scale. Free-threaded builds exist and change this, and which one you have is a property of your build.

▶ Run the lab

The question this answers

The question

Why does adding threads to a CPU-bound Python program not make it faster, and what does?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The same problem in four languages

language-specific· CPython 3.12 for the Python entries; Node 18+ for the JavaScript and TypeScript entries.

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.

Start four CPU-bound chunks concurrently. Three languages get four cores; one needs a different tool. — Run four CPU-bound chunks at the same time and combine the results
C++
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.

JavaScriptNODE.JS
1const workers = chunks.map(c => new Worker('./hash.js', {
2 workerData: c // structured-cloned, not shared
3}))
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.

TypeScriptNODE.JS
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.

PythonCPYTHON
1# threading: four threads, ONE executing bytecode at a time
2with ThreadPoolExecutor(4) as ex: # ~4x slower than you expect
3 totals = list(ex.map(hash_chunk, chunks))
4
5# multiprocessing: four interpreters, four GILs, four cores
6with ProcessPoolExecutor(4) as ex: # actually parallel
7 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.

What actually differs
  • 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 += 1 is still not atomic and still needs a lock.

Choosing between threading, multiprocessing and asyncio

language-specific· CPython 3.12 unless a row says otherwise. Availability and defaults differ across 3.13 and 3.14.

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.

ToolFitsUses many cores?SharingCostCharacteristic failure
asyncioThousands of waiting-bound operationsNo — one thread, one event loopEverything shared; interleaving at every awaitAsync colouring; needs async-native libraries end to endOne CPU-bound coroutine freezes the entire loop
threadingTens to low hundreds of blocking I/O operationsNo for bytecode; yes for time spent inside I/O or GIL-releasing C codeEverything shared, exactly as in any threaded languageA stack per thread; the GIL is released and reacquired around every blocking callAssuming += is atomic; unprotected shared state; oversubscription
multiprocessingCPU-bound workYes — one interpreter and one GIL per processNothing, unless explicitly placed in shared memory or sent through a queuePickling everything across the boundary; ~30 ms+ startup; memory times worker countUnpicklable arguments; fork-in-a-threaded-parent hangs; memory exhaustion
Subinterpreters (PEP 684/734)CPU-bound work needing isolation inside one processYes — per-interpreter GIL since 3.12Nothing by default; a constrained set of shareable objectsA newer, less-supported ecosystem; C extensions must be compatibleExtension incompatibility; sharing rules that differ from both other models
Free-threaded build (PEP 703)CPU-bound threads, on a build that supports itYes — no global lockEverything shared, and now with genuine simultaneous accessA non-default build; some single-thread performance cost; ecosystem still catching upAssuming you have it; data races that the GIL used to make improbable
CPython 3.12: which tool for which work, and what each actually costs.

The GIL is not a lock on your data

language-specific· CPython 3.12. The switch interval is settable via `sys.setswitchinterval`; the bytecode sequence differs slightly across versions but the multi-instruction nature of `+=` does not.

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.

Two CPython threads, one shared counter. One lock held throughout, one increment lost.ILLUSTRATIVE
Invariant · counter equals the total number of completed increments across all threads.
#Thread 1Thread 2GILState
1··GIL acquired by T1counter=41 gil=T1
2LOAD_GLOBAL counter → 41 (onto T1's stack)··counter=41 T1.tos=41 gil=T1
3LOAD_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 boundarycounter=41 T1.tos=42 gil=free
5··GIL acquired by T2counter=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 T1counter=42 T1.tos=42 gil=T1
9STORE_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.
The classic result: eight threads incrementing a shared counter one million times each finish at roughly 5.4 million rather than 8 million, with the exact number varying every run. The GIL prevented simultaneous *bytecode execution* and did nothing about the multi-bytecode read-modify-write. The fix is a 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.
  • multiprocessing gives 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 += 1 is several bytecodes.
  • Single-bytecode operations such as list.append are 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 interpreters module (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.

How it works
  • 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.
  • multiprocessing starts 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.
Interleavings that matter
  • 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.append on the same list: safe in CPython because the append is one bytecode operation. Two threads doing if x not in lst: lst.append(x) are not safe, because that is a sequence.
What it guarantees — and does not
  • 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.
Where contention appears
  • 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.
  • multiprocessing moves 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.
How it fails
  • 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.
  • fork in 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 why spawn is 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.
When it helps
  • asyncio for high-concurrency network work: thousands of connections on one thread with no GIL contention, because there is one thread.
  • threading for modest blocking I/O concurrency, especially against synchronous libraries with no async equivalent.
  • multiprocessing for 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.
When it hurts
  • Threads for pure-Python CPU work: no throughput gain, plus context-switch and GIL-handoff overhead.
  • multiprocessing for small fine-grained tasks, where pickling and startup exceed the work.
  • multiprocessing with 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.
How you would know
  • Wall clock against worker count for the CPU-bound case: flat under threading and near-linear under multiprocessing is 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.setswitchinterval sensitivity: 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.
Complexity it introduces
  • Three concurrency models in one standard library, with different sharing rules, different failure modes and limited interoperability.
  • multiprocessing constrains 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.
Simpler alternatives
  • 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 asyncio if the work is actually I/O-bound, which — in most Python web services — it is.

CPU parallelism simulator

Scaling 100 CPU tasks
100 independent tasks of 20 ms each. The tasks do not share anything — the job around them does.
SIMULATEDA composed model, not a benchmark.

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.

Cores
The serial part is the split and the merge, not the tasks. The sync term is what each worker pays to coordinate with the others. The ceiling is where the memory system stops feeding cores, whatever the core count says.
1 workerdashed = linear speedup16 workers · max 16.0×
ideal
4.0× · 500 ms
Amdahl only
3.48×
modelled
3.28× · 610 ms
efficiency
82%
Where the 4× went
delivered3.3×
lost to the serial part0.5×
lost to sync, switching and bandwidth0.2×
At 4 cores the model delivers 3.28× of a possible 4×, so 109 ms of the run is overhead rather than work. The serial part dominates. Splitting the input, merging the results and the one section that cannot overlap now cost more than the cores save — and no core count fixes that term.
One hundred tasks that share nothing still do not scale linearly, because the job that owns them is not the tasks. Read the gap between the dashed line and the curve as the price of coordination — and note it is charged even when every task is independent.
limited by: serialSIMULATED

counter++ with and without atomicity

counter++ with and without atomicity
The same program on both sides: N threads, one shared counter, one increment each. On the left counter++ is read, add, write. On the right it is a single indivisible instruction. Every schedule of both is enumerated.
20 schedules enumerated on the left, 2 on the right
counter++ — read, add, write
r ← counter
r ← r + 1
counter ← r
schedules
20
lose an update
18
end at 2
2
worst case
1
final counter = 118 · 18 of 20 schedules
final counter = 22 · 2 of 20 schedules
atomic fetch_add — one indivisible step
fetch_add(counter, 1)   # no schedule can cut inside this
schedules
2
lose an update
0
end at 2
2
worst case
2
final counter = 22 · 2 of 2 schedules — the order still varies, the outcome does not
The threads still interleave. Atomicity does not remove the schedules; it removes the points at which a schedule can cut.
The non-atomic version, run 200 times under a random scheduler
runs that produced the right answer59 · 29.5% — a green test suite
runs that lost an update141 · 70.5%
With 2 threads there are 20 schedules of read/add/write and 18 of them — 90.0% — end with a counter smaller than 2. The worst is 1: every thread read 0, every thread computed 1, and the last write erased the rest. And yet 59 of the 200 sampled runs above produced exactly 2. That is why the non-atomic version passes tests. A test does not explore the schedule space, it samples it, and the sampling is biased by whatever the machine happened to be doing. 29.5% green is not 29.5% correct — the invariant is "after k completed increments, counter === k", and it is false in 18 legal schedules whether or not today's run found one. The right-hand column does not test better, it removes the schedules: an atomic read-modify-write has no interior for the scheduler to cut into. That buys correctness for one variable only — atomics compose badly, and two atomic operations in a row are not one atomic operation.
SIMPLIFIEDSchedule counts are exact for this model of the program. counter++ is modelled as three indivisible steps; a real compiler may split it further, and a real CPU may fuse it into one atomic instruction — which is exactly the right-hand column.

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

if (balance >= 100) withdraw(100) — drive it until it overdraws

if (balance >= 100) withdraw(100)
Two withdrawals of 100 from an account holding 100. The check and the debit are separate operations; you decide who runs when.
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
0 schedules tried
balance
0
paid out
100
A decided
withdraw
B decided
Invariant · balance >= 0 — the account is never overdrawn.
#Withdrawal A (100)Withdrawal B (100)State
1rA ← read balance·balance=100 paidOut=0
2if rA >= 100·balance=100 paidOut=0
3debit 100·balance=0 paidOut=100
Balance is 0 and nothing has broken yet. Watch for the shape: both tasks passing step 2 before either reaches step 3. That is check-then-act, and the check is only as good as the instant it was made.
SIMPLIFIEDThe debit itself is modelled as atomic. The bug is the gap between the check and the act — not the arithmetic.

What people believe, and what is true

Claim

Python cannot do concurrency.

Reality

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.

Claim

The GIL makes threaded Python code thread-safe.

Reality

It is released between bytecodes. counter += 1 is several bytecodes, and eight threads incrementing a shared counter reliably lose increments.

Claim

Threads are useless in Python.

Reality

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.

Claim

The GIL was removed in 3.13.

Reality

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.

Claim

Use multiprocessing and the problem goes away.

Reality

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.

Apply it