Async Python
⏱️ Async Python
threading and multiprocessing — two ways to run work concurrently using multiple OS threads or processes. This chapter covers a third model: asyncio, which runs on a single thread but juggles many I/O-bound tasks through cooperative multitasking on an event loop, using async/await.
🔤 async/await Basics
async def defines a coroutine function — calling it doesn't run the body immediately, exactly like calling a generator function (Course 2, Chapter 5) doesn't run its body immediately either. It only produces a coroutine object; something has to actually drive that coroutine for its code to run.
Calling a coroutine function without await-ing it (or otherwise scheduling it) doesn't run its code at all — Python creates the coroutine object and, since nothing holds onto it or drives it, it's simply discarded, usually with a RuntimeWarning: coroutine 'say_hello' was never awaited. This is one of the most common async mistakes: always await a coroutine call (or pass it to asyncio.gather(), below) — never call it bare.
▶️ Running Async Code: asyncio.run()
asyncio.run() is the top-level entry point for async code — it creates an event loop, runs the given coroutine to completion, then shuts the loop down. It should be called once, at the very top of a program (analogous to if __name__ == "__main__": from Chapter 3's multiprocessing example) — not from inside other async code.
🤹 Running Tasks Concurrently: asyncio.gather()
This produces the same ~2-second result as Chapter 3's threaded download example — but using a single thread the whole time. asyncio.gather() schedules all three coroutines onto the event loop; whenever one hits await asyncio.sleep(2), it voluntarily hands control back to the loop, which runs another coroutine in the meantime.
🔁 The Event Loop
The event loop is the single-threaded scheduler underneath every asyncio program. Unlike threading's OS-level preemptive scheduling (where the operating system can interrupt a thread at any instant), asyncio uses cooperative scheduling: a coroutine only ever gives up control at an explicit await point. Between awaits, a coroutine runs uninterrupted — no other coroutine can sneak in, which sidesteps threading's race-condition problem (Chapter 3) entirely, without needing a single Lock.
Async Models: Rust vs Node.js vs Python
| Language | Approach |
|---|---|
| Rust | Futures are lazy — creating one does nothing until it's driven by a runtime like tokio (rust3-3), and Rust ships no built-in runtime at all, a deliberate "bring your own executor" design. |
| Node.js | The event loop is built directly into the runtime (node1-1) — there's no separate "start the loop" call needed; async code just runs, since the whole runtime is event-loop-based from the start. |
| Python | Coroutines, like Rust's futures, are lazy — async def alone does nothing until awaited or run — but unlike Rust, asyncio ships as a built-in standard-library runtime, so no third-party executor is required. |
Python's laziness (a coroutine call does nothing on its own) is a genuine parallel to Rust's own lazy futures — both are a real contrast to Node.js, where the event loop is simply always running underneath everything, with no equivalent "did I forget to await this?" trap.
async def
Defines a coroutine function — calling it creates a coroutine object, doesn't run it.
await
Runs a coroutine, pausing here and handing control back to the event loop until it resolves.
asyncio.run()
The top-level entry point — starts the loop, runs one coroutine, stops the loop.
asyncio.gather()
Runs multiple coroutines concurrently on one thread via cooperative scheduling.
💻 Coding Challenges
Challenge 1: A Basic Coroutine
Write an async function brew_coffee() that prints "Brewing...", awaits asyncio.sleep(1), then prints "Coffee ready!". Run it with asyncio.run().
Goal: Practice the basic async def/await/asyncio.run() mechanics.
Challenge 2: Timed Sequential vs Concurrent Coroutines
Using this chapter's download(name) coroutine, time how long it takes to await it 3 times sequentially inside one async def main(), versus running the same 3 calls via asyncio.gather(). Print both elapsed times.
Goal: See the concurrency benefit of gather() firsthand, directly paralleling Chapter 3's own threading-vs-sequential timing challenge.
Challenge 3: Fix the Forgotten await
Given a coroutine function greet(name) that prints a greeting, write an async def main() that calls it WITHOUT await (reproducing this chapter's warn-box mistake), observe that nothing prints, then fix it.
Goal: See the "forgot to await" trap firsthand and confirm the fix.
🎯 What's Next
Next chapter: Type Hints & Static Typing — the typing module, mypy, Protocol, and generics.