Building Scalable I/O‑Bound Apps with Python asyncio: A Practical Guide
Learn how to use Python 3.10+ asyncio to build high‑throughput, single‑threaded I/O services. The article walks through event‑loop setup, task scheduling, cancellation, structured concurrency, and common pitfalls with concrete code examples.
20 Sept 2025, 16:14 UTC

Desired Outcome
Build a single‑threaded, non‑blocking server that can handle thousands of concurrent TCP connections, gracefully cancel long‑running coroutines, and offload blocking work without blocking the event loop.
Prerequisites
- Python 3.10 or newer (3.11+ recommended for
asyncio.TaskGroup).
Why? TaskGroup adds structured concurrency support. - Basic knowledge of async/await syntax and coroutine functions.
What is a coroutine? A function defined withasync defthat can be awaited. - Understanding of the
asyncioevent loop and its single‑threaded cooperative multitasking model.
Why single‑threaded? It avoids the GIL overhead of threads while still scaling to thousands of I/O tasks. - Optional:
aiohttporasyncpgfor HTTP or database integration.
These libraries already use asyncio under the hood.
Step‑by‑Step Procedure
- Entry point –
asyncio.run()Use
asyncio.run(main())as the program’s entry point. It creates a fresh event loop, runsmain, and closes the loop automatically, ensuring no stray tasks remain.import asyncio async def main(): # your async logic here pass if __name__ == "__main__": asyncio.run(main()) - Start a TCP echo server
Leverage
asyncio.start_serverto create a non‑blocking server. The handler echoes received data back to the client.async def handle_echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): try: while True: data = await reader.read(1024) if not data: break writer.write(data) await writer.drain() except asyncio.CancelledError: # Clean up if the task is cancelled writer.close() await writer.wait_closed() raise async def start_server(host: str = "127.0.0.1", port: int = 8888): server = await asyncio.start_server(handle_echo, host, port) async with server: await server.serve_forever() - Structured concurrency with
TaskGroupWrap long‑running operations inside a
TaskGroupso that all child tasks are awaited or cancelled together.async def worker(): await asyncio.sleep(10) # Simulate work async def main(): async with asyncio.TaskGroup() as tg: tg.create_task(worker()) tg.create_task(worker()) # All workers finished or raised - Offloading blocking work
When you need to run a CPU‑bound or blocking I/O function (e.g.,
time.sleepor a legacy socket call), useloop.run_in_executor.def blocking_task(x: int) -> int: import time time.sleep(5) # Blocking return x * x async def main(): loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, blocking_task, 7) print(result) - Graceful cancellation
When canceling a task, catch
asyncio.CancelledErrorinside the coroutine to release resources, then re‑raise to propagate the cancellation.async def long_task(): try: await asyncio.sleep(100) except asyncio.CancelledError: print("long_task cancelled – cleaning up") raise async def main(): task = asyncio.create_task(long_task()) await asyncio.sleep(1) task.cancel() try: await task except asyncio.CancelledError: print("task fully cancelled") - Windows event‑loop policy
On Windows,
ProactorEventLoopis default but may lack support for some socket APIs. Switch toSelectorEventLoopif needed.import asyncio if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
Expected Checks
- Loop responsiveness: After offloading a blocking call, verify that a quick
await asyncio.sleep(0)still returns immediately. Usetime.monotonic()to measure. - Task cleanup: After cancellation, ensure no
asyncio.CancelledErrorremains unhandled and thatloop.is_running()returnsFalseafterloop.close(). - Resource leaks: Confirm that all
StreamWriterobjects are closed viawriter.wait_closed()and that no sockets remain open after server shutdown. - Scalability test: Spin up 1000+ concurrent clients connecting to the echo server and observe CPU usage remains low (<10%) and latency stays under 5ms.
Recovery Options
- If a coroutine raises an unexpected exception, the
TaskGroupwill propagate it to the parent, allowing a singleexceptblock to handle all errors. - For unhandled
CancelledErrorleaks, wrap the coroutine body in atry/exceptthat logs the event and re‑raises. - When the event loop hangs after a cancellation, explicitly await
asyncio.gather(*asyncio.all_tasks(), return_exceptions=True)before closing the loop. - On Windows, if the server fails to accept new connections, switch the loop policy as shown above.
Practical Verification
Run the following command to test the echo server with 1000 clients:
python -m asyncio_echo_server && python -m asyncio_clients --count 1000
Check the console for "All clients responded" and monitor top or htop for CPU usage.
Limitations & Considerations
- Asyncio is single‑threaded; CPU‑bound workloads still block the loop. Use
concurrent.futures.ProcessPoolExecutorormultiprocessingfor such tasks. - Python <3.10 lacks
TaskGroup; emulate structured concurrency with manual task tracking. - The global event‑loop policy can affect third‑party libraries. Change it early in the program before importing such libraries.
- On Windows, some low‑level socket options may not work with
ProactorEventLoop.
Conclusion
By following this guide you can build a robust, scalable I/O‑bound service in Python 3.10+. The key takeaways are to use asyncio.run for a clean entry point, schedule tasks with TaskGroup for structured concurrency, offload blocking work with run_in_executor, and handle cancellation explicitly to avoid resource leaks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.