The decision tree
(1) Is the work I/O-bound (network, files, database)? → asyncio if your stack supports it, threading if it doesn't. (2) Is the work CPU-bound? → multiprocessing. (3) Both? → asyncio for the I/O parts, offload CPU work to a process pool from inside the async function (asyncio.to_thread, loop.run_in_executor).
asyncio.to_thread — bridging sync to async
3.9+ added await asyncio.to_thread(sync_fn, *args). Runs sync_fn in a thread, awaits its completion, returns the result. Perfect for calling a blocking library from async code without blocking the event loop. cwkPippa uses this for the few sync-only operations it has.
The cwkPippa example — what async-all-the-way looks like
cwkPippa is asyncio top to bottom. The web layer (FastAPI) is async. Database access (aiosqlite) is async. The Claude/Codex/Gemini SDKs all expose async streaming. JSONL writes use async file I/O. Every chat request runs as a single async coroutine through the entire stack — no thread pool, no process pool, just the event loop dispatching between thousands of waiting I/Os.
The free-threaded future (3.13+, experimental)
Python 3.13 added an experimental free-threaded build (no GIL). When stable and widely supported, this changes the "CPU-bound use multiprocessing" calculus — threads will actually parallelize. Today (early 2026), this is opt-in and not yet the default. For 99% of code today, the GIL still applies and the multiprocessing-for-CPU answer is correct.
asyncio.wait_for around __anext__ for a per-chunk timeout. When the timeout fired during a tool call, the cancellation finalized the async generator and subsequent next-calls raised StopIteration. The fix was timeout-around-the-whole-stream, not per-chunk. Async cancellation is subtle — treat it like load-bearing structural code.