Skip to content

⚡ Python Async & Await — Concurrency for AI Agents

Target: Staff/Principal Engineer | Focus: Async Python patterns for high-throughput agent systems | Reviewed: October 2026 (Python 3.11–3.14)

30-second answer

asyncio runs many I/O-bound tasks on one thread by switching at every await: while one task waits on an LLM or database, the event loop runs another. That fits agents, which spend most of their time waiting. The rules that matter in production: never block the loop (no time.sleep, sync HTTP or heavy CPU in a coroutine; offload with asyncio.to_thread or a process pool), bound concurrency with a semaphore, put deadlines on everything (asyncio.timeout), and use structured concurrency (asyncio.TaskGroup, 3.11+) so failures and cancellation propagate instead of leaking orphan tasks.


1. WHY ASYNC FOR AI AGENTS?

AI agent systems are I/O-bound, not CPU-bound. The agent spends most of its time:

  • Waiting for LLM API responses (200ms–10s)
  • Waiting for tool/API calls (50ms–5s)
  • Waiting for database queries (1ms–500ms)
  • Waiting for RAG retrieval (10ms–2s)

Synchronous execution wastes this waiting time. Async execution allows the agent to work on other tasks while waiting.

# ❌ Synchronous — blocks on every I/O
def handle_user_sync(user_id, query):
    user = get_user_sync(user_id)          # Block 50ms
    context = search_kb_sync(query)        # Block 200ms  
    response = call_llm_sync(context)       # Block 2s
    return send_response_sync(response)     # Block 10ms
# Total: ~2.26s — CPU idle 99% of the time

# ✅ Asynchronous — non-blocking I/O
async def handle_user_async(user_id, query):
    user_task = get_user_async(user_id)    # Start
    context_task = search_kb_async(query)   # Both in parallel
    user, context = await asyncio.gather(user_task, context_task)

    response = await call_llm_async(context)  # Still must wait
    return await send_response_async(response)
# Total: ~2.01s — 250ms saved via parallel user+KB lookup

2. CORE CONCEPTS

2.1 The async def Keyword

Defines a coroutine — a function that can be paused and resumed:

async def fetch_weather(city: str) -> dict:
    """This is a coroutine. It doesn't run when called — it returns a coroutine object."""
    data = await make_api_call(f"/weather/{city}")
    return data

# Calling it:
coro = fetch_weather("Tokyo")  # Returns a coroutine object, NOT the result
# To run it, you need an event loop:
result = await coro  # Inside another async function
# or
result = asyncio.run(fetch_weather("Tokyo"))  # Top-level entry point

2.2 The await Keyword

Pauses the current coroutine until the awaited coroutine completes:

async def process():
    # Without await: returns a coroutine object (WRONG)
    coro = fetch_weather("Tokyo")  #  <coroutine object fetch_weather at 0x...>

    # With await: returns the actual result (CORRECT)
    result = await fetch_weather("Tokyo")  #  {"temp": 22, "condition": "cloudy"}

What await does: 1. Suspends execution of the current coroutine 2. Gives control back to the event loop 3. Event loop can run other tasks while waiting 4. When the awaited coroutine completes, the event loop resumes the current coroutine

2.3 The Event Loop

The event loop is the scheduler that manages all async tasks:

Time ────────────────────────────────────────────────────────→

Task A: |──fetch_weather──|  |──parse_result──|  |──save──|
Task B:                    |──fetch_weather──|  |──parse──|
Task C:                                        |──fetch──|

Without Async:
Task A: |──fetch──|──parse──|──save──|
Task B:                              |──fetch──|──parse──|
Task C:                                        |──fetch──|
Total: ~9 time units

With Async (I/O overlap):
Task A: |──fetch──|──parse──|──save──|
Task B:    |──fetch──|──parse──|
Task C:                |──fetch──|
Total: ~5 time units — I/O wait is overlapped!

Figure: tasks hand control to the loop at every await, so I/O waits overlap.

sequenceDiagram
  participant L as Event loop
  participant A as Task A
  participant B as Task B
  L->>A: Run until first await
  A-->>L: Suspend (waiting on I/O)
  L->>B: Run until first await
  B-->>L: Suspend (waiting on I/O)
  Note over L: I/O completes for A
  L->>A: Resume
  A-->>L: Done
  L->>B: Resume
  B-->>L: Done

3. PRACTICAL PATTERNS FOR AGENT SYSTEMS

3.1 Parallel LLM Calls

import asyncio
import os
from openai import AsyncOpenAI

client = AsyncOpenAI()
MODEL = os.environ["LLM_MODEL"]            # model ids change; keep them in config
llm_slots = asyncio.Semaphore(8)           # bound concurrency: provider rate limits are finite

async def call_llm_parallel(prompts: list[str]) -> list[str]:
    """Call multiple LLM prompts in parallel."""
    async def single_call(prompt: str) -> str:
        async with llm_slots:
            response = await client.responses.create(model=MODEL, input=prompt)
            return response.output_text

    # All calls run concurrently (up to the semaphore limit)
    tasks = [single_call(p) for p in prompts]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # Handle any failures
    processed = []
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            processed.append(f"[Error on prompt {i}]: {result}")
        else:
            processed.append(result)

    return processed

# Usage
results = await call_llm_parallel([
    "Summarize this document",
    "Extract key entities",
    "Classify sentiment",
])
# All 3 calls run concurrently — ~2s instead of ~6s

gather(..., return_exceptions=True) gives partial results when some calls fail. If any failure should abort the rest, use a TaskGroup instead (3.6).

3.2 Async Agent Loop with Timeout

async def run_agent_with_timeout(
    agent_fn, query: str, timeout: float = 30.0
) -> str:
    """Run an agent with a deadline — prevents runaway agents."""
    try:
        async with asyncio.timeout(timeout):      # 3.11+; wait_for() also works
            return await agent_fn(query)
    except TimeoutError:                          # asyncio.TimeoutError is an alias since 3.11
        # The agent coroutine has been CANCELLED, not paused: any work it did
        # is lost unless it checkpoints. Return a clear failure, not a fake answer.
        return f"Agent did not complete within {timeout}s"

# ─── Usage ────────────────────────────────────────
async def my_agent(query: str) -> str:
    """Agent might take 2s or 60s depending on complexity."""
    await asyncio.sleep(2)  # Simulate work
    return f"Result for: {query}"

result = await run_agent_with_timeout(my_agent, "Hello", timeout=5.0)

Timeouts work by cancellation: CancelledError is raised at the coroutine's current await. Code that catches it must clean up and re-raise; swallowing it breaks timeouts and shutdown. Timeouts also only fire at an await, so a coroutine stuck in blocking code can't be timed out.

3.3 Async Generator for Streaming Agent Outputs

import json

def sse(event: dict) -> str:
    # Server-Sent Events wire format: "data: <payload>\n\n"
    return f"data: {json.dumps(event)}\n\n"

async def stream_agent_events(query: str):
    """Stream agent thoughts and actions as they happen."""
    agent = Agent()

    # Yield initial acknowledgment
    yield {"type": "status", "content": "Processing..."}

    async for step in agent.run(query):
        if step["type"] == "thought":
            yield {"type": "thought", "content": step["content"]}
        elif step["type"] == "tool_call":
            yield {"type": "action", "content": f"🔧 Calling {step['tool']}({step['params']})"}
        elif step["type"] == "tool_result":
            yield {"type": "observation", "content": f"Result: {step['result'][:100]}..."}
        elif step["type"] == "error":
            yield {"type": "error", "content": f"❌ {step['error']}"}

    yield {"type": "done", "content": "✅ Complete"}

# FastAPI endpoint
@app.get("/chat")
async def chat(query: str):
    async def body():
        async for event in stream_agent_events(query):
            yield sse(event)           # StreamingResponse needs str/bytes, not dicts
    return StreamingResponse(body(), media_type="text/event-stream")

When the client disconnects, the server cancels the generator at its current await; make sure that also cancels the in-flight model call (or the run keeps burning tokens), and send periodic heartbeat comments so proxies and load balancers don't cut idle streams.

3.4 Async Rate Limiting

import asyncio
from time import monotonic as time   # monotonic: immune to wall-clock jumps

class AsyncRateLimiter:
    """Token-bucket rate limiter for async contexts."""

    def __init__(self, rate: float = 10, burst: int = 20):
        self.rate = rate
        self.burst = burst
        self.tokens = burst
        self.last_refill = time()
        self._lock = asyncio.Lock()

    async def acquire(self) -> bool:
        """Acquire a token. Returns True if allowed."""
        async with self._lock:
            now = time()
            elapsed = now - self.last_refill
            self.tokens = min(
                self.burst,
                self.tokens + elapsed * self.rate
            )
            self.last_refill = now

            if self.tokens >= 1:
                self.tokens -= 1
                return True
            return False

    async def wait_and_acquire(self):
        """Wait until a token is available."""
        while not await self.acquire():
            await asyncio.sleep(1 / self.rate)

    async def __aenter__(self):
        await self.wait_and_acquire()
        return self

    async def __aexit__(self, *args):
        pass

# Usage
rate_limiter = AsyncRateLimiter(rate=10, burst=20)

async def call_api(data: dict) -> dict:
    async with rate_limiter:
        return await http_client.post("/api/endpoint", json=data)

3.5 Circuit Breaker Pattern (Async)

class AsyncCircuitBreaker:
    """Circuit breaker for async API calls."""

    def __init__(self, failure_threshold: int = 5, 
                 recovery_timeout: float = 30.0):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = "closed"  # closed, open, half-open
        self.last_failure_time = 0.0
        self._lock = asyncio.Lock()

    async def call(self, coro_factory):
        """Execute a coroutine with circuit breaking.

        Simplification: in half-open state every caller gets through; a real
        breaker lets one (or a few) trial calls through and fails the rest fast.
        Count only failures that indicate an unhealthy dependency (timeouts,
        5xx, 429), not caller errors like 400s.
        """
        async with self._lock:
            if self.state == "open":
                if time() - self.last_failure_time > self.recovery_timeout:
                    self.state = "half-open"
                else:
                    raise CircuitBreakerOpen("Circuit breaker is open")

        try:
            result = await coro_factory()

            async with self._lock:
                if self.state == "half-open":
                    self.state = "closed"
                    self.failure_count = 0
                else:
                    self.failure_count = 0

            return result

        except Exception as e:
            async with self._lock:
                self.failure_count += 1
                self.last_failure_time = time()
                if self.failure_count >= self.failure_threshold:
                    self.state = "open"

            raise

3.6 Structured Concurrency with TaskGroup (3.11+)

async def gather_context(query: str) -> dict:
    async with asyncio.TaskGroup() as tg:
        user = tg.create_task(get_user(query))
        docs = tg.create_task(search_kb(query))
        hist = tg.create_task(load_history(query))
    # Leaving the block means ALL tasks finished. If any task raised, the
    # others were cancelled and the errors are raised together as an
    # ExceptionGroup (handle with `except* SomeError:`).
    return {"user": user.result(), "docs": docs.result(), "history": hist.result()}
asyncio.gather asyncio.TaskGroup
One task fails Other tasks keep running (default) Other tasks are cancelled
Error reporting First exception, or all results with return_exceptions=True All exceptions in an ExceptionGroup
Leaked tasks Possible Impossible: the block waits for every task
Use when You want partial results All-or-nothing work, clean cancellation

4. ASYNC VS THREADING VS MULTIPROCESSING

Feature Async (asyncio) Threading (threading) Multiprocessing (multiprocessing)
Concurrency model Cooperative (single-threaded) Preemptive (OS threads) Parallel (separate processes)
Best for I/O-bound tasks I/O-bound + blocking calls CPU-bound tasks
Memory Low (single process) Medium (shared memory) High (separate memory)
GIL limitation One thread, so no CPU parallelism No CPU parallelism on the default build (see note) Not affected
Overhead Very low Moderate High
Race conditions Fewer (single-thread) Common (shared state) Fewer (separate memory)
Example use 1000 concurrent API calls 10 blocking I/O threads 4 CPU cores for ML inference

GIL note (3.13+): CPython now ships an optional free-threaded build (PEP 703; experimental in 3.13, officially supported but still not the default in 3.14) where threads can run Python code in parallel. The default build still has the GIL, and many C extensions need to opt in, so treat it as something to evaluate, not assume.

When to Use Each in Agent Systems:

# ✅ Async: Default choice for agent systems
async def agent_handler(request):
    user_data = await db.get_user(request.user_id)
    context = await rag.retrieve(request.query)
    response = await llm.generate(context, user_data)
    return response

# ✅ Threading: When you must use blocking (I/O) libraries
async def run_blocking_code():
    # 3.9+: runs in the loop's default ThreadPoolExecutor
    return await asyncio.to_thread(blocking_io_function, arg1, arg2)

# ✅ Multiprocessing: For CPU-bound agent tasks (parsing, embeddings on CPU)
from concurrent.futures import ProcessPoolExecutor

process_pool = ProcessPoolExecutor(max_workers=4)   # create once, reuse

async def process_batch(items: list) -> list:
    loop = asyncio.get_running_loop()               # not get_event_loop() inside a coroutine
    # Note: run_in_executor(None, ...) uses a THREAD pool, not a process pool.
    return await loop.run_in_executor(
        process_pool, cpu_intensive_batch, items   # function and args must be picklable
    )

Figure: pick the concurrency tool by workload type.

flowchart TD
  A["Work to run concurrently"] --> B{"I/O-bound?"}
  B -- "yes" --> C{"Async library available?"}
  C -- "yes" --> D["asyncio (default for agents)"]
  C -- "no, blocking library" --> E["asyncio.to_thread"]
  B -- "no, CPU-bound" --> F["ProcessPoolExecutor via run_in_executor"]

5. COMMON PITFALLS

5.1 Blocking the Event Loop

# ❌ BAD: Blocks the event loop for 2 seconds
async def bad_agent():
    import time
    time.sleep(2)  # Blocks ALL other async tasks!
    return "result"

# ✅ GOOD: Non-blocking sleep
async def good_agent():
    await asyncio.sleep(2)  # Yields control — other tasks run
    return "result"

5.2 Forgetting to Await

# ❌ BAD: Returns coroutine instead of result
async def bad():
    result = fetch_data()  # Forgot await!
    return result  # Returns coroutine object, not data!

# ✅ GOOD: Awaits the coroutine
async def good():
    result = await fetch_data()
    return result  # Returns actual data

5.3 Mixing Sync and Async Incorrectly

# ❌ BAD: Calling async function from sync code without event loop
def sync_function():
    result = async_function()  # Returns coroutine, not result!
    # ❌ Can't use await here

# ✅ GOOD: Use asyncio.run() at the boundary
def sync_function():
    result = asyncio.run(async_function())  # Works correctly
    return result

# ✅ BETTER: Make the whole call chain async if possible
async def better_sync_function():
    return await async_function()

5.4 Fire-and-Forget Without Tracking

# ❌ BAD: Fire and forget — exception is lost
async def bad():
    asyncio.create_task(some_work())  # If it fails, nobody knows

# ✅ GOOD: Keep a strong reference AND surface failures
class TaskTracker:
    def __init__(self):
        self.tasks = set()

    def create_tracked_task(self, coro):
        task = asyncio.create_task(coro)
        self.tasks.add(task)          # the loop only holds a WEAK ref: an
                                      # unreferenced task can be garbage-collected mid-run
        task.add_done_callback(self._done)
        return task

    def _done(self, task: asyncio.Task):
        self.tasks.discard(task)
        if not task.cancelled() and task.exception() is not None:
            logger.error("background task failed", exc_info=task.exception())

tracker = TaskTracker()

async def good():
    tracker.create_tracked_task(some_work())  # Tracked and logged

Better still, when the work belongs to a request, run it inside a TaskGroup so it can't outlive its parent.

5.5 Unbounded Fan-out

gather(*[call(x) for x in ten_thousand_items]) starts everything at once: you hit provider 429s, exhaust connection pools and memory. Bound it with a Semaphore (as in 3.1) or a fixed pool of worker tasks reading from an asyncio.Queue.


6. PERFORMANCE TUNING

import asyncio

class AsyncAgentTuner:
    """Tune async agent performance."""

    @staticmethod
    def optimal_concurrency(test_fn, min_concurrent=1, max_concurrent=100):
        """Find optimal concurrency level for your API calls."""
        import time

        results = []
        for n in range(min_concurrent, max_concurrent, 10):
            sem = asyncio.Semaphore(n)

            async def limited_call():
                async with sem:
                    start = time.perf_counter()
                    await test_fn()
                    return time.perf_counter() - start

            async def run_batch():
                tasks = [limited_call() for _ in range(100)]
                return await asyncio.gather(*tasks)

            wall_start = time.perf_counter()
            latencies = asyncio.run(run_batch())
            wall = time.perf_counter() - wall_start
            throughput = len(latencies) / wall          # completed calls per second of WALL time
            avg_latency = sum(latencies) / len(latencies)
            results.append((n, throughput, avg_latency))

        # Throughput rises with concurrency until the provider or pool
        # saturates; past that point latency climbs and 429s appear.
        return results  # Plot to find the elbow

    @staticmethod
    def connection_pool_size(max_connections: int = 100):
        """Configure optimal connection pool for HTTP clients."""
        import aiohttp

        connector = aiohttp.TCPConnector(
            limit=max_connections,
            ttl_dns_cache=300,
            force_close=False,
            enable_cleanup_closed=True
        )
        return connector

7. TESTING ASYNC CODE

import pytest

@pytest.mark.asyncio
async def test_calls_run_concurrently(monkeypatch):
    """Concurrency test with a FAKE client: deterministic, no network."""
    class FakeResponses:
        async def create(self, **kwargs):
            await asyncio.sleep(0.2)                 # pretend LLM latency
            return type("R", (), {"output_text": "ok"})()
    monkeypatch.setattr(client, "responses", FakeResponses())

    loop = asyncio.get_running_loop()
    start = loop.time()
    results = await call_llm_parallel(["a", "b", "c", "d"])
    duration = loop.time() - start

    assert results == ["ok"] * 4
    assert duration < 0.5  # ~1x latency (0.2s), not 4x (0.8s)

@pytest.mark.asyncio
async def test_rate_limiter():
    """Test that rate limiter respects limits."""
    limiter = AsyncRateLimiter(rate=100, burst=10)
    calls = []

    async def limited_call(i):
        await limiter.wait_and_acquire()
        calls.append(i)

    # Fire 20 calls quickly
    tasks = [limited_call(i) for i in range(20)]
    await asyncio.gather(*tasks)

    # First 10 should be immediate, rest should be delayed
    assert len(calls) == 20

With pytest-asyncio, set asyncio_mode = "auto" in config to drop the per-test marker. For timing-sensitive tests, prefer fake clocks or loose bounds; CI machines are noisy.


8. NESTED ASYNC FUNCTIONS — Async Inside Async (Deep Dive)

One of the most common questions about async Python is: "How do nested async functions work? What happens technically when I await inside an async def that's already inside another async def?"

Let's trace the exact execution path.

8.1 The Stack of Coroutines

async def inner():
    # Level 3
    await asyncio.sleep(0.1)
    return "inner done"

async def middle():
    # Level 2
    result = await inner()  # <--- Nested await!
    return f"middle got: {result}"

async def outer():
    # Level 1
    result = await middle()  # <--- Another nested await!
    return f"outer got: {result}"

# Entry point
final = asyncio.run(outer())
print(final)  # "outer got: middle got: inner done"

8.2 What Happens Step-by-Step (The Exact Execution Trace)

Time ────────────────────────────────────────────────────────────────►

asyncio.run(outer())
  │
  ├── 1. Creates a NEW event loop (raises if called from a running loop)
  ├── 2. Wraps outer() in a Task (the only Task in this example)
  └── 3. Runs the event loop
        │
        ▼
┌──────────────────────────────────────────────────────────────────┐
│  TASK: outer() entered                                           │
│                                                                  │
│  ── Line: result = await middle() ──                             │
│                                                                  │
│  4. outer() creates middle() coroutine object                   │
│  5. outer() calls middle().__await__() — THIS IS KEY            │
│  6. middle() starts executing                                   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  COROUTINE (same Task): middle() entered                   │  │
│  │                                                            │  │
│  │  ── Line: result = await inner() ──                       │  │
│  │                                                            │  │
│  │  7. middle() creates inner() coroutine object              │  │
│  │  8. middle() calls inner().__await__()                     │  │
│  │  9. inner() starts executing                               │  │
│  │                                                            │  │
│  │  ┌──────────────────────────────────────────────────┐    │  │
│  │  │  COROUTINE (same Task): inner() entered            │    │  │
│  │  │                                                    │    │  │
│  │  │  ── Line: await asyncio.sleep(0.1) ──            │    │  │
│  │  │                                                    │    │  │
│  │  │  10. inner() calls asyncio.sleep(0.1)              │    │  │
│  │  │  11. sleep() creates a Future that will be         │    │  │
│  │  │      resolved in 100ms                            │    │  │
│  │  │  12. inner() awaits the Future → SUSPENDS          │    │  │
│  │  │  13. Control returns to middle()'s await          │    │  │
│  │  │      → middle() also SUSPENDS                      │    │  │
│  │  │  14. Control returns to outer()'s await           │    │  │
│  │  │      → outer() also SUSPENDS                      │    │  │
│  │  │  15. Control returns to the EVENT LOOP            │    │  │
│  │  │                                                    │    │  │
│  │  │  ── Event loop runs OTHER tasks for 100ms ──     │    │  │
│  │  │                                                    │    │  │
│  │  │  16. After 100ms, the Future is resolved           │    │  │
│  │  │  17. Loop resumes the Task: outer→middle→inner     │    │  │
│  │  │  18. inner() resumes, gets None from sleep()       │    │  │
│  │  │  19. inner() returns "inner done"                 │    │  │
│  │  └──────────────────────────────────────────────────┘    │  │
│  │                                                            │  │
│  │  20. middle()'s await resumes with "inner done"           │  │
│  │  21. middle() continues: f"middle got: inner done"        │  │
│  │  22. middle() returns the string                          │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                  │
│  23. outer()'s await resumes with the string                    │
│  24. outer() continues: f"outer got: middle got: inner done"    │
│  25. outer() returns the final result                           │
└──────────────────────────────────────────────────────────────────┘

Figure: awaiting a nested coroutine suspends the whole chain back to the event loop.

sequenceDiagram
  participant E as Event loop
  participant O as outer()
  participant M as middle()
  participant I as inner()
  E->>O: Run Task
  O->>M: await middle()
  M->>I: await inner()
  I->>E: await sleep(0.1) suspends the whole chain
  Note over E: Runs other tasks for 100ms
  E->>I: Future resolved, resume Task
  I-->>M: return "inner done"
  M-->>O: return "middle got: inner done"
  O-->>E: Task finished

8.3 The Call Stack (How It Really Works)

A plain await other_coro() does not create a new task. The awaiting coroutine delegates to the awaited one (like yield from), so the whole chain outer → middle → inner runs inside one Task. When inner hits a real suspension point (a Future that isn't done), the Future is passed up through every frame to the Task, which hands control back to the event loop.

Event loop
   │  task.step()  (resumes the Task)
   ▼
Task(outer)
   │  outer frame ── send() ──► middle frame ── send() ──► inner frame
   │                                                          │
   │                         awaits sleep() Future (not done) │
   ◄──────────── Future bubbles back up through each frame ───┘
   │
   ▼  Task registers a callback on the Future, returns to the loop
Event loop runs other Tasks; when the Future completes, it resumes Task(outer),
which re-enters outer → middle → inner and continues after the await.

While suspended, the coroutine frames live on the heap and hold no thread stack, which is why 10,000 concurrent idle tasks are cheap. But while running, a deep await chain is resumed frame by frame, and each level counts toward Python's recursion limit, exactly like normal calls.

8.4 The Technical Mechanism: Generators + Yield

async/await is built on the same machinery as generators. await x calls x.__await__() and delegates to that iterator, roughly like yield from. Only the innermost object, an asyncio.Future, actually yields, which suspends the whole chain:

# Conceptually (not literally what CPython emits):
async def my_coro():
    result = await other_coro()
    return result

# behaves like a generator-based coroutine:
def my_coro_gen():
    result = yield from other_coro().__await__()   # delegate until it returns
    return result

# And asyncio.Future.__await__ is, in essence:
def __await__(self):
    if not self.done():
        yield self            # hand the Future to the Task / event loop
    return self.result()      # resumed after the loop marks it done

8.5 The Event Loop's Perspective

# Simplified event loop: what asyncio's BaseEventLoop does, minus details
class EventLoop:
    def __init__(self):
        self._ready = collections.deque()   # callbacks ready to run now
        self._scheduled = []                # heap of (when, callback) timers

    def run_forever(self):
        while True:
            timeout = self._time_until_next_timer()  # 0 if _ready is non-empty
            events = self._selector.select(timeout)  # epoll/kqueue: wait for I/O
            self._process_io_events(events)          # their callbacks -> _ready
            self._move_due_timers_to_ready()
            for _ in range(len(self._ready)):
                callback = self._ready.popleft()
                callback()        # usually Task.__step: resume a coroutine
                                  # until its next suspension point

A Task is the bridge: its __step resumes the coroutine with send(); when the coroutine yields a Future, the Task adds add_done_callback(self.__wakeup) and returns. When the Future completes, the wake-up callback is put on _ready. Nothing runs in parallel; everything is "run until the next await".

8.6 What This Means for Deeply Nested Async

async def very_deep(n: int):
    if n == 0:
        await asyncio.sleep(0)
        return "base"
    return await very_deep(n - 1)   # recursive await, all in ONE task

asyncio.run(very_deep(900))    # fine
asyncio.run(very_deep(5000))   # RecursionError (default limit 1000), same as sync recursion

Measured on CPython 3.14: very_deep(900) succeeds and very_deep(1100) raises RecursionError, matching a plain recursive function. Recursive await is not a way around the recursion limit. If you genuinely need deep recursion, convert it to iteration, or break the chain by running sub-steps as separate tasks.

Property Sync call chain await chain (one Task) Separate Tasks
Counts toward recursion limit Yes Yes, while running No (each task starts a fresh chain)
Can be suspended mid-chain No Yes, at any await Yes
Memory while idle Thread stack held Heap frames only Heap frames + Task object
Runs concurrently with siblings No No Yes

8.7 Nested Async in Agent Systems

# Real-world example: an agent with deeply nested async calls

class AIAgent:
    async def handle_request(self, query: str) -> str:
        """Entry point — called by the API server."""
        context = await self._gather_context(query)
        plan = await self._make_plan(context)
        result = await self._execute_plan(plan)
        return await self._format_response(result)

    async def _gather_context(self, query: str) -> dict:
        """Collect all context needed — runs sub-tasks in parallel."""
        user_task = self._get_user_context(query)
        kb_task = self._search_knowledge_base(query)
        hist_task = self._get_conversation_history(query)

        # Nested await + gather = 3 levels of async nesting
        user, kb, hist = await asyncio.gather(
            user_task, kb_task, hist_task
        )

        return {"user": user, "kb": kb, "history": hist}

    async def _get_user_context(self, query: str) -> dict:
        """Fetch user data — another nested call."""
        user_id = await self._extract_user_id(query)

        # Even deeper nesting with parallel calls
        profile, prefs, perms = await asyncio.gather(
            self._db.get_user_profile(user_id),
            self._db.get_user_preferences(user_id),
            self._auth.get_permissions(user_id),
        )

        return {"profile": profile, "prefs": prefs, "perms": perms}

Nesting depth in this example: handle_request → _gather_context → _get_user_context → _db.get_user_profile → (some DB driver's async call) → (socket I/O). That's about 6 levels of nested await, trivially within limits. Note where the concurrency actually comes from: the two gather calls each create Tasks; the plain nested awaits just run inside their parent's Task.

8.8 Performance Characteristics of Nested Async

Rough numbers measured on CPython 3.14 on a laptop (order of magnitude only; measure on your own hardware):

Operation Approximate cost
await of a coroutine that returns immediately tens of nanoseconds
Creating and running a Task (via gather) about a microsecond
Size of a coroutine object (sys.getsizeof) ~200 bytes, plus its frame

Compared with a 1–30 s LLM call, async overhead is noise. What actually hurts agent latency is accidental serialization (awaiting in a loop when calls are independent), blocking calls on the loop, and unbounded fan-out hitting rate limits.

8.9 Common Nested Async Patterns

# Pattern 1: Sequential (natural await chain)
async def sequential():
    a = await step1()
    b = await step2(a)    # Depends on step1
    c = await step3(b)    # Depends on step2
    return c

# Pattern 2: Parallel within sequential (gather inside nested)
async def parallel_nested():
    # Step 1: do A and B in parallel
    a, b = await asyncio.gather(get_a(), get_b())

    # Step 2: use results in parallel calls
    results = await asyncio.gather(
        process_a(a),
        process_b(b),
        compute_derived(a, b)  # Depends on both
    )
    return results

# Pattern 3: Dynamic nesting (loop with awaits)
async def dynamic_nesting(items: list):
    """Process N items with varying numbers of steps per item."""
    async def process_one(item):
        # Each item might need different steps
        if item.type == "simple":
            return await quick_process(item)
        elif item.type == "complex":
            data = await fetch_details(item)
            return await analyze(data)
        else:
            return await default_process(item)

    # Process all items concurrently
    return await asyncio.gather(*[
        process_one(item) for item in items
    ])

# Pattern 4: Cancellation propagation
async def cancellable_deep():
    """When cancelled, all nested awaits also get cancelled."""
    try:
        result = await asyncio.wait_for(
            deep_operation(),
            timeout=5.0
        )
        return result
    except asyncio.TimeoutError:
        # deep_operation was cancelled → all its nested awaits cancelled too
        return "Timed out"

8.10 Key Takeaways for Nested Async

Takeaway Why It Matters
A nested await is not a new task The chain runs inside one Task; only create_task, gather and TaskGroup create concurrency
Deep await recursion still hits the recursion limit Frames are resumed one inside another while running
Idle coroutines are cheap Suspended frames sit on the heap; thousands of waiting tasks are fine
Each await on an unfinished Future is a suspension point The event loop can interleave other work only there
Cancellation propagates down the chain Cancelling the Task raises CancelledError at the innermost await
Exceptions propagate up naturally try/except around the outer await catches inner exceptions
Overhead is negligible next to I/O Nanoseconds per await vs seconds per LLM call

8.11 What Interviewers Probe Next

  • "How do you find what's blocking the loop?" Enable debug mode (PYTHONASYNCIODEBUG=1 or asyncio.run(..., debug=True)), which logs callbacks slower than loop.slow_callback_duration (100 ms by default). Python 3.14 adds python -m asyncio ps <PID> / pstree <PID> to inspect running tasks of a live process.
  • "How do you shut down cleanly?" On SIGTERM stop accepting work, cancel or drain in-flight tasks with a deadline, and make sure handlers re-raise CancelledError.
  • "Why is my async service slow at 100 concurrent requests?" Usually a sync client hiding in the path (requests, a sync DB driver, a CPU-heavy JSON parse), or a connection pool smaller than your concurrency.

9. QUICK REFERENCE

# ─── Key Functions ─────────────────────────────────

asyncio.run(main())                           # Run async from sync (creates a new loop)
await coroutine                                # Wait for result (same task)
asyncio.create_task(coro)                      # Run concurrently (keep a reference!)
async with asyncio.TaskGroup() as tg: ...      # Structured concurrency (3.11+)
asyncio.gather(*tasks, return_exceptions=True) # Parallel execution, partial results
async with asyncio.timeout(10): ...            # Deadline (3.11+)
asyncio.wait_for(coro, timeout=10.0)          # Deadline (older style)
asyncio.shield(coro)                           # Protect inner work from outer cancellation
asyncio.to_thread(blocking_fn, *args)          # Offload blocking I/O (3.9+)
asyncio.get_running_loop()                     # Inside coroutines (not get_event_loop())
asyncio.sleep(0.1)                            # Non-blocking sleep

# ─── Async Context Managers ───────────────────────

async with aiohttp.ClientSession() as session:  # Async context manager
    async with session.get(url) as response:    # Nested async ctx
        data = await response.json()

# ─── Async Iterators ──────────────────────────────

async for chunk in stream_response():           # Streaming
    process(chunk)

# ─── Async Queues ─────────────────────────────────

queue = asyncio.Queue(maxsize=100)
await queue.put(item)                           # Producer
item = await queue.get()                        # Consumer

# ─── Synchronization ──────────────────────────────

lock = asyncio.Lock()
sem = asyncio.Semaphore(10)  # Max 10 concurrent
event = asyncio.Event()

Next: Agent Observability → Debugging, monitoring, and production observability for agent systems