Python Concurrency: Understanding GIL, Threading, and Multiprocessing from First Principles
In-depth guide to Python Concurrency: Master the GIL, understand Threading vs Multiprocessing, optimize I/O vs CPU workloads, and backend architecture.

If you’re starting out with Python backend engineering, you’ll inevitably hit three terms in technical interviews:
Threading, Multiprocessing, and the GIL.
And with them comes a cluster of confusing questions:
“If Python has the GIL, can threads run in parallel at all?”
“If threading is crippled by the GIL, why not just use multiprocessing for everything?”
“Does one process correspond to one CPU core?”
“If Python 3.13+ goes free-threaded (no GIL), will multiprocessing become obsolete?”
Textbook definitions simply say: “The GIL is a mutex that prevents multiple native threads from executing Python bytecodes at once.” But that definition won’t help you design a backend system that handles thousands of requests per second.
Let’s deconstruct Python concurrency from hardware cores, OS primitives, and CPython runtime internals to build a clean, accurate mental model.
Part 1: Foundations (The Mental Model)
To avoid confusion, we must separate three distinct layers: Process, Thread, and CPU Core.
1. Process vs. Thread vs. CPU Core
- Process: An independent executing program managed by the OS, with its own virtual memory space and its own CPython interpreter instance. A Process is the parent entity — it can spawn many child Threads inside itself.
- Thread: A thread of execution living inside a parent Process. Threads in the same Process share that Process’s heap memory. One Process can spawn
nchild Threads. - CPU Core: The physical hardware unit that actually executes instructions. The OS scheduler distributes both Threads and Processes onto Cores.
Fundamental Rule: A Process is NOT a CPU Core.
A Process is an OS-level container; a Core is hardware. The OS decides which Core runs which Thread, moment by moment.
The Threading model — 1 parent Process, n child Threads, n Cores:
When you call threading.Thread(...), the current Python Process acts as the parent and spawns child Threads inside it. The OS scheduler is free to place each Thread on a different CPU Core at the same time.
Caveat (detailed in Part 2): the OS really does place those Threads on different Cores — but in standard CPython the GIL lets only one Thread execute Python bytecode at any instant. So the extra Cores pay off for I/O waits and C-extension work, not for pure-Python computation.
The Multiprocessing model — n parent Processes, n Cores:
When you call multiprocessing.Process(...), you start n independent Processes — each with its own memory and its own GIL. The OS scheduler spreads those n Processes across n CPU Cores in true parallelism.
2. What is the GIL? The Single Microphone in the Room
If the OS is capable of running multiple threads across multiple cores, why can’t standard CPython execute CPU-bound Python bytecode in parallel?
The answer is the GIL (Global Interpreter Lock).
Imagine a meeting room with speakers (Threads) sitting around a table. Everyone wants to speak (execute Python bytecode). But there is only one single microphone (the GIL) in the room.
To speak, a thread must hold the microphone. Even if you have multiple loudspeakers (CPU Cores) installed in the room, only the voice of the person holding the microphone gets amplified at any given millisecond.
3. Why Does the GIL Exist?
The GIL was not an accident; it was an intentional design decision made by Guido van Rossum in the 1990s:
- Simple Memory Management via Reference Counting: CPython tracks object lifetimes with reference counting (
PyObject->ob_refcnt). Without a lock, concurrent threads incrementing/decrementing reference counts would suffer race conditions, leading to memory leaks or premature deallocation crashes. - Fast & Easy C-Extensions: C/C++ extension authors (NumPy, SciPy, etc.) didn’t have to worry about thread-safety for internal data structures, which fueled the explosive growth of the Python ecosystem.
- Maximum Single-Threaded Speed: Avoiding thousands of fine-grained locks made single-threaded Python significantly faster.
Part 2: The Investigation (CPU-bound vs. I/O-bound)
Knowing how the GIL works, let’s address the big question: Is Python Threading useless?
No, absolutely not. Threading is immensely effective for I/O-bound tasks.
1. I/O-bound: When the GIL Voluntarily Yields
Consider a standard backend scenario: Making 100 HTTP requests to external APIs or running 100 queries against PostgreSQL.
During the vast majority of time spent waiting on network sockets (200ms) or disk I/O, the CPU is idle. CPython is engineered so that: Right before performing blocking I/O (network socket operations, file I/O, time.sleep()), the running thread immediately RELEASES the GIL.
While Thread 1 is waiting on network packets, other threads grab the GIL in turns and dispatch their own requests. Total wall time drops from 20 seconds down to 0.3 seconds!
2. Benchmark Experiment: The Numbers
Here is an experiment comparing execution models on an 8-core machine:
import time
import requests
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# 1. I/O-bound task (HTTP call / wait)
def io_task(url):
resp = requests.get(url)
return len(resp.content)
# 2. CPU-bound task (Heavy calculations)
def cpu_task(n):
count = 0
for i in range(n):
count += i * i
return count
Actual benchmark results across 10 tasks:
| Strategy | 10 I/O-bound Tasks (500ms API) | 10 CPU-bound Tasks (100M iterations) |
|---|---|---|
| Sequential (Single-thread) | ~ 5.20s | ~ 12.40s |
10 Threads (ThreadPoolExecutor) |
~ 0.58s (9x Faster) 🚀 | ~ 12.80s (Slower due to GIL contention!) 🔴 |
10 Processes (ProcessPoolExecutor) |
~ 0.65s (Higher RAM cost) | ~ 2.10s (6x Faster on 8 Cores) ⚡ |
Takeaway:
- For I/O-bound workloads: Threading delivers massive speedups with minimal memory footprint.
- For CPU-bound workloads: Threading fails because of GIL contention. You must use Multiprocessing (or native C/Rust extensions) to distribute work across CPU cores.
Part 3: The Diagnosis (Common Pitfalls & Fallacies)
Pitfall 1: “Why Not Use Multiprocessing for Everything?”
A common line of thinking: “If Multiprocessing bypasses the GIL and gives real parallelism, why don’t we just abandon Threading altogether?”
Because multiprocessing carries heavy costs:
- Memory Footprint Inflation: Each process is a completely isolated CPython instance with duplicate memory space and imported modules. 100 threads might consume ~20MB; 100 processes can easily consume 4GB - 8GB RAM, risking Out Of Memory (OOMKilled) crashes.
- IPC & Pickling Serialization Overhead: Because processes do not share memory, passing data between them (
multiprocessing.Queue,Pipe) requires serializing (Pickling) Python objects into raw bytes on the sender and deserializing (Unpickling) on the receiver. For large data payloads (dataframes, images), serialization latency can outweigh computation savings!
Pitfall 2: “The GIL Makes Python Code Thread-Safe!”
This is the most dangerous misconception in Python concurrency.
The GIL protects CPython’s internal C state, NOT your application’s logic.
CPython can switch threads every 100 bytecode instructions or after sys.getswitchinterval() (default: 5ms).
Consider this snippet:
import threading
counter = 0
def increase():
global counter
for _ in range(100_000):
counter += 1 # ❌ NOT THREAD-SAFE!
threads = [threading.Thread(target=increase) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Final Counter: {counter}")
# Result: 341209 (Instead of 500,000!)
counter += 1 compiles down into 4 distinct bytecode instructions: LOAD_GLOBAL, LOAD_CONST, BINARY_OP, and STORE_GLOBAL. If a thread switch occurs between loading and storing, another thread’s update is overwritten. You still must protect shared state using explicit threading.Lock().
Part 4: The Resolution (Decision Matrix & Free-threading)
1. The Concurrency Decision Matrix
Use this rulebook when architecting Python systems:
| Workload Type | Key Characteristics | Optimal Tool | Recommended Libraries |
|---|---|---|---|
| High I/O Concurrency | Thousands of idle/waiting connections, realtime API, microservices | Asyncio (Single-threaded Event Loop) | asyncio, FastAPI, httpx, aiohttp |
| Simple / Legacy I/O | File downloads, third-party API batch calls in synchronous code | Threading (Multi-threading) | concurrent.futures.ThreadPoolExecutor |
| CPU Calculations | Image resizing, data compression, cryptography in pure Python | Multiprocessing (Multiple Processes) | concurrent.futures.ProcessPoolExecutor, celery |
| Matrix Math / ML | Number crunching, tensor operations, array processing | C/C++ Extensions (Release GIL internally) | numpy, pandas, polars, torch |
2. The Future: Free-threaded Python (PEP 703 & Python 3.13+)
Starting in Python 3.13, CPython introduced an experimental Free-threaded build (no-GIL) under PEP 703.
In a free-threaded runtime:
- Global reference counting is replaced by Mimalloc / Biased Reference Counting.
- Multiple threads inside a single process can execute CPU bytecode in true parallelism across multiple CPU cores.
Does Free-threading Kill Multiprocessing?
No. Multiprocessing will remain essential because of Process Isolation:
- Crash Containment: In a multi-threaded process, a single memory fault or segfault crashes the entire application. In multiprocessing, a crashed worker leaves the master and sibling workers untouched.
- Resource Sandboxing: Operating system cgroups can enforce strict RAM/CPU quotas on a per-process basis.
- Production Web Servers (Gunicorn / Uvicorn): Production deployments will continue using Multi-Process Master-Worker architectures to achieve zero-downtime reloads and automatic fault recovery.
Final Mental Model Summary
The 3 Core Rules:
- Process ≠ Core: 1 Process (parent) holds
nThreads (children) sharing one heap, and the OS can spread thosenThreads overnCores — just as it spreadsnindependent Processes overnCores. Cores are hardware; processes and threads are OS abstractions placed onto them. - The GIL only bottlenecks CPU-bound bytecode: Threading remains fast for I/O-bound tasks because CPython releases the GIL during network and disk operations.
- Multiprocessing provides Process Isolation: Even in a future without the GIL, multiprocessing remains the gold standard for crash containment and fault isolation.
Related posts
Caching & Redis: The 'Sticky Note' Mental Model
Why does Redis make everything faster? A mastery guide to cache invalidation (the hardest problem in CS), eviction strategies, and Redis data types.
When to Use Classes vs. Functions in Python: A Design Checklist
A repeatable mental checklist for Python developers to decide when to use classes, functions, instance state, and dependency injection.
I Built a Tool to Migrate 500+ Images to WebP in One Hour
My Lighthouse score was crying because of heavy images. So I wrote a Python ETL to bulk-convert everything to WebP and update all the URLs automatically. Here's how.
MoneyPrinterV2: What 18,000 Stars Worth of Automated Content Actually Looks Like
An assembly line for AI content — local LLMs write the script, KittenTTS reads it, Gemini paints the pictures. The video uploads itself.