Python asyncio skill for Codex
Concurrency that cancels cleanly and never blocks. Codex scans async def bodies for blocking calls with a bundled ast-based script before touching anything, then restructures fan-out work into TaskGroups with semaphores or queue-fed worker pools. It handles ExceptionGroups with except*, keeps CancelledError propagating, stores references to background tasks, and proves the speedup with timings instead of assuming it.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- python-asyncio Installs to ~/.agents/skills/python-asyncio/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install python-asyncio The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project python-asyncio Installs to .agents/skills/python-asyncio/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/python-asyncio.zipunzip -o python-asyncio.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) python-asyncio PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $python-asyncio, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and fixes Python asyncio code - TaskGroup structured concurrency, timeouts, cancellation that propagates correctly, bounded concurrency with semaphores and queues, offloading blocking calls with to_thread, and debugging stuck or slow event loops. Use when async Python code hangs, leaks tasks, swallows exceptions, runs slower than sync code, mixes requests or time.sleep into async def, or when the user asks to parallelize I/O with asyncio.
Use it when
- Async Python code hangs, leaks tasks or loses exceptions
- You need bounded concurrent I/O over many items
- Timeouts or cancellation do not stop work the way you expect
- Blocking libraries are being called from coroutines
Use something else when
- The code is slow because of CPU work or you need to find where time goes: use python-profiling
- You are designing FastAPI routes and dependencies rather than concurrency: use fastapi-services
How to check the result
The bundled blocking-call scanner exits clean, a debug-mode run shows no slow-callback warnings on the hot path, and timings show requests overlapping.
The mistake it prevents
Calling requests or time.sleep inside async def, which freezes every other coroutine on the loop while it waits.
Done well means: every task has an owner that awaits it, cancellation and timeouts reach
every coroutine, concurrency is bounded, and nothing in an async def blocks the loop for
more than a few milliseconds.
Before changing anything
- Read
AGENTS.mdandrequires-python. TaskGroup andasyncio.timeout()need 3.11+; on 3.10 useanyiotask groups orasyncio.wait_forinstead. - Identify the framework that owns the loop (FastAPI/Starlette, aiohttp, Django async,
a worker). Never call
asyncio.run()inside code that already runs in a loop. - Check whether the project uses
anyioortrio. If so, write anyio APIs, not raw asyncio. - Run the blocking-call scanner over the code you will touch:
python3 scripts/find_blocking_calls.py src/
It parses files with ast and lists time.sleep, requests.*, subprocess.run,
open(), sync DB drivers and similar calls made directly inside async def. Exit code 1
means findings. It cannot see blocking calls hidden inside your own sync helpers; follow
those by hand.
Decision table
| Need | Use | Avoid |
|---|---|---|
| Run N coroutines, fail fast if one fails | asyncio.TaskGroup | gather without handling the rest |
| Run N, collect every result and error | gather(*aws, return_exceptions=True) then inspect | Ignoring the exception objects in the result list |
| Deadline on a block of awaits | async with asyncio.timeout(5): | wait_for around each call separately |
| Limit concurrent requests | asyncio.Semaphore(n) or a worker pool over asyncio.Queue | Creating 10,000 tasks at once |
| Call blocking library code | await asyncio.to_thread(fn, *args) | Calling it directly in async def |
| CPU-heavy work | loop.run_in_executor(ProcessPoolExecutor(), fn, arg) | Threads (GIL) unless on free-threaded 3.13t+ |
| Fire-and-forget background work | A long-lived TaskGroup owned by the app lifespan, or a real job queue | Bare asyncio.create_task() with no stored reference |
| Sync code needs one async result | asyncio.run(coro()) at the top level only | loop.run_until_complete inside running loops |
Structured concurrency with TaskGroup
import asyncio
import httpx
async def fetch_all(urls: list[str], limit: int = 10) -> dict[str, int]:
sem = asyncio.Semaphore(limit)
results: dict[str, int] = {}
async with httpx.AsyncClient(timeout=10) as client:
async def fetch(url: str) -> None:
async with sem:
r = await client.get(url)
results[url] = r.status_code
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch(url))
return results
- When any task raises, the group cancels the others and raises an
ExceptionGroup. Handle it withexcept*:
try:
await fetch_all(urls)
except* httpx.TimeoutException as eg:
log.warning("timeouts: %d", len(eg.exceptions))
except* httpx.HTTPError as eg:
raise RuntimeError("fetch failed") from eg
- One shared client per group, not one per request: connection pooling is most of the speedup.
- For thousands of items, prefer a fixed pool of workers reading from a
Queueover one task per item, so memory stays flat.
Worker pool over a queue
async def worker(q: asyncio.Queue[str], out: list[str]) -> None:
while True:
item = await q.get()
try:
out.append(await process(item))
finally:
q.task_done()
async def run(items: list[str], n: int = 8) -> list[str]:
q: asyncio.Queue[str] = asyncio.Queue(maxsize=n * 2)
out: list[str] = []
async with asyncio.TaskGroup() as tg:
workers = [tg.create_task(worker(q, out)) for _ in range(n)]
for item in items:
await q.put(item) # backpressure via maxsize
await q.join()
for w in workers:
w.cancel()
return out
On 3.13+, q.shutdown() replaces the cancel loop and makes get() raise QueueShutDown.
Cancellation rules
asyncio.CancelledErrorderives fromBaseException.except Exceptiondoes not catch it; a bareexcept:orexcept BaseException:does and must re-raise.- Clean up in
finally, then let cancellation propagate. Neverreturnfrom a handler that caughtCancelledErrorunless you also callasyncio.current_task().uncancel()and know why. asyncio.timeout()turns its internal cancellation intoTimeoutErrorat theasync withboundary. CatchTimeoutError, notCancelledError, around it.- Cleanup that must finish even under cancellation (closing a transaction, releasing a lock in
an external system):
await asyncio.shield(cleanup()), and still bound it with a timeout. - Long CPU loops inside a coroutine never see cancellation; add
await asyncio.sleep(0)per batch, or move the work to an executor.
async def handler(conn) -> None:
try:
async with asyncio.timeout(30):
await conn.process()
except TimeoutError:
await conn.send_error("timeout")
finally:
await conn.close()
Background tasks without leaks
The loop keeps only weak references to tasks. An unreferenced create_task() result can be
garbage collected mid-flight, and its exception is only logged at GC time.
background: set[asyncio.Task[None]] = set()
def spawn(coro) -> None:
task = asyncio.create_task(coro)
background.add(task)
task.add_done_callback(background.discard)
Better: create the TaskGroup in the application lifespan (FastAPI lifespan, aiohttp
cleanup_ctx) and spawn into it, so shutdown waits for or cancels in-flight work.
Mixing sync and async
| Direction | Pattern |
|---|---|
| async calls blocking function | await asyncio.to_thread(fn, arg); default pool is min(32, cpu+4) threads |
| sync code calls coroutine, no loop running | asyncio.run(coro()) once at the entry point |
| sync thread calls into the running loop | asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=5) |
| Library has both APIs | Use the async one (httpx.AsyncClient, asyncpg, psycopg async, redis.asyncio) |
asyncio.Lock, Event and Queue are not thread-safe. Across threads use
loop.call_soon_threadsafe.
Debugging a hung or slow loop
PYTHONASYNCIODEBUG=1 python app.py # logs callbacks slower than 100 ms and never-awaited coroutines
python -X dev app.py # dev mode also enables asyncio debug
python -m asyncio pstree <PID> # 3.14+: live task tree of a running process
asyncio.all_tasks()plustask.print_stack()in a debug endpoint shows where each task waits.- “Task was destroyed but it is pending” means a lost reference or a loop closed with running tasks.
- “coroutine was never awaited” means a missing
await; the call did nothing. - Throughput no better than sequential: look for a shared lock, a per-request client, a blocking call, or a semaphore set to 1.
Testing
import pytest
@pytest.mark.asyncio # pytest-asyncio; or use anyio's pytest plugin
async def test_fetch_all_limits_concurrency(httpx_mock):
...
- Assert timeouts with a tiny budget (
asyncio.timeout(0.01)) rather than sleeping for real. - Test cancellation explicitly: start the task,
task.cancel(), await it insidepytest.raises(asyncio.CancelledError), then assert cleanup ran.
Gotchas
gather()withoutreturn_exceptions=Trueraises the first error but leaves the other tasks running unsupervised.async forover a sync generator is aTypeError; sync iteration of a huge list inside a coroutine is legal and blocks the loop.- DNS resolution,
logginghandlers writing to slow disks, andjson.loadson 50 MB bodies all block. Measure before assuming the network is the bottleneck. asyncio.get_event_loop()in a coroutine: useasyncio.get_running_loop().- Never share one
AsyncClientor DB pool across different event loops (common in tests that create a loop per test).
Verification
python3 scripts/find_blocking_calls.py <changed dirs>exits 0, or every remaining hit is justified (startup code, a CLI path).- Run once with
PYTHONASYNCIODEBUG=1: no slow-callback warnings above 100 ms on the hot path, no never-awaited warnings. - A timing check proves the concurrency: N requests take close to
max(latency) * ceil(N/limit), notsum(latency). - Tests cover the failure path: one task raising cancels its siblings and surfaces the error.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Python asyncio"
short_description: "Concurrency that cancels cleanly and never blocks"
default_prompt: "$python-asyncio This async scraper is no faster than the sync version, figure out why" scripts/find_blocking_calls.py
#!/usr/bin/env python3
"""Flag blocking calls made directly inside `async def` bodies.
Usage: python3 find_blocking_calls.py <path> [<path> ...]
Walks .py files, parses them with ast (no imports executed), and reports calls
that block the event loop when made from a coroutine: time.sleep, requests.*,
urllib.request.urlopen, subprocess.run/call/check_output, socket.create_connection,
sync file I/O via open(), input(), and common sync DB drivers. Calls inside
nested sync functions or lambdas are ignored, as are calls wrapped in
asyncio.to_thread / loop.run_in_executor (the callable is passed, not called).
Exit code: 0 when nothing is found, 1 when findings exist, 2 on usage errors.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
BLOCKING_EXACT = {
"time.sleep": "use await asyncio.sleep()",
"urllib.request.urlopen": "use an async HTTP client (httpx.AsyncClient, aiohttp)",
"subprocess.run": "use asyncio.create_subprocess_exec()",
"subprocess.call": "use asyncio.create_subprocess_exec()",
"subprocess.check_call": "use asyncio.create_subprocess_exec()",
"subprocess.check_output": "use asyncio.create_subprocess_exec()",
"os.system": "use asyncio.create_subprocess_shell() or exec()",
"socket.create_connection": "use asyncio.open_connection()",
"open": "wrap file I/O in asyncio.to_thread() or use aiofiles/anyio",
"input": "read stdin via a thread (asyncio.to_thread(input))",
"psycopg2.connect": "use psycopg (v3) async or asyncpg",
"pymysql.connect": "use aiomysql/asyncmy",
"sqlite3.connect": "use aiosqlite or asyncio.to_thread()",
}
BLOCKING_PREFIX = {
"requests.": "use httpx.AsyncClient or aiohttp",
}
def dotted(node: ast.AST) -> str | None:
parts: list[str] = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
return None
class Finder(ast.NodeVisitor):
def __init__(self, path: Path) -> None:
self.path = path
self.async_depth = 0
self.findings: list[str] = []
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self.async_depth += 1
self.generic_visit(node)
self.async_depth -= 1
def _sync_scope(self, node: ast.AST) -> None:
saved, self.async_depth = self.async_depth, 0
self.generic_visit(node)
self.async_depth = saved
visit_FunctionDef = _sync_scope # type: ignore[assignment]
visit_Lambda = _sync_scope # type: ignore[assignment]
def visit_Call(self, node: ast.Call) -> None:
if self.async_depth:
name = dotted(node.func)
hint = None
if name in BLOCKING_EXACT:
hint = BLOCKING_EXACT[name]
elif name:
for prefix, h in BLOCKING_PREFIX.items():
if name.startswith(prefix):
hint = h
if hint:
self.findings.append(f"{self.path}:{node.lineno}:{node.col_offset + 1}: {name}() in async def -> {hint}")
self.generic_visit(node)
def iter_files(paths: list[str]):
for p in map(Path, paths):
if p.is_dir():
yield from (f for f in p.rglob("*.py") if ".venv" not in f.parts and "node_modules" not in f.parts)
elif p.suffix == ".py":
yield p
def main(argv: list[str]) -> int:
if not argv:
print(__doc__.strip().splitlines()[2], file=sys.stderr)
return 2
findings: list[str] = []
for f in iter_files(argv):
try:
tree = ast.parse(f.read_text(encoding="utf-8"), filename=str(f))
except (SyntaxError, UnicodeDecodeError) as exc:
print(f"{f}: skipped ({exc.__class__.__name__})", file=sys.stderr)
continue
finder = Finder(f)
finder.visit(tree)
findings.extend(finder.findings)
for line in findings:
print(line)
print(f"{len(findings)} blocking call(s) found in async code", file=sys.stderr)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.