Python Asyncio
Tutorial 45 of 65 · pythondeck.com Python course
asyncio provides single-threaded concurrency through coroutines defined with async def and awaited with await. Ideal for many concurrent I/O operations (HTTP, DB, sockets). Run with asyncio.run and combine tasks with gather.
Asyncio lets one thread juggle thousands of I/O-bound tasks—network calls, disk reads, timers—without spawning a thread per connection. That model matches how modern services spend most of their time waiting on sockets and databases, not crunching CPU.
Learning the event loop, coroutines, and await teaches you when concurrency helps and when threads or processes are still the right tool. Misusing asyncio for CPU-heavy work is a common source of mysteriously slow programs.
Coroutines — functions defined with async def that suspend at await and resume when I/O completes.
Event loop — schedules ready tasks; in scripts you often use asyncio.run(main()) as the single entry point.
Tasks and TaskGroup — structured concurrency: run coroutines concurrently and cancel siblings on failure (Python 3.11+).
asyncio.gather / create_task — fan-out work; understand return order and exception propagation.
Sync bridges — asyncio.to_thread for blocking libraries; never call blocking I/O directly inside coroutines without offloading.
Timeouts and cancellation — asyncio.wait_for, shielding, and cooperative cleanup in finally blocks.
Under the hood, await points yield control to the loop, which picks another ready coroutine. The loop is single-threaded for your Python code; parallelism for CPU work still means ProcessPoolExecutor or native extensions. Libraries like aiohttp and asyncpg assume you never block the loop.
Design services as small awaitable steps with clear cancellation: closing HTTP sessions, flushing buffers, and releasing locks in async with context managers. For mixed codebases, isolate asyncio behind an async API and keep the rest synchronous until you can migrate hot paths.
Calling time.sleep or synchronous requests.get inside coroutines, freezing the entire loop.
Creating unbounded tasks without backpressure, leading to memory growth and thundering herds on retries.
Mixing multiple event loops across threads without understanding loop affinity.
Treating asyncio as a drop-in speed boost for NumPy-heavy numerical loops.
Profile first: if the bottleneck is CPU, use multiprocessing; if it is I/O, asyncio or threads may win.
Prefer async with for clients and servers; centralize session lifecycle in one place.
Set explicit timeouts on all external calls and log slow operations.
Use structured concurrency (TaskGroup) instead of fire-and-forget tasks when errors must cancel peers.
Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.
The program below demonstrates coroutine. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# Example: Coroutine
# Run in the REPL or save as a .py file and execute with python.
import asyncio
async def hello(name, delay):
await asyncio.sleep(delay)
print("hi", name)
async def main():
await asyncio.gather(
hello("A", 0.2),
hello("B", 0.1),
hello("C", 0.3),
)
asyncio.run(main())
This sample walks through task in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.
# Example: Task
# Run in the REPL or save as a .py file and execute with python.
import asyncio
async def work(n):
await asyncio.sleep(0.1)
return n * 2
async def main():
tasks = [asyncio.create_task(work(i)) for i in range(5)]
print(await asyncio.gather(*tasks))
asyncio.run(main())
Here is a hands-on illustration of timeout. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.
# Example: Timeout
# Run in the REPL or save as a .py file and execute with python.
import asyncio
async def slow():
await asyncio.sleep(5)
async def main():
try:
await asyncio.wait_for(slow(), timeout=0.5)
except asyncio.TimeoutError:
print("too slow")
asyncio.run(main())
The program below demonstrates gather tasks. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# asyncio runs concurrent I/O on one thread with an event loop
import asyncio # async runtime
async def fetch(n): # coroutine simulating I/O
await asyncio.sleep(0.1) # non-blocking wait
return n * 2 # result
async def main(): # entry coroutine
results = await asyncio.gather(*(fetch(i) for i in range(3))) # parallel
print(results) # [0,2,4]
asyncio.run(main()) # start loop until main completes
This sample walks through create task in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.
# create_task schedules coroutine concurrently
import asyncio # asyncio
async def ticker(): # periodic logger
for i in range(3): # three ticks
print("tick", i) # stdout
await asyncio.sleep(0.05) # yield control
async def main(): # runner
task = asyncio.create_task(ticker()) # schedule background work
await asyncio.sleep(0.12) # do other work briefly
await task # ensure ticker finished
asyncio.run(main()) # run program
Continue with these focused follow-up lessons on Python Asyncio: