Skip to main content

Asynchronous Programming

Source: Fluent Python, 2nd Edition — Chapter 22 by Luciano Ramalho (through sequential awaits in one download task)

Videla & Williams: async is all or nothing. One blocking call stalls the loop; the rest of your async def was theatre.

This chapter: async def / await / async with / async for; the objects that implement them; a thin slice of asyncio (plus aiohttp in the book). Builds on generators (17), with (18), classic coroutines (19). Also: don’t block the loop (run_in_executor), and “How async works and how it doesn’t” later. Hattingh, Using Asyncio in Python. After Selivanov, the docs split app APIs from framework guts. asyncio.Future is that low-level pile now.

Three coroutine kinds (3.5+)​

Native: async def. Always a native coroutine, even with no await. await only inside native coroutines. Delegate with await (like yield from for classic).

Classic: generator + .send / yield. Not driven by await. asyncio dropped them.

Generator-based: @types.coroutine so a generator is awaitable. Still used in Curio/Trio guts. @asyncio.coroutine deprecated 3.8, gone 3.11.

This lesson is native only.

blogdom.py​

Check kw.dev for keywords of length ≤ 4. + means DNS resolved (loop.getaddrinfo); blank means socket.gaierror — might be free. Output order = completion, not keyword order. Wall time ≈ slowest lookup, not the sum.

async def probe(domain: str) -> tuple[str, bool]:
loop = asyncio.get_running_loop() # 3.7+; no running loop → RuntimeError
try: # faster than get_event_loop
await loop.getaddrinfo(domain, None)
except socket.gaierror:
return (domain, False)
return (domain, True)

async def main() -> None:
...
coros = [probe(d) for d in domains] # objects, not yet run
for coro in asyncio.as_completed(coros):
domain, found = await coro # done; await still needed for the value
...
asyncio.run(main())

Guido’s squint: ignore async/await and read it as sequential — except await suspends this probe('if.dev'), the loop drives probe('or.dev'), then resumes when DNS answers.

as_completed here is the asyncio cousin of futures.as_completed.

Awaitable​

for wants iterables; await wants awaitables. Daily:

  1. Native coroutine object (probe(d)).
  2. asyncio.Task from create_task. Schedule without waiting (spinner). await other() when you need the result now.

Lower: __await__ → iterator (Task subclasses Future); C am_await. PEP 492: await is yield from plus “is this awaitable?”

flags with aiohttp​

3.9 stdlib: TCP/UDP, no HTTP. Book uses aiohttp. Happy path first.

download_many stays a plain function so flags.main can call it: return asyncio.run(supervisor(cc_list)).

async def supervisor(cc_list):
async with ClientSession() as session: # all aiohttp I/O lives here
to_do = [download_one(session, cc) for cc in sorted(cc_list)]
res = await asyncio.gather(*to_do) # wait for all; list in *submit* order
return len(res)

download_one awaits get_flag; get_flag does async with session.get(url) as resp: return await resp.read(). You cannot reuse requests.get — it blocks the loop. You cannot reuse the threadpool download_one either: it doesn’t await. All-or-nothing.

save_flag is sync disk I/O. Node people treat that as blocking; CPython often shrugs. Fix: run_in_executor (below).

Humble generators​

You don’t .send. The loop sends. Your awaits form a channel: user coro → aiohttp → low-level generator the loop ticks on sockets/timers (often C). gather / create_task = several such channels, one thread.

async with​

Classic with is __enter__ / __exit__ — not coroutines. DB transactions (asyncpg) need await tr.start() / commit / rollback without stalling everyone. PEP 492: __aenter__ / __aexit__ as coroutines. async with connection.transaction(): .... Same for ClientSession / ClientResponse. (contextlib async generators: later, after async gen functions.)

Quiz

Native coroutines

1 / 2

asyncio.run(main()) where main is async def

Pick an answer to see if you are right.

sorted on ' by' vs '+ax': space (ASCII 32) before + (43), so ' by +ax'. Starter never probes.

flags2 asyncio: semaphore + as_completed​

Same CLI as Chapter 21. asyncio can open connections faster than threads — more DoS-shaped. Test locally. Default max concurrent in the book’s asyncio client is 5 (REMOTE 503s).

gather is useless for tqdm: it waits for all. Use asyncio.as_completed, wrap with tqdm(..., total=len(cc_list)).

Throttle: threadpool used max_workers. Here: asyncio.Semaphore(concur_req). Counter: acquire (coro, may suspend if 0) / release (never blocks). async with semaphore: around get_flag only — this coro waits; the loop keeps running. __aenter__ awaits acquire; __aexit__ releases. BoundedSemaphore forbids the counter from exceeding the start value.

404 → not_found inside download_one. Other ClientResponseError → raise FetchError(cc) from exc (PEP 3134). You cannot key a dict by the objects as_completed yields: asyncio may replace your awaitables with wrappers that still produce the same result. Carry cc on the exception instead.

download_many builds supervisor(...) and asyncio.runs it — main cannot call a coroutine directly.

run_in_executor​

save_flag freezes the whole loop (one thread). Threads: one worker blocks, GIL dropped on I/O. Fix:

loop = asyncio.get_running_loop()
loop.run_in_executor(None, save_flag, image, f'{cc}.gif')

None = the loop’s default thread pool. Pass a ProcessPoolExecutor (created once in supervisor) for CPU Python. Tiny GIFs: no win; 10× bytes or megapixels: yes. asyncio itself wraps blocking socket.getaddrinfo this way. Motor: async façade over threads. Libraries often hide the executor so callers only await.

The flags2 snippet does not await that call — save races with the rest. For “file is on disk before we continue,” await loop.run_in_executor(...).

Two HTTP calls, one task​

Flag bytes then metadata.json for the country name → filename. Threads: two blocking GETs, locals in one function. Callbacks: pyramid of doom. await get_flag then await get_country in the same coroutine — JS and Python both grew await for this.

Don’t gather them if a failed flag makes metadata pointless. Two separate async with semaphore: blocks: hold the permit only while that request runs.

Some APIs mix plain methods and coroutines (e.g. StreamWriter later). Rule of thumb: await awaitables; if it isn’t one, don’t.

Servers next.

Quiz

Semaphore and gather

1 / 2

asyncio.gather(*download_ones)

Pick an answer to see if you are right.