Python Async/Await: Write Non-Blocking Code from Scratch
Imagine you're a chef in a busy kitchen. You don't stand and watch the oven for 20 minutes while your pasta boils on another burner. Instead, you start the oven, move to the pasta, then check the salad -- all without waiting idle. That's exactly what asynchronous programming does for your code.
What Is Asynchronous Programming?
In regular (synchronous) Python, each line runs one after the other. If one line takes 5 seconds -- like downloading a file -- everything else just waits. Asynchronous programming lets your program start a slow task, move on to other work, and come back when the slow task finishes.
Python's async/await syntax, introduced in Python 3.5, gives you a clean way to write non-blocking code. It's built on top of the asyncio library, which provides the event loop that coordinates all the concurrent tasks.
Coroutines: The Building Blocks
A coroutine is a special function declared with async def. When you call it, it doesn't run immediately. Instead, it returns a coroutine object -- a promise that the function will run when you schedule it.
The await Keyword
The await keyword pauses the current coroutine until the awaited coroutine finishes. You can only use await inside an async def function.
Running Coroutines with asyncio.run()
asyncio.run() is the main entry point for async programs. It creates an event loop, runs your top-level coroutine, and shuts everything down cleanly when done.
Concurrent Tasks with asyncio.gather()
The real power of async shows up when you run multiple tasks at once. asyncio.gather() takes multiple coroutines and runs them concurrently. It's like putting three dishes in the oven at the same time instead of cooking them one by one.
# Takes 3 seconds total
async def main():
a = await fetch_user(1) # 1 sec
b = await fetch_user(2) # 1 sec
c = await fetch_user(3) # 1 sec
return [a, b, c]# Takes ~1 second total
async def main():
a, b, c = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3),
)
return [a, b, c]Let's simulate this difference to see the speed-up:
Async For and Async Context Managers
Python also provides async for to iterate over asynchronous iterators and async with for asynchronous context managers. These are useful when each iteration or resource setup involves I/O.
The Event Loop: Orchestrating It All
The event loop is the conductor of the async orchestra. It keeps track of all running tasks, decides which one to wake up next, and handles I/O notifications from the operating system. You rarely interact with the loop directly -- asyncio.run() manages it for you.
Practice Exercises
Write an async def function called greet that takes a name parameter and returns the string Hello, <name>! Welcome to async Python. (where <name> is replaced with the argument). Then, outside the function, print the type of the object returned by calling greet('World') (do NOT await it).
What does the following code print?
import asyncio
async def add(a, b):
return a + b
result = add(3, 4)
print(type(result).__name__)Create a class called Countdown that implements the async iterator protocol. It should:
1. Accept a start value in __init__
2. Implement __aiter__ returning self
3. Implement __anext__ that counts down from start to 1, raising StopAsyncIteration when done
After the class, print the string Countdown ready to confirm your class is defined.
The code below tries to define an async context manager but has two bugs. Fix them so that the output shows:
Entering context
Doing work
Exiting contextSince we can't run async code directly, the fixed class should print Context manager ready after definition.
Write a function called simulate_gather that takes a list of regular (synchronous) functions, calls each one, collects the results into a list, and returns that list. This simulates what asyncio.gather() does. Then call it with three lambda functions: lambda: 'A', lambda: 'B', lambda: 'C' and print the result.
The synchronous code below processes users sequentially. Refactor it to use async def syntax that *would* run concurrently with asyncio.gather(). Keep the same logic but:
1. Make fetch_user an async def
2. Make process_all an async def that uses await on each fetch_user call
3. After the definitions, print Refactored to async to confirm.