Skip to content

Python · Concurrency & Parallelism

Python asyncio Explained — Coroutines, the Event Loop, await, and Running Tasks Concurrently

4 min read Updated 2026-06-19 Share:

Practice asyncio & async/await interview questions

Python asyncio, explained

asyncio is Python's answer to I/O-bound concurrency — handling thousands of network connections, file reads, or API calls without spawning a thread per task. It trips people up because it introduces three new ideas at once: coroutines, await, and an event loop. This guide separates them so you can reason about what actually runs when.

What a coroutine actually is

Defining a function with async def doesn't run anything — it creates a coroutine function. Calling it returns a coroutine object, a paused computation that does nothing until it's driven by an event loop.

async def fetch(name):
    print(f"start {name}")
    return name.upper()

coro = fetch("a")        # nothing printed yet — just a coroutine object
print(coro)              # <coroutine object fetch at 0x...>

That last line even warns you the coroutine was never awaited. A coroutine is inert; it needs something to drive it.

await suspends, it doesn't block

await does two things: it runs another awaitable to completion, and — crucially — it yields control back to the event loop while waiting. That's the whole point. While one coroutine is parked on a slow network read, the loop runs others.

import asyncio

async def task(name, delay):
    await asyncio.sleep(delay)      # yields to the loop; doesn't block the thread
    print(f"{name} done after {delay}s")
    return name

async def main():
    result = await task("a", 1)     # runs to completion before continuing
    print(result)

asyncio.run(main())

Note await asyncio.sleep(1) is non-blocking, whereas time.sleep(1) would freeze the entire loop. Mixing blocking calls into async code is the #1 asyncio mistake.

The event loop is a single thread

There is one thread and one event loop. It keeps a queue of ready tasks and runs them one at a time; each task runs until it hits an await that can't complete immediately, then the loop switches to another ready task. This is cooperative multitasking — tasks must yield via await for anything else to run.

Because it's single-threaded, you don't need locks for ordinary in-memory state, and there are no race conditions between await points. But CPU-heavy code still blocks everything.

Running things concurrently with tasks

Awaiting coroutines one after another is sequential. To overlap them, wrap each in a task (scheduled on the loop) and await them together with asyncio.gather.

import asyncio, time

async def work(n):
    await asyncio.sleep(1)
    return n * 2

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(work(1), work(2), work(3))
    print(results, f"in {time.perf_counter() - start:.1f}s")   # [2,4,6] in ~1.0s

asyncio.run(main())

Three one-second sleeps finish in ~1 second, not 3, because they overlap. gather returns results in argument order regardless of which finished first.

Tasks vs coroutines

asyncio.create_task(coro) schedules a coroutine to run right away in the background and returns a Task handle. A bare coroutine only runs when awaited. Use tasks when you want work to start before you await its result.

async def main():
    t = asyncio.create_task(work(10))   # starts running now
    await asyncio.sleep(0.5)            # do other things meanwhile
    print(await t)                      # collect the result later

asyncio.run(main())

Modern code often prefers asyncio.TaskGroup (3.11+), which awaits a set of tasks and cancels the rest if one fails — safer structured concurrency than raw gather.

Running blocking code without freezing the loop

When you must call a blocking function (a sync DB driver, heavy CPU work), offload it to a thread or process pool so the loop stays responsive.

import asyncio

def blocking_io():
    with open("big.log") as f:
        return len(f.read())

async def main():
    size = await asyncio.to_thread(blocking_io)   # runs in a worker thread
    print(size)

asyncio.run(main())

asyncio.to_thread is the clean way to bridge sync and async. For CPU-bound work, use loop.run_in_executor with a ProcessPoolExecutor to escape the GIL.

Where asyncio fits

asyncio shines for I/O-bound, high-concurrency workloads — web servers, scrapers, chat backends, anything waiting on many sockets at once. It is not a speedup for CPU-bound work (use multiprocessing) and adds real complexity, so don't reach for it when a handful of threads or a simple sequential script would do.

Recap

async def creates coroutines — inert until driven by the event loop, which runs on a single thread. await runs an awaitable and yields control while waiting, letting other tasks run cooperatively. To overlap work, schedule coroutines as tasks (or use gather/TaskGroup); awaiting them one by one stays sequential. Never call blocking functions directly inside async code — use asyncio.to_thread or an executor. asyncio is the right tool for massive I/O concurrency, and the wrong one for CPU-bound speed.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel