AI ENGINEER00-02 · Async Python
20/90
Sign in

Layer 00 · Foundations · Ch 02

Async Python

How to run many slow calls at once instead of one after another — the pattern almost every agent depends on.

38 min readdifficulty beginnerdepth build

AI applications spend a lot of time waiting.

They wait for a model, a database, a file, or another service to reply. Async Python lets the program use that waiting time to make progress on other work.

It does not make the model faster. It stops one wait from unnecessarily blocking every other independent wait.

Lesson details

Module
AI engineering foundations
Lesson
2 · Async Python
Difficulty
Beginner
Learning time
38 minutes

Before you start

  • Python variables, lists, loops, and functions
  • Python foundations for AI applications

Skills you will gain

  • Explain when async Python is useful
  • Write and await a small coroutine
  • Run independent waits concurrently
  • Limit concurrent work with a semaphore
  • Add a timeout and clean up cancelled work

Why this matters

Imagine a support assistant that needs a customer’s plan, recent tickets, and a classification of their new message. Those three requests are independent. Waiting for each one before starting the next creates a slower answer.

Async code can start all three waits together while still limiting traffic and handling slow services safely.

The intuition

A coroutineCoroutineA function defined with `async def`. Calling it does not run the body — it returns a coroutine object, which only starts executing once you `await` it or schedule it as a task. A coroutine can pause at an `await` and hand control back to the event loop, which is what lets many coroutines make progress on one thread without any of them blocking the others. is a Python function that can pause and resume. It is written with async def.

Concurrency means several tasks make progress during the same period. It does not mean they execute Python instructions at exactly the same instant.

FIG 00-02.1 · SEQUENTIAL VS CONCURRENT WAITINGSEQUENTIAL · await, await, awaittotal: 3.0sCONCURRENT · asyncio.gather(...)total: ~1.0ssame 3 calls, same waiting — 3× faster wall-clock
FIG 00-02.1— three calls, each waiting about one second for a reply. Awaited one after another, the wait time stacks up: 3.0s. Started together with asyncio.gather, the same three waits overlap: ~1.0s. Nothing here got computed faster — the waiting itself got reorganised.

How it works

An async def function is a coroutineCoroutineA function defined with `async def`. Calling it does not run the body — it returns a coroutine object, which only starts executing once you `await` it or schedule it as a task. A coroutine can pause at an `await` and hand control back to the event loop, which is what lets many coroutines make progress on one thread without any of them blocking the others. function. Calling it does not run its body:

async def call_api(name, seconds):
    await asyncio.sleep(seconds)
    return f"{name}: done"

call_api("plan", 1)   # this alone does nothing — it just builds a coroutine object

You get back a coroutine object — a paused, not-yet-started computation — and nothing has happened yet. To actually run it, you await it, or hand it to the scheduler as a task. await is the key word in this whole chapter: it means “pause me here until this is ready, and let something else run in the meantime.” That “something else” is scheduled by the event loopEvent loopThe single-threaded scheduler that runs your coroutines. It keeps a queue of tasks that are ready to make progress; it runs one until it hits an `await` on something not yet finished, then parks it and runs the next ready task. Nothing here runs in parallel — the loop only ever gives the CPU to one task at a time — but no task sits idle waiting for I/O while another task could be making progress. — the single-threaded loop that keeps a list of coroutines that are ready to make progress, runs one until it hits an await on something not yet finished, parks it, and moves on to the next ready one.

FIG 00-02.2 · THE EVENT LOOP DISPATCHES ONE TASK AT A TIMEONE THREAD · ONE TASK RUNS AT A TIMEREADY QUEUETask CEVENTLOOPRUNNINGTask Ahas the CPU nowPARKEDTask Bawait asyncio.sleep(2)not blocking anything elsedispatch next ready taskhits `await`,yields controlI/O completes → rejoins the ready queue
FIG 00-02.2— the loop gives the CPU to a ready task; when that task hits an await on I/O, it parks instead of blocking, and the loop moves to the next ready task. When the parked task's I/O finishes, it rejoins the queue. Exactly one task ever runs at once — the loop only ever switches between them at await points.

In a normal Python script, you start that loop yourself:

import asyncio

async def main():
    result = await call_api("plan", 1)
    print(result)

asyncio.run(main())     # creates the loop, runs main() until it finishes, closes the loop

To run several coroutines concurrently instead of one after another, hand them to asyncio.gather:

results = await asyncio.gather(
    call_api("plan", 1),
    call_api("history", 1),
    call_api("classify", 1),
)

gather schedules all three as tasks right away, then waits for all of them to finish. Because each one spends its second waiting, not computing, the loop interleaves them — all three waits overlap — so the wall-clock total is close to the slowest one (about 1s), not the sum of all three (3s). Results come back in the order you passed the coroutines in, regardless of which one actually finished first.

A small complete example

PythonFetch three independent pieces of support data
import asyncio


async def fetch(name, seconds):
    await asyncio.sleep(seconds)
    return f"{name}: ready"


async def main():
    results = await asyncio.gather(
        fetch("plan", 1),
        fetch("history", 1),
        fetch("classification", 1),
    )
    print(results)


asyncio.run(main())

Important lines

async def fetch(name, seconds):
Defines a coroutine that is allowed to pause.
await asyncio.sleep(seconds)
Represents an external wait and gives control back to the event loop.
await asyncio.gather(...)
Starts the three independent coroutines and waits for every result.
asyncio.run(main())
Creates an event loop for a normal Python script and runs main to completion.

Common modifications include adding another fetch(...) call, giving calls different delays, or wrapping each call in a timeout.

Real APIs come with limits, so unlimited concurrency is not always safe. A semaphoreSemaphoreA counter, shared across coroutines, that limits how many of them may be inside a section of code at once. `asyncio.Semaphore(n)` lets up to `n` coroutines past `acquire()` at the same time; the rest wait until one of the `n` finishes and releases it. Used to cap concurrent calls to a rate-limited API without giving up concurrency altogether. caps how many coroutines may be past a certain point at once:

sem = asyncio.Semaphore(3)          # at most 3 "inside" at a time

async def call_api(name, seconds):
    async with sem:                 # waits here if 3 are already inside
        await asyncio.sleep(seconds)
        return f"{name}: done"

Ten calls can still all be scheduled together with gather; the semaphore just makes the 4th, 5th, … wait their turn until one of the first 3 finishes and frees a slot. You get bounded, not unlimited, concurrency.

Waiting also needs an upper bound in time, not just in count. asyncio.wait_for(coro, timeout=...) raises asyncio.TimeoutError if coro has not finished in time — and it also cancels coro for you:

try:
    result = await asyncio.wait_for(slow_call(), timeout=2)
except asyncio.TimeoutError:
    result = None

CancellationCancellationAsking a running asyncio task to stop via `task.cancel()`. Cancellation is cooperative, not immediate — it raises `asyncio.CancelledError` inside the task at its next `await` point, so the task keeps running until it reaches one. Code that needs to release a resource on cancellation must do it in a `finally` block, since the exception path still runs it. is cooperative, not immediate: calling .cancel() on a task (or timing it out) raises asyncio.CancelledError inside it at its next await point — the task does not stop mid-instruction, it stops the next time it would have paused anyway. Anything that must run no matter what — closing a connection, releasing a lock — belongs in a finally block, because finally still runs as that CancelledError propagates out.

A worked example

Three calls need durations of 1s, 1s, and 2s. You bound concurrency to 2 with a semaphore, and give each call a 1.5s timeout. Trace it by hand:

Task Needs Acquires semaphore at Its timeout deadline What happens
A 1.0s t = 0.0 (slot free) 1.5 finishes at t = 1.0 ✓
B 1.0s t = 0.0 (slot free) 1.5 finishes at t = 1.0 ✓
C 2.0s t = 1.0 (waits for A or B to free a slot) 1.0 + 1.5 = 2.5 needs until t = 3.0, but its own timeout fires at t = 2.5 — cancelled ✗

Two things happen at once and it is worth separating them. First, C cannot even start its network call until t = 1.0, because both semaphore slots are held by A and B until then — that is the semaphore doing its job, not a bug. Second, once C does start, its own 2.0s of work is longer than its 1.5s timeout budget, so it is cancelled at t = 2.5 regardless of the semaphore. Total wall-clock time for the whole batch: 2.5s — worse than the 2.0s you’d get with no semaphore at all, but with the guarantee that never more than 2 calls are in flight, which is often the actual constraint a real API forces on you.

Try it

Three live cells. The first two use asyncio.sleep to stand in for real network waits — long enough to see the effect, short enough not to be annoying.

First, the hook’s sequential loop next to the concurrent version, so you can see both totals from the same run:

LAB 00-02.A · SEQUENTIAL VS CONCURRENT
IDLE⌘↵ to run

Next, a semaphore capping concurrency at 2, with start/finish timestamps so you can see task C wait its turn:

LAB 00-02.B · A SEMAPHORE BOUNDS CONCURRENCY
IDLE⌘↵ to run

Finally, a timeout that cancels a slow call — watch the finally block still run:

LAB 00-02.C · TIMEOUT AND CANCELLATION
IDLE⌘↵ to run

Production reality

Nearly every LLM client and web framework you will use is built on this. FastAPI request handlers, AsyncOpenAI-style SDK clients, and agent frameworks are all async underneath, for the same reason: a server handling many users at once needs exactly this pattern — lots of I/O-bound waiting, happening concurrently, without spinning up a thread per request.

A few things this chapter’s toy examples hide, which matter the moment you ship:

  • Unbounded concurrency gets you rate-limited. asyncio.gather on ten thousand coroutines will fire ten thousand requests at once. Real providers respond with 429 Too Many Requests long before that, and you have gained nothing over a much smaller, well-chosen concurrency limit. A semaphore (or a small pool of workers pulling from a queue) is not an optimisation here — it is what keeps you under the provider’s actual limits.
  • Timeouts are not optional. A dependency that hangs without a timeout can hold a connection open indefinitely. Enough hung requests exhaust your server’s connection pool, and one slow dependency takes the whole service down with it — not just the request that triggered it.
  • A silently-failing background task is a real failure mode. If you asyncio.create_task(...) something and never await or check on it, an exception inside it can vanish with only a "Task exception was never retrieved" warning in the logs — easy to miss until the behaviour it was supposed to produce just never happens. If you want several calls to run concurrently but let some fail without cancelling the rest, use asyncio.gather(..., return_exceptions=True) and check each result explicitly, rather than firing tasks you never look at again.
  • Async does not help CPU-bound work. Tokenising a huge corpus or running a local model’s forward pass does not get faster inside an async def — there is still only one thread, and no amount of waiting-reorganisation helps code that is actually computing the whole time. That needs multiprocessing, or moving the work to a service designed for it (Layer 7 covers serving-level concurrency properly).

That last point has a sharper, more dangerous version: mixing a genuinely blocking call into async code.

Common mistakes

Calling a coroutine function without awaiting it — `call_api("x")` on its own.

Fix — This only builds a coroutine object; nothing runs, and Python prints a "coroutine was never awaited" warning. Await it directly, or wrap it with asyncio.create_task(...) if you want it to start running without blocking here.

Putting a real blocking call — time.sleep, a synchronous requests.get, heavy CPU work — inside an async function.

Fix — There is still only one thread. A genuine blocking call occupies that one thread completely, freezing every other concurrent coroutine, not just this one — unlike asyncio.sleep, which yields control back. Use the async version of the client (an async HTTP library, or the SDK's Async* client), or push blocking work off-thread with await asyncio.to_thread(fn, ...).

Catching asyncio.CancelledError with a broad `except Exception` (or a bare `except:`) and swallowing it.

Fix — This eats the cancellation signal, so the task can keep running when the rest of the program has already moved on and given up on it — and it breaks the caller's ability to actually stop it. Only use CancelledError for cleanup (a finally block, or a narrow except that re-raises); never catch it to silently continue.

Firing unbounded concurrency at a real API: asyncio.gather(*(call_api(u) for u in ten_thousand_urls)).

Fix — This is what actually happens with no cap in place, and it trips rate limits or exhausts local sockets instead of finishing faster. Bound it with a semaphore, or a small fixed-size pool of workers pulling from a queue.

Exercises

warmup · 1

You have three async functions, `fetch_a()`, `fetch_b()`, `fetch_c()`, each of which awaits `asyncio.sleep(1)` and returns a string. Write the code to run all three concurrently and print the total time taken.

Reveal solution
import asyncio, time

async def fetch_a():
    await asyncio.sleep(1)
    return 'a'

async def fetch_b():
    await asyncio.sleep(1)
    return 'b'

async def fetch_c():
    await asyncio.sleep(1)
    return 'c'

start = time.perf_counter()
results = await asyncio.gather(fetch_a(), fetch_b(), fetch_c())
print(results)                                        # ['a', 'b', 'c']
print(f'{time.perf_counter() - start:.1f}s')          # ~1.0s, not 3.0s

All three sleep(1) calls happen during the same one second, because each one yields control back to the event loop instead of blocking it.

core · 2

You must call `await call_api(url)` for 10 URLs, but the provider allows at most 3 requests in flight at once. Rewrite the loop so at most 3 calls ever run concurrently, using a semaphore.

Reveal solution
import asyncio

sem = asyncio.Semaphore(3)

async def call_api(url):
    async with sem:                 # blocks here if 3 are already inside
        return await do_request(url)

async def main(urls):
    return await asyncio.gather(*(call_api(u) for u in urls))

results = await main(urls)

asyncio.gather schedules all 10 coroutines at once, but each one blocks on async with sem: until fewer than 3 are currently inside — so exactly 3 are ever running the request body at a time, and the other 7 wait their turn without you writing any manual queueing.

stretch · 3

Write `fetch_with_timeout(coro, seconds)` that awaits `coro`, but returns `None` instead of raising if it has not finished within `seconds`. Explain what happens to `coro` itself when the timeout fires.

Reveal solution
import asyncio

async def fetch_with_timeout(coro, seconds):
    try:
        return await asyncio.wait_for(coro, timeout=seconds)
    except asyncio.TimeoutError:
        return None

When seconds elapses, asyncio.wait_for cancels the task wrapping coro — this raises asyncio.CancelledError inside coro at its next await point, unwinding it (running any finally blocks on the way), and then wait_for itself raises TimeoutError to the caller, which this function catches and turns into None. The coroutine does not keep running in the background after the timeout; it is actually stopped.

Knowledge check

Knowledge check

1Which task is the best fit for async Python?
2What happens when a coroutine reaches await on unfinished I/O?
3Why place a semaphore around model requests?
4Where should required cleanup happen when a task may be cancelled?

Practice activity

Practice · 10–20 minutes

Fetch three mock AI resources concurrently

Goal: Write a small async program that overlaps three independent waits.

  1. Import asyncio.
  2. Complete the fetch function so it waits without blocking.
  3. Use asyncio.gather to fetch profile, history, and policy.
  4. Print the returned list.
  5. Run the program and confirm the result order.

Expected result: ['profile: ready', 'history: ready', 'policy: ready']

Optional challenge: Give policy a two-second delay, then add a timeout that returns a fallback value.

Reveal solution

Starter code:

import asyncio


async def fetch(name, delay):
    # Pause without blocking, then return "{name}: ready"
    pass


async def main():
    # Run the three fetch calls concurrently.
    pass


asyncio.run(main())
import asyncio


async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name}: ready"


async def main():
    results = await asyncio.gather(
        fetch("profile", 1),
        fetch("history", 1),
        fetch("policy", 1),
    )
    print(results)


asyncio.run(main())

Key takeaways

  • Most AI code is I/O-bound — waiting on models, databases, and tools — so what async saves is idle waiting, not CPU time.
  • async def and await give you coroutines that pause at await points and hand control back to a single-threaded event loop; asyncio.gather runs several concurrently, so wall-clock time approaches the slowest one, not the sum.
  • A semaphore bounds how many calls are in flight at once — essential the moment you call a real, rate-limited API instead of a toy example.
  • Timeouts (asyncio.wait_for) and cancellation (cleaned up in finally) stop one hung call from hanging your whole program — never ship an unbounded-wait network call.
  • A single blocking call inside an async function freezes every other concurrent coroutine, because there is still only one thread — this is the most common way async code breaks in production.

Flashcards

Flashcards

1 of 6

Revision and interview questions

  1. Explain sequential waiting and concurrent waiting using the support-assistant example.
  2. Why does async help network calls but not heavy CPU calculation?
  3. How do gather and a semaphore work together?
  4. What happens during cancellation, and why does finally matter?

One-page sketchnote summary

Print me · one-page revision sheet

Async Python for AI applications

Main idea

Async Python lets one program make progress on other work while a model, database, file, or tool is taking time to reply.

Core terms

  • Coroutine — a function that can pause and resume
  • Await — pause here and let another task run
  • Event loop — schedules ready coroutines
  • Semaphore — limits concurrent access
  • Timeout — an upper limit on waiting

One picture

SEQUENTIAL · await, await, awaittotal: 3.0sCONCURRENT · asyncio.gather(...)total: ~1.0ssame 3 calls, same waiting — 3× faster wall-clock

Code pattern

results = await asyncio.gather(
  fetch("profile"),
  fetch("history"),
)

Watch out

  • Do not forget to await a coroutine
  • Do not put blocking sleep inside async code
  • Do not send unbounded requests
  • Do not swallow cancellation

Remember

  • Overlap independent waits
  • Async reorganises waiting, not calculation
  • Bound concurrency
  • Set timeouts
  • Clean up in finally

Three quick questions

  1. When is async useful?
  2. What does gather return?
  3. Why limit concurrency?

Lesson checklist

0 of 7 complete

Resources

Next up
Modern Python tooling
You now have the async patterns real AI code runs on. Next, the tools — uv, ruff, mypy, pytest, pre-commit — that keep a growing async codebase fast to work in and safe to change.