Server

asyncio for I/O-Bound Scripts

By Prabath Thalangama· October 24, 2026· 3 min read
#python#asyncio#concurrency

Introduction

asyncio lets one thread juggle thousands of in-flight I/O operations by switching whenever one is waiting. It helps I/O-bound work (HTTP, DB, disk, subprocess). It does nothing for CPU-bound work — that needs multiprocessing or a thread pool with a C extension that releases the GIL.

The mental model

  • The event loop runs one coroutine at a time. await on something that isn't ready yields control back to the loop, which runs another ready coroutine.
  • A single blocking call (time.sleep, requests.get, a heavy for loop) freezes the whole loop — everything stalls. Use async libraries (asyncio.sleep, httpx.AsyncClient, aiofiles) or push blocking work to asyncio.to_thread.

A correct bulk-fetch script

import asyncio
import httpx

CONCURRENCY = 20

async def fetch_one(client: httpx.AsyncClient, sem: asyncio.Semaphore, url: str) -> tuple[str, int | str]:
    async with sem:                       # cap in-flight requests
        try:
            r = await client.get(url, timeout=10.0)
            return url, r.status_code
        except httpx.HTTPError as e:
            return url, f"error: {e.__class__.__name__}"

async def main(urls: list[str]) -> None:
    sem = asyncio.Semaphore(CONCURRENCY)
    async with httpx.AsyncClient(follow_redirects=True) as client:
        async with asyncio.TaskGroup() as tg:      # Python 3.11+
            tasks = [tg.create_task(fetch_one(client, sem, u)) for u in urls]
    for t in tasks:
        url, result = t.result()
        print(f"{result}\t{url}")

if __name__ == "__main__":
    urls = [line.strip() for line in open("urls.txt") if line.strip()]
    asyncio.run(main(urls))

Key points:

  • asyncio.run(main()) — one entry point, creates and closes the loop.
  • Semaphore — without it, 10,000 URLs = 10,000 simultaneous sockets = file-descriptor exhaustion and a hammered target. Bound it.
  • TaskGroup (3.11+) — if any task raises, the group cancels the rest and propagates. Cleaner than gather for "all must succeed".
  • gather — use when you want results in order and to control error handling: await asyncio.gather(*coros, return_exceptions=True) returns exceptions as values instead of raising.

Mixing in blocking or CPU work

# Blocking library you can't avoid
result = await asyncio.to_thread(blocking_parse, big_blob)

# CPU-bound: a process pool, driven from async
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
    result = await loop.run_in_executor(pool, cpu_heavy, data)

Timeouts and cancellation

async with asyncio.timeout(30):          # 3.11+; raises TimeoutError
    await slow_operation()

# Cancellation is cooperative — clean up in finally
try:
    await something()
finally:
    await cleanup()

Verification and troubleshooting

python -X dev script.py          # warns on un-awaited coroutines, slow callbacks
PYTHONASYNCIODEBUG=1 python script.py
  • "coroutine was never awaited" — you called an async def without await or create_task. It did nothing.
  • The whole script hangs / one slow item blocks everything — a synchronous blocking call in a coroutine (requests, time.sleep, psycopg2). Swap for the async equivalent or to_thread.
  • No speedup vs a plain loop — the work is CPU-bound, or CONCURRENCY is 1, or you're awaiting each item in a for loop instead of creating tasks first.
  • RuntimeError: This event loop is already running — calling asyncio.run() inside a context that already has a loop (Jupyter, some web frameworks). Use await main() directly, or nest_asyncio in notebooks.
  • File descriptors / "too many open files" — no semaphore, or the client isn't reused (AsyncClient created per request). One client, bounded concurrency.
  • Unclosed session / "Event loop is closed" on exit — resource not in an async with; the loop closed before cleanup. Use context managers.
  • Exceptions vanish — a bare create_task whose exception nobody retrieves. Use TaskGroup, or gather, or add a done-callback that logs.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.