Python Asyncio Deep Dive: Coroutines, Tasks, and Event Loops in 2026

Asynchronous programming in Python has matured from an experimental add-on in Python 3.4 into the default architectural paradigm for modern high-performance microservices, API gateways, and streaming data pipelines. Powered by frameworks like FastAPI, Litestar, and Sanic, Python backends regularly handle tens of thousands of concurrent connections.
Yet, despite its widespread adoption, asyncio remains one of the most misunderstood systems in Python. Developers frequently trigger event loop starvation, mix synchronous blocking calls inside async execution contexts, or rely on outdated legacy APIs like asyncio.gather without proper exception isolation.
In this deep dive, we will unpack how the Python asyncio runtime actually functions under the hood, compare asyncio.TaskGroup against legacy gather, demonstrate how to safely offload CPU-bound work using asyncio.to_thread, and build a high-concurrency rate-limited worker pipeline.
High-Concurrency Python & FastAPI Systems
The Event Loop Architecture: How Python Handles 10,000 Sockets on One Thread
At the center of asyncio is a single-threaded Event Loop. Rather than allocating an OS thread per connection (which consumes ~8 MB of stack memory per thread and incurs heavy OS context-switching penalties), asyncio multiplexes non-blocking I/O operations onto a single thread using operating system polling primitives (epoll on Linux, kqueue on macOS, and IOCP on Windows).
+---------------------------------------------------------------------------------+
| Single OS Thread |
| |
| +-------------------------------------------------------------------------+ |
| | Asyncio Event Loop | |
| | | |
| | 1. Poll OS Kernel Socket Ready State (epoll / kqueue) | |
| | 2. Resume Waiting Coroutines (send next() value) | |
| | 3. Suspend at 'await' boundary (yield socket descriptor to loop) | |
| | 4. Run Scheduled Timers and Callbacks | |
| +-------------------------------------------------------------------------+ |
| | |
| v |
| +-------------------------------------------------------------------------+ |
| | OS Kernel Non-Blocking I/O | |
| | [ Socket 1: Reading ] [ Socket 2: Writing ] [ Socket 3: Connected ] | |
| +-------------------------------------------------------------------------+ |
+---------------------------------------------------------------------------------+
When a function is declared with async def, calling it does not execute its body immediately. Instead, it returns a coroutine object. A coroutine is an enhanced generator that can suspend execution at any await expression and yield control back to the event loop until the awaited I/O operation is ready.
Modern Structured Concurrency: asyncio.TaskGroup vs asyncio.gather
Before Python 3.11, the standard idiom for running multiple async operations concurrently was asyncio.gather. While ubiquitous, gather possesses a critical architectural flaw: unhandled exceptions do not cancel sibling tasks.
If one task fails in gather(), the other tasks continue running as detached "orphan" coroutines in the background, consuming memory, holding database connections open, and generating silent bugs.
The Legacy Problem: Orphaned Tasks in asyncio.gather
import asyncio
async def fetch_user(user_id: int):
await asyncio.sleep(0.5)
if user_id == 2:
raise ValueError("User database connection timeout!")
return {"id": user_id, "name": f"User {user_id}"}
async def fetch_orders(user_id: int):
# This keeps running and executing even after fetch_user fails!
await asyncio.sleep(2.0)
print("Orders fetched (wasted database compute!)")
return ["Order_101", "Order_102"]
async def main_legacy():
try:
results = await asyncio.gather(
fetch_user(2),
fetch_orders(2)
)
except ValueError as e:
print(f"Caught error: {e}")
# fetch_orders is still running in the background!
The Modern Solution: asyncio.TaskGroup (Python 3.11+)
Python 3.11 introduced structured concurrency via asyncio.TaskGroup. When used inside an asynchronous context manager (async with), if any task inside the group raises an exception, the TaskGroup automatically cancels all remaining sibling tasks, cleans up resources, and raises an ExceptionGroup:
import asyncio
async def fetch_user(user_id: int):
await asyncio.sleep(0.5)
if user_id == 2:
raise ValueError("User database connection failed!")
return {"id": user_id, "name": f"User {user_id}"}
async def fetch_orders(user_id: int):
try:
await asyncio.sleep(2.0)
return ["Order_101", "Order_102"]
except asyncio.CancelledError:
print("fetch_orders was cleanly cancelled because fetch_user failed!")
raise
async def main_modern():
try:
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch_user(2))
task2 = tg.create_task(fetch_orders(2))
# Execution reaches here only if both tasks succeed
print(task1.result(), task2.result())
except* ValueError as eg:
# Python 3.11+ exception group pattern matching
for error in eg.exceptions:
print(f"Handled error in TaskGroup: {error}")
if __name__ == "__main__":
asyncio.run(main_modern())
The Cardinal Sin: Event Loop Starvation and How to Fix It
Because the asyncio event loop runs on a single thread, any synchronous blocking call freezes the entire application. If an endpoint executes a CPU-heavy loop, calls time.sleep(), or uses a synchronous database driver like psycopg2 instead of asyncpg, every other concurrent user waiting on that server is blocked.
Starvation Demonstration
import asyncio
import time
# WRONG: Synchronous blocking call inside async function!
async def bad_endpoint():
# Freezes the ENTIRE event loop for 2 full seconds!
# No other requests can be accepted or processed during this window.
time.sleep(2.0)
return {"status": "ok"}
Solution 1: Offloading Blocking Calls with asyncio.to_thread
For synchronous third-party SDKs (such as boto3 for AWS S3 or PIL/Pillow for image processing), use asyncio.to_thread(). This delegates the blocking call to Python's internal thread pool without stalling the event loop:
import asyncio
from PIL import Image
def resize_image_sync(filepath: str, output_path: str):
# CPU-heavy image compression
with Image.open(filepath) as img:
img.thumbnail((800, 800))
img.save(output_path, "JPEG", quality=85)
async def handle_image_upload(filepath: str, output_path: str):
# Cleanly offloaded to background thread pool
await asyncio.to_thread(resize_image_sync, filepath, output_path)
return {"status": "optimized"}
Production Pattern: Bounded Concurrency with asyncio.Semaphore
When crawling APIs or processing thousands of queue items, spawning thousands of concurrent coroutines without limits will overwhelm external services, exhaust file descriptors, or trigger HTTP 429 Too Many Requests errors. Use asyncio.Semaphore to cap concurrency:
import asyncio
import aiohttp
class BoundedAPIScraper:
def __init__(self, max_concurrent_requests: int = 25):
self.semaphore = asyncio.Semaphore(max_concurrent_requests)
self.session: aiohttp.ClientSession | None = None
async def __aenter__(self):
self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def fetch_item(self, item_id: int) -> dict:
async with self.semaphore:
# At most 25 requests will enter this block concurrently
url = f"https://api.example.com/v1/items/{item_id}"
assert self.session is not None
async with self.session.get(url) as response:
return await response.json()
async def process_all(self, item_ids: list[int]) -> list[dict]:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(self.fetch_item(i)) for i in item_ids]
return [t.result() for t in tasks]
Boosting Event Loop Throughput: uvloop
The default Python event loop (asyncio.SelectorEventLoop) is written in pure Python. For production Linux servers, replacing it with uvloop (a drop-in replacement written in Cython on top of Node.js's underlying libuv C library) increases I/O throughput by 2x to 4x, bringing Python networking performance directly on par with Node.js and Go.
uv add uvloop
import asyncio
import sys
# Configure uvloop as the default event loop policy on Unix
if sys.platform != "win32":
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
async def main():
print("Running on ultra-fast libuv event loop!")
if __name__ == "__main__":
asyncio.run(main())
You Might Also Like
- Python Concurrency in 2026: AsyncIO vs Threads vs Processes
- Profiling Async Python Memory Leaks in Production
- Optimizing Python FastAPI for High-Concurrency
- FastAPI vs Litestar (2026): Performance, Benchmarks & When to Switch
Frequently Asked Questions
A Coroutine is a function defined with async def that yields control when awaited. A Future is a low-level object representing an eventual result that is not yet ready. A Task is a concrete subclass of Future that wraps a coroutine and schedules it on the event loop for immediate background execution.
TaskGroup implements structured concurrency: if any child task fails with an unhandled exception, TaskGroup automatically cancels all remaining sibling tasks and raises an ExceptionGroup. Legacy asyncio.gather leaves sibling tasks running as detached orphan coroutines.
Never run CPU-heavy loops or blocking synchronous calls directly in an async function. Offload synchronous I/O or fast CPU work using asyncio.to_thread(). For heavy sustained computation (e.g. data processing or model inference), use ProcessPoolExecutor or a dedicated task queue like Celery.
No. The GIL only restricts multiple CPU-bound threads from executing Python bytecode in parallel on multiple cores. For I/O-bound network workloads (HTTP requests, database reads, WebSocket streaming), the thread releases the GIL while waiting on the network socket, allowing asyncio to handle tens of thousands of concurrent connections efficiently on a single core.
Knowledge Check
What happens if a child task raises an unhandled exception inside an asyncio.TaskGroup?
Related Guides & Deep Dives
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Python Async & Concurrency Reference: Event Loop, TaskGroups & Production Patterns (2026)
The definitive production reference for Python asynchronous programming: event loop internals, structured concurrency with TaskGroup, free-threaded CPython 3.13, connection pooling, and profiling event loop starvation.
Read more
Python Concurrency in 2026: AsyncIO vs Threads vs Processes
Why 4 threads can make CPU code 50% slower. Compare AsyncIO event loops, GIL workarounds, and multiprocessing benchmarks with architectural decision trees.
Read more
Profiling Async Python Memory Leaks in Production
Identify, profile, and fix memory leaks in Python asyncio loops, long-running coroutines, and background tasks using tracemalloc and Memray visualizers.
Read more