Table of Contents

    asyncio

    PYTHON & CONCURRENCY

    Python asyncio — The Complete Guide

    Learn Python's built-in asyncio library: the event loop, coroutines, tasks, gather, and how to write fast non-blocking code.

    Introduction

    asyncio is Python's built-in library for writing asynchronous, concurrent code using the async/await syntax. It provides an event loop that runs many coroutines on a single thread, letting your program handle thousands of I/O operations — network calls, file access, database queries — without blocking or spinning up expensive threads.

    In one line: asyncio lets a single Python thread juggle many I/O-bound tasks concurrently using an event loop.

    Real-World Analogy

    The Restaurant Waiter

    One waiter (the event loop) serves many tables. They take an order at table 1, and while the kitchen cooks, they take orders at tables 2 and 3 instead of standing idle. asyncio is that waiter — one worker efficiently switching between many tasks whenever one is "waiting."

    Prerequisites

    Before You Start

    • Python 3.7+ installed (3.11+ recommended for the latest asyncio features)
    • Understanding of functions and return values
    • Familiarity with async/await and coroutines
    • Basic idea of blocking vs non-blocking I/O
    • Optional: pip install aiohttp for async HTTP examples

    Core Concepts of asyncio

    1

    Event Loop

    The heart of asyncio.

    It schedules and runs coroutines, resuming each one whenever its awaited operation is ready. asyncio.run() creates and manages it for you.

    2

    Coroutine

    Defined with async def.

    A special function you can pause with await. Calling it returns a coroutine object that must be awaited or scheduled to actually run.

    3

    Task

    A coroutine scheduled to run concurrently.

    Created with asyncio.create_task(), a Task starts running in the background on the event loop immediately.

    Your First asyncio Program

    Every asyncio program starts with an async def coroutine, launched by asyncio.run().

    import asyncio
    
    async def main():
        print("Hello")
        await asyncio.sleep(1)   # non-blocking pause for 1 second
        print("World")
    
    asyncio.run(main())          # creates the event loop and runs main()
    Note asyncio.sleep() is non-blocking — during that second, the event loop can run other tasks.

    Running Tasks Concurrently

    The real power of asyncio appears when you run multiple coroutines at once.

    Sequential (slow)

    import asyncio
    
    async def task(name, seconds):
        print(f"Start {name}")
        await asyncio.sleep(seconds)
        print(f"Done {name}")
    
    async def main():
        await task("A", 2)   # waits 2s
        await task("B", 2)   # then another 2s  -> total ~4s
    
    asyncio.run(main())

    Concurrent with gather (fast)

    import asyncio
    
    async def task(name, seconds):
        print(f"Start {name}")
        await asyncio.sleep(seconds)
        print(f"Done {name}")
        return name
    
    async def main():
        # Both run at the same time -> total ~2s, not 4s
        results = await asyncio.gather(
            task("A", 2),
            task("B", 2),
        )
        print(results)
    
    asyncio.run(main())

    Using create_task

    import asyncio
    
    async def worker(n):
        await asyncio.sleep(n)
        return n * 10
    
    async def main():
        # Schedule tasks to start immediately
        t1 = asyncio.create_task(worker(1))
        t2 = asyncio.create_task(worker(2))
    
        # Do other work here if needed...
    
        print(await t1, await t2)   # collect results
    
    asyncio.run(main())

    Essential asyncio Functions

    Function Purpose
    asyncio.run(coro) Entry point — creates the event loop, runs the coroutine, then closes the loop
    asyncio.sleep(s) Non-blocking pause for s seconds
    asyncio.gather(*coros) Run many coroutines concurrently and collect all results
    asyncio.create_task(coro) Schedule a coroutine to run in the background as a Task
    asyncio.wait_for(coro, timeout) Await with a timeout; raises TimeoutError if exceeded
    asyncio.Queue() Async-safe queue for producer/consumer patterns

    Real Example: Concurrent HTTP Requests

    Fetching many URLs concurrently is the classic asyncio use case.

    import asyncio
    import aiohttp
    
    async def fetch(session, url):
        async with session.get(url) as response:
            data = await response.text()
            print(f"{url} -> {len(data)} bytes")
            return data
    
    async def main():
        urls = [
            "https://example.com",
            "https://python.org",
            "https://github.com",
        ]
        async with aiohttp.ClientSession() as session:
            tasks = [fetch(session, url) for url in urls]
            await asyncio.gather(*tasks)   # all three run concurrently
    
    asyncio.run(main())

    Timeouts and Cancellation

    Protect against operations that hang using wait_for.

    import asyncio
    
    async def slow_task():
        await asyncio.sleep(10)
        return "finished"
    
    async def main():
        try:
            result = await asyncio.wait_for(slow_task(), timeout=3)
            print(result)
        except asyncio.TimeoutError:
            print("Task took too long and was cancelled")
    
    asyncio.run(main())

    The Performance Win

    For \(N\) I/O tasks each taking time \(t\), running them sequentially costs:

    \[ T_{\text{sequential}} = N \times t \]

    Running them concurrently with asyncio.gather, the total is close to the longest single task:

    \[ T_{\text{gather}} \approx \max_{1 \le i \le N} t_i \]

    So 100 requests of 1 second each drop from ~100 seconds to roughly ~1 second — that's the asyncio advantage.

    asyncio vs Threading vs Multiprocessing

    Approach Best For Note
    asyncio I/O-bound, high concurrency Single thread, cooperative, very lightweight
    threading I/O-bound, blocking libraries Limited by the GIL for CPU work
    multiprocessing CPU-bound work True parallelism across cores; higher overhead

    Best Practices

    Do This

    • Use asyncio.run() as the single entry point of your program
    • Use gather or create_task to run independent coroutines concurrently
    • Never call blocking functions (like time.sleep or requests.get) inside a coroutine
    • Use async libraries (aiohttp, asyncpg, aiofiles) for I/O
    • Always add timeouts with wait_for to avoid hangs
    • Offload CPU-bound work with loop.run_in_executor or multiprocessing

    Common Mistakes

    Bad Using time.sleep(2) inside a coroutine — it blocks the entire event loop, freezing all tasks.
    Good Using await asyncio.sleep(2) — it yields control so other tasks can run.
    Bad Forgetting to await a coroutine: get_data() alone does nothing and warns "coroutine was never awaited".

    Interview Questions

    Question Short Answer
    What is asyncio? Python's standard library for asynchronous concurrency using an event loop and async/await.
    Difference between a coroutine and a task? A coroutine is defined with async def; a task is a scheduled coroutine running on the loop.
    What does asyncio.gather do? Runs multiple coroutines concurrently and returns all their results.
    Why not use time.sleep in async code? It blocks the event loop; use await asyncio.sleep instead.
    Is asyncio good for CPU-bound work? No — it's for I/O-bound concurrency; use multiprocessing for CPU work.

    Quick Revision

    Task asyncio Way
    Run the program asyncio.run(main())
    Pause without blocking await asyncio.sleep(s)
    Run many concurrently await asyncio.gather(*coros)
    Background task asyncio.create_task(coro)
    Add a timeout await asyncio.wait_for(coro, t)

    Key Takeaways

    asyncio powers high-concurrency I/O in Python on a single thread using an event loop. Define coroutines with async def, run them concurrently with gather / create_task, and always use async libraries — never blocking calls — inside them.