Python asyncio: The Event Loop and When It Actually Helps
asyncio gives Python programs the ability to handle many I/O-bound tasks at once without threads. Understanding the event loop, what coroutines actually are, and where asyncio does not help prevents misapplied concurrency in real code.
Python code runs one statement at a time on a single thread. That is fine for most work, but it becomes a bottleneck when a program needs to wait on many external operations at once: network requests, database queries, or file reads. Each wait blocks everything else. asyncio is Python’s standard library answer to this problem. It allows a single-threaded program to suspend one waiting operation, run something else, and return to the first when it is ready, without threads and without processes.
The Event Loop
The event loop is the runtime that makes asyncio work. It is a continuously running scheduler that tracks every task registered with it and decides which one runs next. When a task reaches a point where it must wait (a network response, a sleep, a file read) it signals the event loop and yields control. The event loop then picks up another ready task and runs it until that task also yields or completes.
This model is called cooperative multitasking. Tasks are not interrupted by the runtime. They run until they explicitly hand control back with an await expression. This means the event loop is single-threaded: only one piece of code is ever executing at any moment. There is no parallelism, and no operating system scheduler involved in switching between tasks. The programmer’s await points are the only places where a switch can occur.
asyncio.run() is the standard entry point. It creates an event loop, runs the given coroutine to completion, and then closes the loop.
import asyncio
async def main():
print("start")
await asyncio.sleep(1)
print("end")
asyncio.run(main())
Coroutines and What async/await Actually Means
A coroutine is a function declared with async def. Calling it does not run it. It returns a coroutine object, a description of the work to be done, not the execution of it.
async def fetch():
await asyncio.sleep(0.5)
return "done"
result = fetch()
# result is a coroutine object, nothing has run
To run a coroutine, it must be awaited or scheduled as a Task. await suspends the current coroutine, hands control to the event loop, and resumes when the awaited operation completes. The async def declaration and await keyword work together: await is only valid inside an async def function.
This is not parallelism. Two coroutines do not run simultaneously on two cores. Concurrency here means the event loop interleaves their execution at await points, so that waiting time in one does not block progress in another.
When asyncio Helps: I/O-Bound Concurrent Tasks
asyncio is, as the official documentation states, “a perfect fit for IO-bound and high-level structured network code.” When a program spends most of its time waiting (for a server to respond, a database to return rows, a file read to complete) that waiting time is wasted if only one operation runs at a time.
With asyncio, many waiting operations can be in-flight at once. The event loop suspends each one at its await point and resumes it when the I/O finishes. The total time to complete many such operations is closer to the duration of the longest single operation, rather than the sum of all of them.
A concrete example: fetching data from ten API endpoints sequentially takes ten times as long as one request. With asyncio and concurrent tasks, all ten requests can be in-flight simultaneously. The event loop handles the responses as they arrive.
When asyncio Does Not Help: CPU-Bound Tasks
asyncio does not provide any benefit for CPU-bound work. A coroutine performing a heavy computation (sorting a large dataset, parsing a complex document, running a mathematical simulation) does not await anything. It holds the thread continuously without yielding to the event loop. While that computation runs, every other coroutine is blocked.
For CPU-bound concurrency, the appropriate tools are multiprocessing or concurrent.futures.ProcessPoolExecutor. These create separate processes that run on separate cores, which is genuine parallelism. asyncio is not a substitute for them.
Concurrent Execution: Coroutines vs Tasks
Simply awaiting coroutines one after another is sequential, not concurrent. Each await completes in full before the next line runs.
async def main():
await asyncio.sleep(1) # waits 1 second
await asyncio.sleep(2) # then waits 2 more seconds
# total: 3 seconds
To run coroutines concurrently, they must be scheduled as Tasks before being awaited. asyncio.create_task() wraps a coroutine in a Task and schedules it for execution immediately. asyncio.gather() is the higher-level function for running multiple coroutines or Tasks concurrently and collecting their results.
async def main():
task1 = asyncio.create_task(asyncio.sleep(1))
task2 = asyncio.create_task(asyncio.sleep(2))
await task1
await task2
# total: 2 seconds — both ran concurrently
asyncio.gather()
asyncio.gather(*aws, return_exceptions=False) accepts any number of awaitables, schedules them all as Tasks if they are not already, and returns an aggregated list of results in the same order they were passed. This is the idiomatic way to run several coroutines concurrently and wait for all of them.
import asyncio
async def fetch(label, delay):
await asyncio.sleep(delay)
return f"{label} done in {delay}s"
async def main():
results = await asyncio.gather(
fetch("A", 1),
fetch("B", 2),
fetch("C", 1),
)
print(results)
# ['A done in 1s', 'B done in 2s', 'C done in 1s']
# total elapsed: ~2 seconds, not 4
asyncio.run(main())
By default, if any awaitable raises an exception, it propagates immediately and the others continue running rather than being cancelled. Passing return_exceptions=True causes exceptions to be treated as results and included in the returned list instead of being raised.
Concurrent vs Parallel
These two terms are often used interchangeably, but they describe different things. Concurrent means multiple tasks are in progress at the same time, with their execution overlapping in time, but not necessarily at the exact same instant. Parallel means multiple tasks are executing at the exact same instant on separate processors or cores.
asyncio provides concurrency. At any given moment, only one coroutine is executing. The event loop interleaves tasks at await points, making progress on many things within a single thread. Parallelism requires multiple threads or processes, and in CPython, multiple processes are required for CPU-bound parallelism because of the Global Interpreter Lock.
Understanding this distinction prevents a common mistake: using asyncio to speed up CPU-heavy code and finding no improvement, or worse, finding that a long-running coroutine blocks everything else in the application.
What You Can Do Now
Run the following to observe concurrent I/O behaviour directly. Three coroutines each simulate a different wait time. With asyncio.gather(), all three run concurrently and the total time reflects the longest single wait, not their sum.
import asyncio
import time
async def simulate_request(name, delay):
print(f"{name}: starting")
await asyncio.sleep(delay)
print(f"{name}: finished after {delay}s")
return f"{name}: {delay}s"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
simulate_request("request-A", 1),
simulate_request("request-B", 3),
simulate_request("request-C", 2),
)
elapsed = time.perf_counter() - start
print(f"\nAll done in {elapsed:.2f}s")
print(results)
asyncio.run(main())
# request-A: starting
# request-B: starting
# request-C: starting
# request-A: finished after 1s
# request-C: finished after 2s
# request-B: finished after 3s
#
# All done in ~3.00s ← not 6s
# ['request-A: 1s', 'request-B: 3s', 'request-C: 2s']
Once this runs as expected, replace asyncio.sleep() with a real I/O call (an HTTP request using httpx or aiohttp) and observe the same concurrent behaviour with actual network latency.