Python offers three concurrency approaches: threading for I/O-bound work using OS threads constrained by the Global Interpreter Lock, multiprocessing for CPU-bound work using separate processes that bypass the GIL, and asyncio for high-volume I/O-bound work using a single-threaded cooperative event loop.
Doing more than one thing at a time
What you'll learn: The critical distinction between I/O-bound and CPU-bound work, and which Python concurrency tool fits each. Threading for I/O tasks, asyncio for high-volume I/O, multiprocessing for computation.
How to read this tab: Read the decision table first, before anything else. The whole page rests on one question: is your code waiting (I/O-bound) or computing (CPU-bound)? Answer that question, and the right tool is obvious.
Concurrency means doing more than one thing at a time. Python gives you three tools, and choosing the right one depends on what is slowing you down: waiting (network, disk, database) or computing (heavy number-crunching). The wrong choice can make things slower, so the distinction matters.
This decision table is the whole lesson. Print it. Stick it on your wall. Before writing any concurrent code, ask: "Is my code waiting or computing?" Waiting (network, file, database, sleep) → threads or asyncio. Computing (maths, image processing, parsing large files) → multiprocessing. Using threads for CPU-bound work is worse than a single-threaded loop due to the GIL.
Which model to use
| Model | Best for | How it runs |
|---|---|---|
threading | I/O-bound (waiting on network, disk) | Multiple OS threads, one at a time due to the GIL |
multiprocessing | CPU-bound (heavy computation) | Multiple processes, truly parallel |
asyncio | I/O-bound at high volume (thousands of connections) | One thread, cooperative event loop |
I/O-bound: your program spends most of its time waiting — for a web response, a file, a database. Threads and asyncio help because while one task waits, another runs. CPU-bound: your program spends its time calculating — image processing, maths. Only multiprocessing gives a real speed-up here, because it sidesteps the GIL.
Threads for I/O work. Python's threading.Thread creates OS threads, but they can't run Python bytecode simultaneously (the GIL prevents it). However, the GIL IS released while waiting for I/O — so threads genuinely speed up code that spends time waiting for networks, files, or databases. The overhead of creating threads is low.
Threading — for I/O-bound work
A thread runs a function alongside others in the same process. Because of the GIL (explained in the Expert tab), threads do not speed up pure computation — but they are excellent when tasks spend time waiting, because Python releases the GIL during I/O.
import threading
import time
def download(name: str) -> None:
print(f"Starting {name}")
time.sleep(2) # simulates waiting for a network response
print(f"Finished {name}")
# Run three "downloads" concurrently
threads = []
for site in ["site-a", "site-b", "site-c"]:
t = threading.Thread(target=download, args=(site,))
t.start() # begins running the function
threads.append(t)
# Wait for all threads to complete
for t in threads:
t.join()
# Without threads this takes ~6s (2s x 3).
# With threads it takes ~2s — they wait at the same time.
print("All downloads done")✅ Beginner tab complete — check your understanding
- I can explain the difference between I/O-bound and CPU-bound work
- I know: I/O-bound → threading or asyncio; CPU-bound → multiprocessing
- I can write a basic threading.Thread example
- I can write a basic asyncio coroutine with async/await
- I understand why threads do NOT speed up CPU-heavy Python code
multiprocessing, concurrent.futures, and the GIL
What you'll learn: multiprocessing.Pool for true parallelism. concurrent.futures as the clean unified API over threads and processes. The Global Interpreter Lock — what it is and what it means for your code.
How to read this tab: Start with concurrent.futures — it's the highest-level API and the right starting point for most real work. Drop down to threading or multiprocessing directly only when you need the extra control.
The if __name__ == "__main__" guard is mandatory. On Windows (and macOS with Python 3.8+ default), multiprocessing uses "spawn" to create child processes — which re-imports your script. Without the guard, child processes would spawn more child processes recursively. Always wrap multiprocessing entry code in this guard.
Multiprocessing — for CPU-bound work
The multiprocessing module creates separate Python processes, each with its own interpreter and memory — and crucially, its own GIL. This achieves true parallelism on multiple CPU cores for computation-heavy work. The trade-off: starting processes is more expensive than threads, and data must be sent between them (pickled), so it suits coarse-grained, CPU-bound tasks.
from multiprocessing import Pool
def heavy_compute(n: int) -> int:
# A CPU-bound task — pure calculation
return sum(i * i for i in range(n))
if __name__ == "__main__": # required guard for multiprocessing
numbers = [1_000_000, 2_000_000, 3_000_000, 4_000_000]
# Pool distributes the work across CPU cores, truly in parallel
with Pool(processes=4) as pool:
results = pool.map(heavy_compute, numbers)
print(results)
# On a 4-core machine this runs ~4x faster than a plain loop,
# because each process runs on its own core with its own GIL.if __name__ == "__main__" guard is requiredOn platforms that start processes via "spawn" (Windows, and macOS by default), the child process re-imports your script. Without the main-guard, that would recursively start more processes. Always wrap multiprocessing entry code in if __name__ == "__main__":.
asyncio runs one thing at a time — but switches between them. It's not parallel. It's cooperative: when a coroutine hits an await, it says "I'm waiting, go run something else." This means you can handle thousands of simultaneous network connections on a single thread, as long as they spend most of their time waiting.
asyncio — single-threaded concurrency
asyncio runs many tasks on one thread using an event loop and the async/await keywords. When a coroutine hits an await on something that would wait (a network call), it yields control so another coroutine can run. This scales to thousands of simultaneous connections with very little overhead — ideal for network servers and clients. The Codex has a dedicated deep-dive: asyncio in the Standard Library.
import asyncio
async def fetch(name: str) -> str:
print(f"Starting {name}")
await asyncio.sleep(2) # yields control while "waiting"
print(f"Finished {name}")
return name
async def main() -> None:
# Run three coroutines concurrently and wait for all
results = await asyncio.gather(
fetch("api-1"),
fetch("api-2"),
fetch("api-3"),
)
print(results) # ['api-1', 'api-2', 'api-3']
# asyncio.run starts the event loop and runs main() to completion
asyncio.run(main())
# Total time ~2s, not 6s — all three wait concurrently on one thread.time.sleep or a synchronous database driver) inside a coroutine stalls the entire event loop. Use the async equivalents (asyncio.sleep) or run blocking work in an executor.✅ Intermediate tab complete — check your understanding
- I can use ThreadPoolExecutor for I/O-bound work and ProcessPoolExecutor for CPU-bound work
- I can use pool.map() to apply a function to a list in parallel
- I can explain what the GIL is and why Python threads don't run Python bytecode in parallel
- I always include if __name__ == "__main__": when using multiprocessing
The GIL's implementation and PEP 703
What you'll learn: Why the GIL exists (CPython's reference counting is not thread-safe). When the GIL is released (blocking I/O, C extensions). PEP 703 — the experimental free-threaded build in Python 3.13+ that makes the GIL optional.
How to read this tab: Read after you've used all three concurrency models in practice. The GIL section is one of the most frequently misunderstood aspects of Python — understanding it properly takes you to a different level.
The most misunderstood thing about Python. The GIL is a mutex that allows only one thread to execute Python bytecode at a time. This is why threads don't speed up CPU-bound Python. But it's released during I/O — which is why threads DO help I/O-bound code. The GIL also makes Python's memory management simpler and single-threaded code faster.
The Global Interpreter Lock (GIL)
In CPython, the Global Interpreter Lock is a mutex that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management (reference counting) is not thread-safe; the GIL makes the interpreter simpler and single-threaded code faster. The consequence: threads cannot run Python bytecode in parallel, so CPU-bound multithreaded code sees no speed-up. The GIL is released during blocking I/O and inside many C extensions (such as NumPy operations), which is why threading still helps I/O-bound and C-heavy workloads.
PEP 703 introduced an experimental free-threaded build of CPython (available from Python 3.13 as a separate build) that can run without the GIL, enabling true multithreaded parallelism. As of 3.13 it is optional and experimental; the standard build still uses the GIL. This is the most significant change to Python's concurrency story in its history, but production code today should still assume the GIL is present.
Start here for most concurrent work. concurrent.futures provides ThreadPoolExecutor and ProcessPoolExecutor with identical APIs. Switching from threads to processes is one word change. pool.map() distributes work; as_completed() lets you process results as they finish. Use this over raw threading or multiprocessing unless you need fine-grained control.
concurrent.futures — the unified high-level interface
The concurrent.futures module provides a single high-level API over both threads and processes via ThreadPoolExecutor and ProcessPoolExecutor. You submit callables and get back Future objects representing pending results. Switching between threads and processes is often a one-line change — making it the recommended starting point for most parallel work.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fetch_url(url: str) -> int:
import time
time.sleep(1) # simulate an I/O wait
return len(url)
# I/O-bound -> ThreadPoolExecutor
urls = ["a.com", "b.com", "c.com", "d.com"]
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch_url, urls))
print(results)
# CPU-bound -> ProcessPoolExecutor (same API, different executor)
def square(n: int) -> int:
return n * n
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
results = list(pool.map(square, range(10)))
print(results)
# submit() returns a Future; as_completed yields them as they finish
from concurrent.futures import as_completed
with ThreadPoolExecutor() as pool:
futures = [pool.submit(fetch_url, u) for u in urls]
for future in as_completed(futures):
print("Got result:", future.result())Start with concurrent.futures. Use ThreadPoolExecutor for I/O-bound tasks, ProcessPoolExecutor for CPU-bound tasks. Reach for asyncio when you have very high volumes of concurrent I/O (thousands of network operations) and libraries that support it. Use raw threading or multiprocessing only when you need fine-grained control the executors do not provide.
✅ Expert tab complete
- I can explain why CPython needs the GIL (reference counting is not thread-safe)
- I know the GIL IS released during blocking I/O and inside many C extensions
- I know that PEP 703 introduces an optional free-threaded CPython build in Python 3.13