Python Async Runtime Advanced: GIL, ASGI Lifecycles, and Safe Concurrency
Understand event-loop scheduling, CPU versus I/O concurrency, ASGI worker lifecycles, per-request sessions, and cancellation-safe background work.
On this page
1. Event loop, GIL, and task shape
An asyncio event loop runs callbacks and coroutines cooperatively on its thread. A coroutine yields at await; CPU-heavy Python code or a blocking library call that does not yield prevents every other task on that loop from progressing. The GIL in standard CPython limits simultaneous execution of Python bytecode in one interpreter, but it does not prevent useful concurrency while tasks wait on I/O. Threads help with blocking I/O libraries; process workers or native code that releases the GIL may suit CPU-heavy work.
asyncio.gather() schedules multiple awaitables and collects their results; it does not make CPU work parallel. Bound fan-out with semaphores and enforce deadlines so one incoming request cannot create unlimited downstream tasks. For unavoidable synchronous I/O, use loop.run_in_executor() or asyncio.to_thread() as appropriate, and size the executor as a scarce resource rather than a dumping ground.
results = await asyncio.gather(*(fetch_one(item) for item in batch))
legacy_result = await asyncio.to_thread(blocking_client.fetch, key)
The first line needs a bounded batch or semaphore for large inputs. to_thread protects the event loop from blocking I/O; it does not make CPU-bound Python bytecode parallel under the usual CPython GIL.
C extensions can release the GIL and accelerate numeric work, but they introduce native memory ownership and thread-safety concerns. Cython can compile typed hotspots, but benchmark end-to-end: conversion, serialization, and I/O may dominate the optimized function.
2. FastAPI route and dependency lifecycles
In ASGI frameworks such as FastAPI, async def endpoints run on the event loop; ordinary def handlers are generally dispatched to a thread pool by the framework. An async function is not automatically faster: awaiting a synchronous database call still blocks the event loop. Use async drivers for async paths, or deliberately isolate blocking work.
Create shared connection pools and clients in application lifespan startup and close them in shutdown. Do not create one global SQLAlchemy Session and share it across requests: sessions carry mutable transaction/identity-map state and are not safe for concurrent use. Create a session per request or task, and never let two concurrent coroutines mutate the same session.
Uvicorn worker count and Gunicorn worker class must match the deployment CPU and memory budget. More workers multiply connection pools and caches. Measure active requests, pool wait, loop lag, worker restarts, and per-worker RSS before tuning.
For example, start with a small explicit worker count and graceful timeout, then tune from load tests and connection budgets:
gunicorn app.main:app -k uvicorn.workers.UvicornWorker \
--workers 2 --timeout 45 --graceful-timeout 30
The import path, worker class availability, and recommended deployment command vary by Uvicorn/Gunicorn version. Budget database connections as workers × pool size × replicas, plus jobs and administrative traffic.
3. Background work and task groups
A background task needs an owner, error reporting, cancellation policy, and resource cleanup. For short request-adjacent work, framework background tasks can be sufficient; durable jobs need a queue with persistence and retries. On supported Python versions, asyncio.TaskGroup provides structured ownership: if a child fails, siblings are cancelled and the group raises an exception group after cleanup. Retain the group scope and handle expected failures explicitly rather than leaving tasks detached.
To diagnose a worker that hangs silently, wrap a bounded operation in asyncio.wait_for() or a timeout scope, name tasks, enable asyncio debug mode in a safe environment, and capture task stacks. Install signal handlers only on the main thread/event loop and use them to request graceful shutdown, not to perform lengthy cleanup inside the signal callback.
4. Interview and troubleshooting drills
Interview: Compare CPU-bound and I/O-bound work in async def and def FastAPI routes. Explain event-loop blocking, framework thread dispatch, GIL implications, and when a process pool is appropriate.
Troubleshooting: Requests hang with low CPU. Inspect event-loop lag, pending task stacks, pool acquisition waits, blocking calls inside async code, and detached tasks. Add a timeout to expose the stuck boundary, then fix cancellation/cleanup and bound concurrency.
Challenge: A factory uses a shared mutable default list to collect per-request callbacks, then launches async tasks. Two requests append before either task consumes the list. Both tasks can observe the combined callbacks because they share the same object. Create the list per invocation, pass an immutable snapshot to each task, and test overlapping requests.