Concurrency in Python: threads, processes, and asyncio. The goal is not to memorise every API – it is to know which tool fits which job, and when to leave everything single-threaded because that is simpler and fast enough.
The GIL and why it matters
CPython has a Global Interpreter Lock. Only one thread runs Python bytecode at a time in a single process. Threads still help when your code waits on I/O – network calls, disk reads, sleeping – because the lock is released during those waits. Threads do not help much for CPU-heavy number crunching in pure Python; use multiple processes instead, or push hot loops into C extensions / NumPy.
Threads for I/O-bound work
Fetching several URLs is a classic I/O-bound task. A thread pool keeps the code readable without managing threads by hand.
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
def fetch(url: str) -> int:
response = httpx.get(url, timeout=10)
response.raise_for_status()
return response.status_code
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(fetch, url): url for url in URLS}
for future in as_completed(futures):
url = futures[future]
print(url, future.result())
Keep shared mutable state out of threaded code when you can. If two threads write the same list, use a lock or a queue. Better still, give each thread its own results and merge at the end.
Processes for CPU-bound work
Each process has its own Python interpreter and memory. That costs more than threads, but CPU work can run in parallel across cores.
from concurrent.futures import ProcessPoolExecutor
def is_prime(n: int) -> bool:
if n < 2:
return False
for divisor in range(2, int(n ** 0.5) + 1):
if n % divisor == 0:
return False
return True
candidates = range(10_000, 10_200)
with ProcessPoolExecutor() as pool:
primes = [n for n, ok in zip(candidates, pool.map(is_prime, candidates)) if ok]
print(len(primes), "primes found")
On Windows and macOS, guard process-spawning entry points with if __name__ == "__main__": so child processes do not re-import your module in a loop.
asyncio for many concurrent I/O waits
asyncio suits thousands of lightweight concurrent tasks – websockets, long-lived connections, services built on async libraries. One thread, cooperative multitasking: tasks yield control at await points.
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> int:
response = await client.get(url, timeout=10)
response.raise_for_status()
return response.status_code
async def main() -> None:
urls = ["https://httpbin.org/get"] * 5
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url) for url in urls]
results = await asyncio.gather(*tasks)
print(results)
asyncio.run(main())
Do not sprinkle async def everywhere “because it is modern”. Mixing sync and async code gets messy fast. If your stack is Flask with blocking database drivers, threads or a task queue may be the honest answer until you commit to an async framework end to end.
Choosing a model
- I/O-bound, moderate parallelism, sync libraries – thread pool
- CPU-bound pure Python – process pool or a library that releases the GIL
- Many concurrent network waits, async libraries available –
asyncio - Simple script, fast enough already – leave it alone
Measure before you parallelise. Spawning processes and juggling threads adds complexity. The best concurrency strategy is often a background job queue – covered later in these tutorials.

