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

Table of Contents
- The Event Loop Architecture: How Python Handles 10,000 Sockets on One Thread
- Modern Structured Concurrency: asyncio.TaskGroup vs asyncio.gather
- The Legacy Problem: Orphaned Tasks in asyncio.gather
- The Modern Solution: asyncio.TaskGroup (Python 3.11+)
- The Cardinal Sin: Event Loop Starvation and How to Fix It
- Starvation Demonstration
- Solution 1: Offloading Blocking Calls with asyncio.to_thread
- Production Pattern: Bounded Concurrency with asyncio.Semaphore
- Boosting Event Loop Throughput: uvloop
- You Might Also Like
- Frequently Asked Questions
- Knowledge Check
- Related Guides & Deep Dives
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-Performance Python Backend Series
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
Knowledge Check
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 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
Migrating Python Codebases to Free-Threaded CPython 3.13
Audit C extensions, eliminate GIL assumptions, and safely migrate multi-threaded Python workloads to free-threaded CPython 3.13+ for true parallelism.
Read more
Astral uv in Docker: Multi-Stage Builds, BuildKit Caching & Fast CI
Speed up Python Docker builds from minutes to seconds using Astral uv, multi-stage targets, BuildKit persistent cache mounts, and lean runtime images.
Read more