A parcel dashboard asks four services a question. The depot takes a second to answer. The weather service takes two. Meanwhile, the program sits politely in front of each service, doing absolutely nothing else.
Computers are excellent at waiting badly.
Python asyncio provides a way to organize that waiting: start independent operations, let them pause when they cannot progress, and use the gaps to advance other work. The remote services do not become faster. Your program stops insisting that their waiting times take turns.
Let’s build a miniature dashboard, watch its requests overlap, and then give it two things real software needs: a concurrency limit and a timeout.
What Is asyncio?
asyncio is Python’s standard-library framework for asynchronous I/O and concurrent programming with async and await. It is particularly useful when a program spends substantial time waiting for network responses, database operations through compatible drivers, or other supported I/O. [1]
The essential distinction is between concurrency, where multiple operations can be in progress, and parallelism, where computation happens simultaneously. A single event loop can coordinate many waiting operations without running their Python instructions at the same instant. [2]
If these terms are new, our programming languages guide introduces the broader landscape. Here, we will make one model concrete.
One Postal Clerk, Several Deliveries
Imagine a postal clerk who sends a capsule through a pneumatic tube and then stares into the tube until it returns. Only then does the clerk send the next message.
Now let the clerk send several independent messages, sort incoming replies, and return to each job when its next step becomes possible. There is still one clerk. The deliveries can be in transit together.
In our example, the event loop coordinates which task gets to run. A task executes until it completes or suspends; another ready task can then progress. This is cooperative scheduling. A task that keeps the event-loop thread busy prevents its neighbors from running there. [2, 3]
The clerk also needs permission to pause at appropriate points. That brings us to the two keywords.
What Do async and await Actually Do?
An async def declaration creates a coroutine function. Calling that function produces a coroutine object; calling it alone does not run its body. The coroutine needs to be awaited or scheduled. [4]
await waits for an awaitable operation to produce its result. When that operation needs to suspend, control can return to the event loop. An already-completed operation may return immediately: not every await necessarily hands execution to another task. [2, 4]
A Task is the asyncio object that schedules and manages a coroutine’s execution. For our related service calls, we will create tasks inside a TaskGroup. [5]
The distinction matters. Awaiting service A and then awaiting service B still starts B after A finishes. The spelling has become asynchronous; the dependency chain has remained sequential.
Run This: Four Slow Services, Two Different Schedules
Use Python 3.11 or newer. TaskGroup and the timeout context manager used later were introduced in Python 3.11. The examples need no additional packages. [11]
Save this complete script as python_asyncio_demo.py:
"""Compare sequential and concurrent simulated service calls.
Requires Python 3.11 or newer. Standard library only; no network access.
Run: python python_asyncio_demo.py
"""
import asyncio
from time import perf_counter
SERVICES = (
("Depot", 1.0),
("Weather", 2.0),
("Route", 0.6),
("Customs", 1.4),
)
async def get_status(name, delay):
print(f" Requesting {name}")
await asyncio.sleep(delay) # Simulated I/O wait, not a real request.
print(f" Received {name}")
return f"{name}: ready"
async def sequential():
results = []
for name, delay in SERVICES:
results.append(await get_status(name, delay))
return results
async def concurrent():
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(get_status(name, delay), name=name)
for name, delay in SERVICES
]
return [task.result() for task in tasks]
async def main():
for label, operation in (
("Sequential", sequential),
("Concurrent", concurrent),
):
print(f"\n{label}")
started = perf_counter()
results = await operation()
print(f"{label}: {perf_counter() - started:.2f} seconds")
print(results)
if __name__ == "__main__":
asyncio.run(main())
Run it from a terminal with python python_asyncio_demo.py. If your installation uses python3 instead, use that command name.
The services are fictional. asyncio.sleep() stands in for an asynchronous I/O wait, making the experiment repeatable without an Internet connection. No request leaves your computer, and the results are invented status messages.
The sequential run requests one service and waits for its answer before requesting the next. In the concurrent run, the task group starts the independent operations, and the waits overlap.
| Schedule | Approximate waiting time in this simulation |
|---|---|
| Sequential | 1.0 + 2.0 + 0.6 + 1.4 = 5.0 seconds |
| Concurrent, all four allowed to proceed | Around the longest individual wait: 2.0 seconds |
Allow for scheduling and printing overhead. These are expectations for the simulated delays, not network benchmarks or a guaranteed speedup for real applications.
Watch the messages too. Concurrent replies arrive in a different order from the requests: Route has the shortest delay. The final list nevertheless follows the original service order because we read results from the task list in that order.
Completion order and presentation order are different choices. A dashboard can collect replies as they finish while still arranging its final report consistently.
Why Put the Tasks in a TaskGroup?
The async with boundary gives related tasks a shared lifetime. Leaving the group waits for them to finish; the following task.result() calls therefore retrieve completed results.
If a task raises an unexpected exception other than cancellation, TaskGroup cancels its remaining tasks, waits for their cleanup, and normally reports the failures in an exception group. It prevents this batch from quietly abandoning sibling work. It does not roll back actions already performed. [5]
For this dashboard, we will treat a timeout as a reportable outcome inside each task. An unexpected programming error should still escape and fail the batch.
Add a Waiting-Room Limit and a Timeout
Four fictional calls are harmless. A program that starts thousands of real requests can overwhelm its own resources or a remote service.
A semaphore limits how many operations may occupy a protected section. Here, asyncio.Semaphore(2) allows two simulated service calls inside that section at a time. It does not enforce “two requests per second”: concurrency and request rate are separate controls. [6]
Save this second script as python_asyncio_bounded.py, in the same folder as the first file:
"""Bound active simulated calls and apply a per-call timeout.
Requires Python 3.11 or newer. Standard library only; no network access.
Keep this file beside python_asyncio_demo.py.
Run: python python_asyncio_bounded.py
"""
import asyncio
from python_asyncio_demo import SERVICES, get_status
async def safe_status(name, delay, slots):
async with slots:
try:
async with asyncio.timeout(1.5):
return await get_status(name, delay)
except TimeoutError:
return f"{name}: timed out"
async def concurrent():
slots = asyncio.Semaphore(2)
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(safe_status(name, delay, slots), name=name)
for name, delay in SERVICES
]
return [task.result() for task in tasks]
if __name__ == "__main__":
print(asyncio.run(concurrent()))
Run python python_asyncio_bounded.py. Its import reuses the service definitions and simulated operation; importing the first file does not execute its guarded main() entry point.
The final results should be:
['Depot: ready', 'Weather: timed out', 'Route: ready', 'Customs: ready']
Weather needs two simulated seconds, so it exceeds the 1.5-second budget. Handling that expected TimeoutError inside the worker produces a status rather than failing the whole group. [5]
Notice where the timer starts. Because asyncio.timeout() is inside async with slots, the budget excludes time spent waiting for a semaphore slot. A task can spend longer than 1.5 seconds in the overall operation and still succeed. An end-to-end budget would need to include the queueing stage too.
The semaphore bounds active calls, not the number of task objects: this small example still creates one task per service. For a large or continuing stream of jobs, a fixed worker set and a bounded asyncio.Queue can also limit queued work. [7]
The Most Expensive Mistake: Blocking the Event Loop
Putting async before def does not make the function’s contents nonblocking.
Calling time.sleep() in an event-loop task blocks that thread. So can a synchronous HTTP client, an ordinary blocking file operation, or a long calculation. Other tasks cannot progress on that loop until the code gives control back. [3]
Use an asynchronous client or driver for real I/O when appropriate. To integrate a blocking I/O function, await asyncio.to_thread(blocking_function, argument) can run it in a worker thread while the coroutine waits. That bridge uses a thread; it does not rewrite the function into native asynchronous I/O. [5]
Cancelling the coroutine waiting for an already-running thread does not forcibly stop that thread’s function. Its own timeouts and resource management still matter. [8]
For substantial CPU work, consider a suitable process pool or another execution strategy. Simply moving a long Python calculation into an async def function does not distribute it across cores. [3, 8]
Cancellation Is a Request, Not a Time Machine
An asyncio timeout relies on cancellation. It is cooperative, so blocking or cancellation-resistant code can delay the result beyond the nominal deadline. It is not a hard operating-system kill switch. [5]
Use try/finally and appropriate context managers to clean up resources. If you explicitly catch asyncio.CancelledError, normally re-raise it after cleanup; suppressing it can interfere with structured cancellation. [9]
There is another boundary: cancelling a local task does not prove that a remote server abandoned its operation. A purchase may already have completed even though its response did not arrive. Our guide to API idempotency and safe retries explains why blindly repeating that request can create duplicates.
Does One Thread Make Shared State Safe?
Tasks can interleave around suspension points. If one reads shared state, awaits something, and then writes an update, another task may change that state during the gap. A single event-loop thread therefore does not eliminate all logical races. [2]
Prefer clear ownership, return values, and queues where possible; use an asyncio.Lock when a shared invariant needs protection across cooperating tasks. Asyncio’s synchronization primitives are for asyncio tasks, not a substitute for thread synchronization. [6]
Shared objects also remain shared objects. If several jobs unexpectedly edit the same nested list, concurrency may reveal an older mistake in how the data was constructed. The Haunted Python List shows how separate-looking rows can refer to the very same list.
Try Breaking the Dashboard
- Change Weather’s delay to
0.2. It should finish early and stop timing out. - Change the semaphore to
1. Calls inside the protected section must take turns. - Change it to
4. All four simulated calls can enter together, although Weather still exceeds its timeout. - Make one service raise
RuntimeError. Unlike the handled timeout, this unexpected error should fail the task group and cancel unfinished sibling tasks.
For extra diagnostics, use asyncio.run(main(), debug=True) in the first script. Python’s asyncio development tools can help expose slow callbacks and forgotten coroutines. [3]
In a notebook or framework that already runs an event loop, use its supported entry point—often await main() in a notebook cell. Do not nest asyncio.run() inside another running loop in the same thread. [10]
Give Waiting a Useful Job
Asyncio is most useful when independent operations spend meaningful time waiting and the code cooperates with the event loop. A simple sequential program may remain the clearest solution when there is little waiting or no independent work to overlap. [1]
Our dashboard asks the same four questions either way. The improvement comes from changing the schedule: while one reply travels back, another request can already be on its way.
The postal clerk has not grown extra arms. The clerk has finally stopped staring into the tube.
References and Further Reading
- Python documentation: asyncio — Asynchronous I/O.
- Python documentation: A Conceptual Overview of asyncio.
- Python documentation: Developing with asyncio.
- PEP 492: Coroutines with async and await syntax.
- Python documentation: Coroutines and Tasks.
- Python documentation: Synchronization Primitives.
- Python documentation: asyncio Queues.
- Python documentation: concurrent.futures.
- Python documentation: asyncio Exceptions.
- Python documentation: Runners.
- Python documentation: What’s New in Python 3.11—introduction of task groups and the timeout context manager.
