asyncio

Part of the free LogicWiz Python course, Module 16: Concurrency. The examples and practice run in the app.

Shankar bhaiya serves 30 tables alone. He takes an order, and while the kitchen cooks he takes the next one instead of standing and watching the tawa. One person, many orders in progress. That is asyncio.

You'll learn to

  • Write coroutines with `async def` and `await`, and run them with `asyncio.run`
  • Run many waits together with `asyncio.gather` and `asyncio.create_task`
  • Add timeouts with `asyncio.wait_for` and limits with `asyncio.Semaphore`
  • Choose between threads, processes and asyncio for a given job

Learn

One worker, many waits

asyncio runs many tasks in one thread. A function written with async def is a coroutine. Inside it, await something means "this will take a while; pause me and run someone else meanwhile".

The event loop is Shankar bhaiya: it keeps a list of tasks, runs one until it hits an await, then switches to another that is ready. Nobody is interrupted in the middle of a line, so races are much rarer than with threads.

The catch: it only helps when tasks wait (network, timers, databases), and everything you call must be await-able. And unlike threads, asyncio runs right here in the browser.

async def, await, asyncio.run

import asyncio


async def notify(name):
    print("texting", name)
    await asyncio.sleep(0.1)          # waiting for the SMS gateway
    return f"{name}: sent"


c = notify("Riya")
print(type(c).__name__)               # calling it only MAKES a coroutine
print(asyncio.run(c))                 # asyncio.run starts the loop and runs it

Calling an async def function does not run its body; it returns a coroutine object. Something must await it, or asyncio.run(...) must run it. Use asyncio.sleep, never time.sleep, inside async code.

One by one vs gather

import asyncio
import time


async def notify(name):
    await asyncio.sleep(0.5)
    return f"{name}: sent"


async def one_by_one(names):
    return [await notify(n) for n in names]


async def together(names):
    return await asyncio.gather(*(notify(n) for n in names))


names = ["Riya", "Kabir", "Sneha", "Aarav"]
for runner in (one_by_one, together):
    start = time.perf_counter()
    result = asyncio.run(runner(names))
    print(f"{runner.__name__:<10} {time.perf_counter() - start:.1f} s  {result}")

await in a loop waits for each in turn (2 s). asyncio.gather starts them all and waits for all of them (0.5 s), and gives the results in the order you passed them.

Watch the switching

import asyncio


async def cook(dish, minutes):
    print("start", dish)
    await asyncio.sleep(minutes / 100)
    print("done ", dish)
    return dish


async def main():
    results = await asyncio.gather(cook("Dosa", 3), cook("Chai", 1), cook("Maggi", 2))
    print(results)


asyncio.run(main())

All three start before any finishes. Chai finishes first (shortest wait), but results is still in the order passed to gather.

Tasks and timeouts

import asyncio


async def fetch_menu(seconds):
    await asyncio.sleep(seconds)
    return ["Samosa", "Chai"]


async def main():
    task = asyncio.create_task(fetch_menu(0.05))    # starts now, in the background
    print("doing other work while the menu loads...")
    print("menu:", await task)

    try:
        await asyncio.wait_for(fetch_menu(5), timeout=0.1)
    except TimeoutError:
        print("server too slow, gave up after 0.1 s")


asyncio.run(main())

create_task schedules a coroutine to run as soon as the current one pauses. wait_for(coro, timeout) cancels it and raises TimeoutError if it takes too long.

A Semaphore: at most 2 at a time

import asyncio

gate = asyncio.Semaphore(2)      # the SMS gateway allows 2 messages at once


async def send(name):
    async with gate:
        print("sending", name)
        await asyncio.sleep(0.05)
        print("   sent", name)


async def main():
    await asyncio.gather(*(send(n) for n in ["Riya", "Kabir", "Sneha", "Aarav"]))


asyncio.run(main())

A semaphore is a counter of free slots. async with gate: takes a slot (or waits for one) and gives it back at the end. Real APIs limit how many requests you may send at once.

Two classic async bugs.

  1. Forgetting await: notify("Riya") alone does nothing and Python warns "coroutine was never awaited". Write await notify("Riya").
  2. Blocking the loop: time.sleep(2) or a long calculation inside an async def freezes every task, because there is only one thread. Use await asyncio.sleep(...), and send heavy work to a process pool.

Threads, processes or asyncio?

JobPick
Calculating (primes, images, ML in pure Python)processes: ProcessPoolExecutor
A few dozen waits using normal libraries (requests, files)threads: ThreadPoolExecutor
Thousands of waits (chat server, scraping, notifications)asyncio with async libraries
Shared datathreads need locks; asyncio switches only at await; processes share nothing
Cost of one moreprocess: heavy · thread: medium · task: tiny

In one line

async def makes a coroutine, await pauses it so others run; asyncio.gather runs many together (results in argument order); use wait_for for timeouts, a Semaphore for limits, and never block the loop.

Interview corner

Threads vs processes vs asyncio: when do you use each?

CPU-bound work → processes (ProcessPoolExecutor), because each has its own GIL. I/O-bound work with ordinary blocking libraries → threads (ThreadPoolExecutor). Very many concurrent I/O operations (thousands of sockets) → asyncio, since tasks are far cheaper than threads, but it needs async-aware libraries (aiohttp, asyncpg).

What is an event loop, and what does `await` do?

The event loop is a scheduler running in one thread: it runs a task until the task awaits something that isn't ready, then switches to another ready task, and resumes the first when its I/O or timer completes. await is the point where a coroutine voluntarily yields control. So asyncio is cooperative multitasking; threads are preemptive.

Does asyncio make CPU-bound code faster?

No. It runs in one thread, so a long calculation blocks every other task until it finishes. Offload CPU work with await loop.run_in_executor(process_pool, func, arg) (or asyncio.to_thread for blocking I/O), and keep coroutines to quick steps between awaits.

What is the difference between `asyncio.gather` and `asyncio.create_task`?

create_task(coro) schedules one coroutine to run concurrently and returns a Task you can await or cancel later. gather(*aws) wraps several awaitables, runs them concurrently, and returns their results as a list in argument order; with return_exceptions=True it returns exceptions instead of raising the first one.