Python concurrency has three shapes: asyncio (I/O-bound, cooperative), threading (I/O-bound, preemptive but GIL-bound), multiprocessing (CPU-bound, separate processes). Pick deliberately.
import asyncio
import httpx
async def fetch_one(
client: httpx.AsyncClient,
sem: asyncio.Semaphore,
url: str,
) -> int:
async with sem: # bound in-flight requests
async with asyncio.timeout(5.0): # every external I/O has a deadline
response = await client.get(url)
return response.status_code
async def fetch_all(urls: list[str]) -> list[int]:
sem = asyncio.Semaphore(8)
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as tg: # siblings cancelled on first failure
tasks = [tg.create_task(fetch_one(client, sem, u)) for u in urls]
return [t.result() for t in tasks] # results read after the group closes
if __name__ == "__main__":
codes = asyncio.run(fetch_all(["https://example.com"])) # one entry point
print(codes)This is asyncio for I/O-bound work (9.1), driven by a single asyncio.run at the entry point (9.12). The TaskGroup owns every task's lifecycle so no reference is dropped (9.2, 9.4); the Semaphore bounds concurrency so a large URL list can't open a thousand sockets at once (9.6); and asyncio.timeout puts a documented deadline on each external call (9.3). Results are read only after the group closes, when every task is guaranteed done.
Reasoning, step by step:
- asyncio gives structured concurrency (3.11+), cooperative cancellation, and integrates with the broader async ecosystem (httpx, asyncpg, motor, aiokafka).
- Threading is older, less expressive about lifecycles, and the GIL means it only helps for I/O-bound work anyway.
- Multiprocessing is for CPU-bound work and brings its own complexity (pickling, IPC, lifecycle).
- Decision: I/O-bound new code → asyncio. CPU-bound → multiprocessing or a native extension. Mixed → asyncio for the I/O,
asyncio.to_threadfor the blocking pieces.
Enforcement: review; new I/O-bound modules use async def, not threading.
Reasoning, step by step:
asyncio.TaskGroupprovides structured concurrency: when the block exits, every task is awaited or cancelled. Errors from any task cancel the others; all errors are aggregated into anExceptionGroup.asyncio.gatherhas subtler semantics: by default it propagates the first error and doesn't cancel siblings.return_exceptions=Truehides errors as values. Both are footguns.- Pattern:
async with asyncio.TaskGroup() as tg: t1 = tg.create_task(load_user(uid)) t2 = tg.create_task(load_order(oid)) # both done here; errors raised as ExceptionGroup user, order = t1.result(), t2.result()
- Pre-3.11: use
asyncio.gatherwith explicit error handling, or migrate.
Enforcement: lint rule flagging bare asyncio.gather; review for TaskGroup on fan-out.
Reasoning, step by step:
asyncio.timeoutis a context manager:async with asyncio.timeout(5.0): await something(). Cancellation propagates correctly; works with TaskGroup.asyncio.wait_for(coro, timeout=5)works but has rougher edges around cancellation propagation.- Rule: every external async I/O has a timeout. Wrap external calls with
asyncio.timeout. Pick a number; document the choice. - The timeout should match the user-perceived SLA, not "infinity minus a bit."
Enforcement: review; every external async call wrapped in asyncio.timeout with a documented value.
Reasoning, step by step:
asyncio.create_task(coro)schedules a coroutine and returns aTask. Python only weakly references the task — if you drop the reference, the task can be garbage-collected mid-execution.- Symptoms include: silent task disappearance, mysterious cancellation, lost results.
- Fix: hold the task reference. Use
TaskGroup(which holds tasks for you), or stash tasks in a set and remove on completion:_background: set[asyncio.Task] = set() def fire_and_forget(coro: Coroutine) -> None: task = asyncio.create_task(coro) _background.add(task) task.add_done_callback(_background.discard)
- Better: don't fire-and-forget. Own the lifecycle with a TaskGroup or service-level scope.
Enforcement: ruff RUF006 flags unawaited create_task results; review for held references.
Reasoning, step by step:
- Cancelling a task raises
CancelledErrorat the nextawait. try/exceptin async code must re-raiseCancelledErrorif it catches it:try: await something() except asyncio.CancelledError: cleanup() raise except Exception: handle()
- Long CPU-only sections without
awaitdon't notice cancellation. Insertawait asyncio.sleep(0)periodically, or split the work. except Exception:does not catchCancelledErrorin Python 3.8+ (it becameBaseException-derived). Be explicit if you need to catch it.
Enforcement: review; any except that swallows CancelledError must re-raise it.
Reasoning, step by step:
threading.Lockblocks the underlying thread — inside an async function, that's a starvation hazard.asyncio.Lockis suspension-aware.async with lock: ...is the idiomatic shape.asyncio.Semaphorefor bounding concurrent operations:async with semaphore: await heavy()limits in-flight to the semaphore's value.asyncio.Queuefor producer-consumer between coroutines. Bound the queue:asyncio.Queue(maxsize=N).
Enforcement: review; no threading sync primitives inside async def; queues declare maxsize.
Reasoning, step by step:
- Some libraries are sync-only (legacy DB drivers,
requests, image processing). Don't let them block the event loop. await asyncio.to_thread(sync_fn, *args)runs the call in a worker thread and awaits the result. The event loop stays responsive.- Bound the worker pool. The default
ThreadPoolExecutoris unbounded by default into_thread; set a custom executor with a real cap for high-load systems. - Each
to_threadcall has a thread-context-switch cost. For tight loops over a sync library, batch the work first.
Enforcement: review; blocking sync calls in async code go through asyncio.to_thread with a bounded executor.
Reasoning, step by step:
- Threading in Python is constrained by the GIL — only one thread runs Python bytecode at a time. Useful for I/O parallelism in sync code, useless for CPU parallelism.
- Multiprocessing spawns separate Python processes — each with its own GIL. Useful for CPU-bound work, but inter-process communication is expensive (pickling).
- Decision tree:
- I/O-bound, new code → asyncio.
- I/O-bound, can't go async (legacy library, ecosystem) → threading with a bounded pool.
- CPU-bound → multiprocessing, or a C extension, or
concurrent.futures.ProcessPoolExecutor. - "I want it faster" without measurement → profile first.
- Python 3.13's PEP 703 (per-interpreter GIL) and PEP 684 are experimental — not yet a default option.
Enforcement: review; a threading/multiprocessing choice is justified against the decision tree.
Reasoning, step by step:
ThreadPoolExecutorandProcessPoolExecutorgive a uniformsubmit/map/as_completedAPI.- Always bound the pool size. Default is
os.cpu_count()for processes; for threads, it'smin(32, cpu_count + 4)— sometimes wrong for your workload. with ThreadPoolExecutor(max_workers=8) as pool:— context-managed shutdown.- For async code, prefer asyncio primitives. Use
concurrent.futuresonly in sync code or to bridge.
Enforcement: review; every executor sets an explicit max_workers and is context-managed.
Reasoning, step by step:
- An unbounded queue is a memory leak with a delay.
- Bounded
asyncio.Queue(maxsize=N):put()suspends when full. Producers naturally backpressure. - For multi-producer/multi-consumer: use queues + tasks managed by a TaskGroup. The producers and consumers are tasks; the queue mediates.
- Choose
maxsizefrom the slowest consumer's catch-up time. Document the value.
Enforcement: review; producer-consumer queues are bounded with a documented maxsize.
Reasoning, step by step:
threading.localis per-thread. In asyncio, all coroutines share a thread —threading.localdoesn't isolate them.contextvars.ContextVaris per-context. Each task has its own context; copies inherit the parent's values at task creation.- Use for: request IDs, user identity, tenant context, anything that should "follow" a request through async calls.
- Pattern:
request_id: ContextVar[str] = ContextVar("request_id") request_id.set("abc-123") # inside the request handler # any coroutine descended from here can read request_id.get()
- For logging integration with
contextvars, see the logging chapter.
Enforcement: review; request-scoped state in async code uses ContextVar, not threading.local.
Reasoning, step by step:
asyncio.run(main())is the top-level entry into async code. It creates an event loop, runs the coroutine, closes the loop.- Calling
asyncio.runinside a function called from async code is wrong — there's already a loop. - Tests: use
pytest-asynciooranyioplugins. They handle the loop lifecycle. - Libraries: never call
asyncio.run. Take the coroutine, let the caller run it.
Enforcement: review; asyncio.run appears only at program entry points, never in library code.
Reasoning, step by step:
- A class with both
def get(self)andasync def get_async(self)is two classes pretending to be one. Callers can't tell what they're getting; subclassing breaks; mypy can't help with the shape. - Provide two classes — same name, different module path. Sync
BookingClientinacme.booking; asyncBookingClientinacme.booking.aio. Callers explicitly import the variant they want:from acme.booking import BookingClient # sync from acme.booking.aio import BookingClient # async
- The async client lives in a sibling
.aiosubmodule (the Azure SDK convention — broadly sound). Sub-submodules of.aiomirror the sync side:acme.booking.aio.paymentsmirrorsacme.booking.payments. - Don't name the class
BookingClientAsync. That's the same class-name suffix anti-pattern the Azure SDK explicitly rejects. The module path carries the sync/async distinction; the class name stays clean. - The two classes share documentation conventions, method names, and parameter names. The only difference is the body and the
async/awaitkeywords.
Enforcement: review; sync and async clients split across modules, no def/async def pair on one class.
Reasoning, step by step:
@asyncio.coroutineandyield from-based coroutines were removed in Python 3.11. Don't write new code with them; migrate legacy code when touched.async def+awaitis the only blessed shape.- From Azure SDK guidelines: "DO use the
async/awaitkeywords. Do not use the yield from coroutine or asyncio.coroutine syntax."
Enforcement: review; no @asyncio.coroutine or yield from-based coroutines in new code.
- Resource lifecycle and cancellation: chapter 13.
- Logging in async code (contextvars + structlog): chapter on logging.
- Performance trade-offs of asyncio vs threading: chapter 15.
- Client constructor signature (same shape for sync and async): chapter 10.