48.2 async def and await: Writing Coroutines
At the heart of modern Python asyncio lies the async def and await syntax, introduced in Python 3.5. This syntactic sugar provides a clear and intuitive way to define and work with coroutines, replacing the previous generator-based syntax (@asyncio.coroutine and yield from). A coroutine is a special kind of function that can suspend its execution before reaching return, passing control back to the event loop, and then later resume from where it left off. This suspension and resumption is the mechanism that enables cooperative concurrency, allowing a single thread to handle thousands of seemingly simultaneous connections or tasks.
Defining Coroutines with async def
The async def statement is used to define a coroutine function. Syntactically, it looks like a regular function definition but is prefixed with the async keyword. The key difference is that when called, a coroutine function does not execute its body immediately. Instead, it returns a coroutine object. This object is an awaitable; its execution must be driven by the event loop, either by awaiting it directly or by scheduling it as a task.
import asyncio
# A simple coroutine function
async def fetch_data(delay, id):
print(f"Task {id}: starting fetch with delay {delay}")
await asyncio.sleep(delay) # Simulate an I/O-bound operation
print(f"Task {id}: fetch completed")
return {"data": f"result from task {id}"}
# Calling the function returns a coroutine object, it doesn't run the code.
coroutine_obj = fetch_data(1.0, 99)
print(f"Type of fetch_data(): {type(coroutine_obj)}") # <class 'coroutine'>
To actually run the code inside the fetch_data coroutine, you must await it or pass it to an event loop function like asyncio.run().
The await Keyword and Awaitables
The await keyword is used to suspend the execution of the current coroutine until the provided awaitable object completes and returns a result. An awaitable is any object that can be used in an await expression. The three main types of awaitables are:
- Coroutines: Objects returned by
async deffunctions. - Tasks: Created by
asyncio.create_task()to schedule coroutines to run concurrently. - Futures: Lower-level objects that represent an eventual result, typically used by event loop internals.
When a coroutine hits an await expression, it signals to the event loop: “I’m going to be idle waiting for this result; please go run something else that is ready in the meantime.” This is the cooperative part of cooperative multitasking.
import asyncio
async def main():
# Correct: awaiting the coroutine to get its result.
result = await fetch_data(0.5, 1)
print(f"Main received: {result}")
# Pitfall: Forgetting 'await' returns a coroutine object, not the result.
wrong_result = fetch_data(0.1, 2)
print(f"Main received without await: {wrong_result}") # Prints a coroutine object
# Run the main coroutine
asyncio.run(main())
Chaining and Composing Coroutines
Coroutines can await other coroutines, creating chains of asynchronous operations. This composition is a fundamental pattern for building complex async applications. The event loop manages the entire chain of suspensions and resumptions.
import asyncio
async def simulate_api_call():
await asyncio.sleep(1)
return "API Response"
async def process_data():
# This coroutine awaits another coroutine
raw_data = await simulate_api_call()
print(f"Processing: {raw_data}")
return raw_data.upper()
async def main():
# Awaits a coroutine that itself awaits another coroutine.
processed_data = await process_data()
print(f"Final result: {processed_data}") # "FINAL RESULT: API RESPONSE"
asyncio.run(main())
Common Pitfalls and Best Practices
A frequent and often subtle mistake is forgetting the await keyword. This does not cause an immediate error; the code will run but will not behave as intended. The coroutine object is never awaited, so the asynchronous operation it represents never executes. This often manifests as operations that seem to be “skipped” or happen out of order.
async def incorrect_main():
# This creates three coroutine objects but never awaits them.
# The sleeps never happen, and the prints execute immediately.
asyncio.sleep(1); print("This prints immediately")
asyncio.sleep(1); print("This also prints immediately")
# The program exits before any sleep is actually performed.
# The correct way is to await each operation.
async def correct_main():
await asyncio.sleep(1); print("This prints after 1s")
await asyncio.sleep(1); print("This prints after another 1s")
Another crucial best practice is understanding that await is a suspension point, but the code surrounding it is not automatically concurrent. If you await operations sequentially, they run sequentially. To achieve concurrency, you must create Tasks.
async def sequential():
# This takes ~2 seconds total. The second sleep starts only after the first finishes.
start = asyncio.get_running_loop().time()
await asyncio.sleep(1)
await asyncio.sleep(1)
end = asyncio.get_running_loop().time()
print(f"Sequential took: {end - start:.2f} seconds")
async def concurrent():
# This takes ~1 second total. The tasks run concurrently.
start = asyncio.get_running_loop().time()
task1 = asyncio.create_task(asyncio.sleep(1))
task2 = asyncio.create_task(asyncio.sleep(1))
await task1
await task2
end = asyncio.get_running_loop().time()
print(f"Concurrent took: {end - start:.2f} seconds")
asyncio.run(sequential())
asyncio.run(concurrent())
Finally, it’s important to note that while async def defines a coroutine, not every function that performs I/O should be async. The async ecosystem uses a specific library (e.g., aiohttp for HTTP) that is built on top of asyncio. Using a blocking library (e.g., requests) inside a coroutine will halt the entire event loop, defeating the purpose of async and destroying concurrency. Always use async-native libraries for I/O operations within coroutines.