Skip to content

async / await

async def defines a coroutine function β€” calling it doesn’t run the body immediately, it returns a coroutine object that must be await-ed or scheduled to actually execute. await pauses the current coroutine at an I/O point, handing control back to an event loop, which runs other coroutines while the first one waits β€” all on a single thread, no GIL contention between them.

Handling many concurrent I/O operations (hundreds of open network connections, for example) with threading means one OS thread per connection β€” real but expensive overhead at scale:

import threading
def fetch(url, results, i):
results[i] = blocking_http_get(url) # blocks this whole thread
results = [None] * 500
threads = [threading.Thread(target=fetch, args=(u, results, i)) for i, u in enumerate(urls)]
for t in threads: t.start()
for t in threads: t.join()
# 500 OS threads is a lot of overhead just to wait on network responses
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks) # runs all requests concurrently
results = asyncio.run(main(urls))
# 500 concurrent requests on ONE thread β€” the event loop switches between
# them at each `await` point instead of needing 500 OS threads
  • Massive I/O concurrency, low overhead: thousands of concurrent coroutines cost far less than thousands of threads β€” no per-thread OS stack, no GIL contention between them (there’s only one thread).
  • Explicit suspension points: await marks exactly where a coroutine can be paused, unlike threads where the OS can preempt at any instruction β€” this makes reasoning about shared state simpler (no lock needed between two await points in the same coroutine, since nothing else runs during that stretch).
  • Purpose-built for I/O-bound, high-concurrency workloads: web servers, API clients, chat/websocket services handling many simultaneous connections.
  • async/await does not help CPU-bound work β€” a coroutine that never awaits (just computes) blocks the entire event loop, starving every other coroutine; use multiprocessing (or run CPU-bound work in a thread/process pool via loop.run_in_executor) for that.
  • Calling an async def function does not run it β€” coro = fetch(...) just creates a coroutine object; you need await coro, asyncio.run(coro), or scheduling via asyncio.gather/create_task to actually execute it. Forgetting this is the single most common asyncio mistake (RuntimeWarning: coroutine was never awaited).
  • Regular (blocking) I/O calls inside an async def still block the whole event loop β€” you need async-native libraries (aiohttp instead of requests, asyncpg/aiomysql instead of blocking DB drivers) to actually get the concurrency benefit.
  • asyncio.gather(*tasks) runs multiple coroutines concurrently and collects all their results; awaiting them one at a time in a loop (for t in tasks: await t) would run them sequentially instead, missing the entire point.
  1. What does calling an async def function actually return, before you await it?

    AnswerA coroutine object β€” the function body hasn't run yet. It only executes once awaited or scheduled on the event loop.
  2. Would async/await speed up a CPU-heavy loop with no I/O in it?

    AnswerNo β€” with nothing to `await`, the coroutine never yields control, so it blocks the single-threaded event loop just like any blocking call would; `multiprocessing` is the right tool for CPU-bound work.
  3. Why does mixing a blocking (non-async) library call inside an async def function defeat the purpose?

    AnswerA blocking call doesn't yield control at an `await` point β€” it freezes the entire single-threaded event loop until it returns, stalling every other coroutine that should have been running concurrently.