Cheat SheetsPython A–ZConcurrency

Concurrency — Cheat Sheet

Python A–Z · 4 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Concurrency
Python A–Z4 topicsQuick revision reference
1

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
CPU-bound: two threads are no faster than one
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.
2

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
Fan-out I/O: 10 seconds of waiting compressed into 1
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 first
3

multiprocessing — 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
The GIL escape: one interpreter per process, all cores busy
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 == par
4

asyncio — 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
gather overlaps the waits: slowest call decides total time
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)
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/python