Async, Await, and Tasks
Understand cooperative scheduling and structure concurrent async operations with explicit task ownership.
By the end, you’ll be able to…
- Distinguish a coroutine object from a scheduled task.
- Use TaskGroup for related concurrent operations.
- Keep blocking work off the event loop.
01Await is a scheduling boundary
Calling async def creates a coroutine object; it does not run the body to completion. An event loop drives coroutines, allowing another task to run when the current task suspends at an await that actually waits. This is cooperative scheduling. Ordinary Python instructions between suspension points still execute synchronously in the event-loop thread.
await does not mean parallel execution. Awaiting one operation and then another usually performs them sequentially. Scheduling both as tasks allows their waits to overlap, but their Python CPU work still competes for the same event-loop thread. Async is useful when libraries expose asynchronous I/O and many operations spend time waiting; it is not a shortcut for accelerating a CPU-heavy loop.
02Own tasks with a structured scope
A task schedules a coroutine for execution and provides a handle to its eventual result. TaskGroup gives related tasks a shared lifetime: leaving the context waits for them, and a non-cancellation failure triggers cancellation of the remaining tasks. This makes it easier to see who owns background work and where its failures will be observed.
Create all independent operations before awaiting the group. Store task handles if you need results in a stable input order; completion order is not guaranteed. When several tasks fail, TaskGroup can raise an ExceptionGroup. Handle the group deliberately when recovery is required instead of assuming every concurrent failure arrives as one ordinary exception.
03Blocking code blocks every task
time.sleep, synchronous urlopen, and a long calculation inside async def still block the event loop. Naming a function async does not transform its operations. Use async-native I/O libraries where appropriate. asyncio.to_thread can move blocking I/O into a worker thread, but the original operation may keep running even if the coroutine awaiting it is cancelled.
For CPU-heavy Python work on conventional CPython, consider a process pool rather than to_thread. Keep event-loop callbacks short and measure responsiveness under realistic load. A progress spinner that updates in a toy example is insufficient evidence that the application stays responsive while parsing a huge response or serializing a large report.
04Choose one application entry point
asyncio.run creates an event loop, runs the top-level coroutine, and performs shutdown. Call it at the boundary of a command-line program, rather than repeatedly inside library functions. Environments with an already running loop, such as some notebooks, usually require awaiting the coroutine directly. Library APIs should communicate whether they are synchronous or asynchronous.
Avoid fire-and-forget tasks whose handles are lost. Keep strong references and arrange shutdown if a task must outlive one request. Define whether failure in one task invalidates the whole operation or merely one result. TaskGroup is a strong default for related work, while a deliberate result-collection policy is needed when partial success is a product requirement.
The ideas worth keeping.
- Coroutine construction, task scheduling, and awaiting are different actions.
- TaskGroup makes related task lifetime and failure ownership visible.
- Async code must avoid blocking the event-loop thread.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Concurrent local lookups
Schedule three async lookups inside one TaskGroup. Return their doubled IDs in original input order.
Explore a worked solution
import asyncioasync def lookup(value): await asyncio.sleep(0) return value * 2async def collect(values): async with asyncio.TaskGroup() as group: tasks = [group.create_task(lookup(value)) for value in values] return [task.result() for task in tasks]assert asyncio.run(collect([3, 1, 2])) == [6, 2, 4]The tasks exist together inside one scope. Reading handles after the context exits preserves input order and observes completed results.
Keep a blocking adapter explicit
Wrap a synchronous fixture reader using to_thread and assert its result. Explain why cancellation would not forcibly stop that reader.
Explore a worked solution
import asynciodef read_fixture(): return {'count': 4}async def load(): return await asyncio.to_thread(read_fixture)assert asyncio.run(load()) == {'count': 4}The adapter makes the boundary visible. A cancellation request stops the awaiter cooperatively; it does not provide a general mechanism to kill work already executing in a thread.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Concurrent local lookups
Schedule three async lookups inside one TaskGroup. Return their doubled IDs in original input order.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.