Async and await¶
async/await is Python's third concurrency tool, and the one that scales I/O-bound work the furthest. It runs everything on a single thread, with no GIL contention and no locks — yet it can juggle thousands of simultaneous connections. The trick is cooperative multitasking: tasks voluntarily hand control back whenever they hit a wait, and a scheduler called the event loop runs whichever task is ready.
That cooperation is also the catch. A task only yields control at an await. If any piece of code runs for a long time without awaiting — a heavy computation, or a blocking call like time.sleep — it freezes every other task. The whole notebook comes down to: await the right things, and never block the loop.
Unlike threads and processes, these examples do run in the in-browser sandbox. Because the notebook already runs inside an event loop, the cells use
await main()directly; in a normal.pyscript you'd writeasyncio.run(main())instead — shown in comments throughout. The one exception is the finalasyncio.to_threadexample: it hands work to a real thread, so it's marked "runs locally only" and has no Run button.
Coroutines: async def and await¶
A function defined with async def is a coroutine function. Calling it does not run the body — it returns a coroutine object, much like calling a generator function returns a generator. To actually run it, you await it (from inside another coroutine) or hand it to the event loop.
import asyncio
async def greet(name):
print(f'hello, {name}')
await asyncio.sleep(1) # yields control for ~1s instead of blocking
print(f'goodbye, {name}')
return name.upper()
coro = greet('Ada')
print(type(coro)) # a coroutine object — body hasn't run yet
result = await coro # in a .py script: asyncio.run(greet('Ada'))
print('returned:', result)
The key line is await asyncio.sleep(1). This is the async cousin of time.sleep — but where time.sleep blocks the whole thread, asyncio.sleep yields to the event loop, letting other tasks run during the second. Inside async code you must use the async-aware versions of waits (asyncio.sleep, async libraries' I/O calls); a stray time.sleep freezes everything.
asyncio.run is the front door¶
In a script, asyncio.run(main()) starts the event loop, runs the main() coroutine to completion, and shuts the loop down. It's the single entry point from ordinary synchronous code into the async world. You call it once, at the top of your program.
async def main():
await greet('Ada')
if __name__ == '__main__':
asyncio.run(main()) # the one place sync code enters async code
Because this notebook is already inside a running loop, calling asyncio.run here would raise RuntimeError: asyncio.run() cannot be called from a running event loop. That's why the cells await directly instead.
Running things concurrently with gather¶
Awaiting coroutines one after another is still sequential — each finishes before the next starts. To overlap them, hand several to asyncio.gather, which schedules them all on the loop at once and waits for them all, returning their results in order.
async def fetch(url):
print(f'start {url}')
await asyncio.sleep(1) # pretend this is a 1s network call
print(f'finish {url}')
return f'{url} -> ok'
async def main():
import time
start = time.perf_counter()
results = await asyncio.gather(
fetch('A'), fetch('B'), fetch('C'),
)
print(results)
print(f'elapsed: {time.perf_counter() - start:.2f}s') # ~1s, not 3s
await main() # in a .py script: asyncio.run(main())
Three one-second waits finished in about one second — they overlapped. Notice all three start lines print before any finish: each fetch ran up to its await, yielded, and the loop moved on to the next.
create_task schedules work to run in the background¶
gather is convenient when you have the list of coroutines up front. When you want a coroutine to start running now while you do other things, wrap it in a task with asyncio.create_task. The task begins on the next await; you collect its result by awaiting the task later.
async def main():
task = asyncio.create_task(fetch('background')) # scheduled to run
print('task created; doing other work meanwhile')
await asyncio.sleep(0.2)
print('other work done; now waiting on the task')
result = await task # get its result
print(result)
await main()
A coroutine you simply call and never await or wrap in a task never runs — and Python warns coroutine was never awaited. Creating a task is also how you get true overlap between a background job and foreground work.
TaskGroup: the modern way to run a batch (Python 3.11+)¶
asyncio.TaskGroup is the recommended structured replacement for gather. You create tasks inside an async with block; the block doesn't exit until they all finish. Its big advantage is error handling: if one task fails, the others are cancelled and the error propagates cleanly, so you never leak half-finished tasks.
async def main():
results = []
async with asyncio.TaskGroup() as tg: # Python 3.11+
for url in ('A', 'B', 'C'):
tg.create_task(fetch(url))
# the 'async with' block exits only when all tasks are done
print('all tasks complete')
await main()
On Python 3.10 or earlier, use asyncio.gather instead. For new code on 3.11+, prefer TaskGroup for anything beyond a one-off gather.
Bounded concurrency with a Semaphore¶
"Fetch 10,000 URLs concurrently" rarely means all at once — you'd exhaust sockets or get rate-limited. A Semaphore caps how many tasks are in the critical section simultaneously: acquire before the work, release after, and only N proceed at a time.
async def fetch_limited(url, sem):
async with sem: # at most N of these run concurrently
await asyncio.sleep(0.5)
return f'{url} done'
async def main():
sem = asyncio.Semaphore(3) # cap at 3 in flight
urls = [f'url-{i}' for i in range(9)]
results = await asyncio.gather(*(fetch_limited(u, sem) for u in urls))
print(f'{len(results)} done') # 9 tasks, 3 at a time -> ~1.5s
await main()
Timeouts: don't let one slow task stall the batch¶
Wrap an await in asyncio.timeout (3.11+) to cancel it if it runs too long; it raises TimeoutError. On older versions, asyncio.wait_for(coro, timeout) does the same job.
async def slow():
await asyncio.sleep(5)
return 'eventually'
async def main():
try:
async with asyncio.timeout(1): # 3.11+; else: await asyncio.wait_for(slow(), 1)
await slow()
except TimeoutError:
print('gave up after 1s')
await main()
The cardinal rule: never block the loop¶
Everything above relies on tasks yielding at await. A long synchronous call — time.sleep, a CPU-heavy loop, a blocking requests.get, a synchronous DB driver — doesn't yield, so it freezes every task until it returns. This is the single most common async bug.
When you must call blocking code from async, push it onto a thread with asyncio.to_thread, which runs it in a thread pool and gives you back an awaitable. (For CPU-bound work, send it to a ProcessPoolExecutor via loop.run_in_executor instead — threads won't help there.)
import time
def blocking_call(n):
time.sleep(1) # a synchronous library you can't change
return n * 2
async def main():
# WRONG: calling blocking_call(3) directly would freeze the loop for 1s.
# RIGHT: run it in a worker thread so the loop stays responsive.
result = await asyncio.to_thread(blocking_call, 3)
print('result:', result)
await main()
Recap¶
async defdefines a coroutine; calling it returns a coroutine object that does nothing until awaited.asyncio.run(main())is the one entry point from sync code (in a notebook,await main()).- Use async-aware waits (
asyncio.sleep, async I/O libraries) — atime.sleepfreezes the loop. - Overlap work with
gather, background it withcreate_task, and preferTaskGroup(3.11+) for batches. - Bound fan-out with a
Semaphore; bound duration withasyncio.timeout/wait_for. - Never block the loop — offload blocking calls with
asyncio.to_thread.
That completes the tour of all three tools. The Recipes put them to work on concrete tasks, and the Concepts explain the GIL and how to choose between the three for any given problem.