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.
awaiton 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 heavyforloop) freezes the whole loop — everything stalls. Use async libraries (asyncio.sleep,httpx.AsyncClient,aiofiles) or push blocking work toasyncio.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 thangatherfor "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 defwithoutawaitorcreate_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 orto_thread. - No speedup vs a plain loop — the work is CPU-bound, or
CONCURRENCYis 1, or you'reawaiting each item in aforloop instead of creating tasks first. RuntimeError: This event loop is already running— callingasyncio.run()inside a context that already has a loop (Jupyter, some web frameworks). Useawait main()directly, ornest_asyncioin notebooks.- File descriptors / "too many open files" — no semaphore, or the client
isn't reused (
AsyncClientcreated 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_taskwhose exception nobody retrieves. UseTaskGroup, orgather, or add a done-callback that logs.
Related tools and reading¶
- On-site: Port Checker, HTTP Header Analyzer.
- Related posts: subprocess without shell injection, argparse patterns for CLI tools.
Related tools
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
Python· 3 min read
Python Type Hints That Actually Pay Off
Type hints earn their keep at function boundaries and data models. Here's a pragmatic subset, a CI setup, and how to adopt them on an untyped codebase.
October 24, 2026Python· 3 min read
Packaging a Python CLI
Turn a script into a `pip install`-able command: the pyproject.toml, the entry point, the src layout, and how users should install it.
October 24, 2026Python· 4 min read
Profiling Python Code
Don't guess at what's slow — measure. cProfile for a script, py-spy for a live process, memray for memory. Here's how to run each and read the output.
October 24, 2026