8 min read

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

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.

Part of a Series

High-Performance Python Backend Series

Part 4 of 4

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.


Advertisement

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]

Advertisement

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

Frequently Asked Questions


Knowledge Check


Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement