Concurrency — Cheat Sheet
Python A–Z · 4 topics. Download the PDF or the Instagram carousel and share it.
The GIL — Why Python Threads Don't Run in Parallel
The Global Interpreter Lock lets only one thread execute Python bytecode at a time — so threads never speed up CPU-bound code, but still shine for I/O because the GIL is released while waiting.
- ✓GIL = one lock, one thread executing Python bytecode at a time — per process
- ✓Exists because CPython's reference counting is not thread-safe
- ✓Threads: zero speedup for CPU-bound code, real speedup for I/O-bound code
- ✓Escape hatches: multiprocessing, C extensions (NumPy), Python 3.13 free-threading
import threading
import time
def count(n):
while n:
n -= 1
N = 20_000_000
start = time.perf_counter()
count(N)
count(N)
print(f"sequential: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
t1 = threading.Thread(target=count, args=(N,))
t2 = threading.Thread(target=count, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"2 threads: {time.perf_counter() - start:.2f}s")
# Typical output on a 8-core machine:
# sequential: 1.9s
# 2 threads: 2.0s ← NO speedup. Only one thread runs bytecode at a time.threading — Concurrency for I/O-Bound Work
ThreadPoolExecutor turns "call 50 slow APIs one by one" into "call them together" — and Lock protects the shared state that threads would otherwise corrupt.
- ✓Use ThreadPoolExecutor — map for ordered results, submit + as_completed for fastest-first
- ✓Threads share memory: cheap to start, dangerous to mutate shared state
- ✓x += 1 is not atomic — guard shared writes with threading.Lock (with lock:)
- ✓queue.Queue is thread-safe; producer-consumer beats shared variables
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(url):
time.sleep(1) # stands in for requests.get(url)
return f"{url} -> 200 OK"
urls = [f"https://api.razorpay.com/v1/payments/{i}" for i in range(10)]
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(fetch, urls)) # ordered results
print(f"{len(results)} calls in {time.perf_counter() - start:.1f}s") # ~1.0s
# submit + as_completed: handle each result the moment it arrives
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(fetch, u): u for u in urls}
for fut in as_completed(futures):
print(futures[fut], "→", fut.result()) # fastest firstmultiprocessing — True Parallelism for CPU-Bound Work
Each process gets its own interpreter and its own GIL — ProcessPoolExecutor spreads pure-Python computation across all cores, at the cost of process startup and pickling data between processes.
- ✓One interpreter + one GIL per process → real parallel CPU execution
- ✓if __name__ == "__main__": guard is mandatory with the spawn start method
- ✓Everything crossing process boundaries is pickled — keep inputs/outputs small
- ✓Workers must be module-level functions; lambdas and closures fail to pickle
import time
from concurrent.futures import ProcessPoolExecutor
def heavy(n): # pure-Python CPU work
return sum(i * i for i in range(n))
if __name__ == "__main__": # REQUIRED: children re-import this module
jobs = [10_000_000] * 4
start = time.perf_counter()
seq = [heavy(n) for n in jobs]
print(f"sequential : {time.perf_counter() - start:.1f}s") # ~4.0s
start = time.perf_counter()
with ProcessPoolExecutor() as pool: # defaults to cpu_count() workers
par = list(pool.map(heavy, jobs))
print(f"4 processes: {time.perf_counter() - start:.1f}s") # ~1.1s on 4+ cores
assert seq == parasyncio — async/await and the Event Loop
asyncio runs thousands of concurrent I/O operations on one thread: coroutines pause at await, the event loop switches to whoever is ready, and gather fans out work — as long as nothing blocks the loop.
- ✓async def defines a coroutine; it runs only when awaited; asyncio.run() starts the loop
- ✓gather/create_task overlap I/O waits — total time ≈ the slowest task
- ✓Never block the loop: no time.sleep, no requests.get inside coroutines
- ✓asyncio.to_thread bridges blocking libraries; wait_for adds timeouts
import asyncio
import time
async def call_service(name, delay):
await asyncio.sleep(delay) # yields to the event loop while "waiting"
return f"{name}: ok ({delay}s)"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
call_service("orders", 1.0),
call_service("payments", 1.5),
call_service("inventory", 1.2),
)
for r in results:
print(r)
print(f"total: {time.perf_counter() - start:.1f}s") # ~1.5s, not 3.7s
asyncio.run(main()) # the ONE entry point — creates and closes the loop
# create_task: start now, await later
async def dashboard():
task = asyncio.create_task(call_service("analytics", 2.0))
print("doing other work while analytics runs...")
print(await task)