Async Python

Python Advanced — Async Python
Python Advanced
Course 3 · Chapter 4 · Async Python

⏱️ Async Python

Chapter 3 covered 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

import asyncio async def say_hello(): print("Hello...") await asyncio.sleep(1) # a non-blocking "wait" — hands control back to the event loop print("...world!") coro = say_hello() # does NOT run the function yet — just creates a coroutine object print(type(coro)) # <class 'coroutine'>

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.

⚠ Forgetting await Silently Does Nothing
async def main(): say_hello() # WRONG — creates a coroutine object and immediately discards it. Nothing runs!

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(say_hello()) # the entry point — starts the event loop, runs the coroutine, then stops it # Hello... # ...world! (printed ~1 second later)

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()

async def download(name): print(f"{name}: starting") await asyncio.sleep(2) print(f"{name}: done") async def main(): await asyncio.gather( download("file1"), download("file2"), download("file3"), ) asyncio.run(main()) # total time: ~2 seconds, not 6 — all three "downloads" overlap on ONE thread

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

LanguageApproach
RustFutures 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.jsThe 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.
PythonCoroutines, 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.

→ Solution

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.

→ Solution

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.

→ Solution

🎯 What's Next

Next chapter: Type Hints & Static Typing — the typing module, mypy, Protocol, and generics.