Introduction to Asynchronous Programming with Python asyncio

How Python asyncio's event loop actually works internally (selectors, epoll/kqueue), contrasted with threading and multiprocessing. Includes benchmarked demos of the blocking-call pitfall, CPU-bound workloads, and gather exception handling.

Introduction

Python’s asyncio is a standard library for asynchronous I/O processing. It efficiently runs I/O-bound operations like network communication and file I/O concurrently.

This article covers the basics of async/await syntax, concurrent task execution, and practical async HTTP requests.

Synchronous vs Asynchronous

The Synchronous Problem

import time

def fetch_data(url, delay):
    print(f"Fetching {url}...")
    time.sleep(delay)  # Simulate I/O wait
    print(f"Done {url}")
    return f"data from {url}"

# Sequential: total 6 seconds
start = time.time()
fetch_data("api/users", 2)
fetch_data("api/posts", 3)
fetch_data("api/comments", 1)
print(f"Total: {time.time() - start:.1f}s")  # ~6 seconds

Async Improvement

import asyncio

async def fetch_data(url, delay):
    print(f"Fetching {url}...")
    await asyncio.sleep(delay)  # Async I/O wait
    print(f"Done {url}")
    return f"data from {url}"

async def main():
    results = await asyncio.gather(
        fetch_data("api/users", 2),
        fetch_data("api/posts", 3),
        fetch_data("api/comments", 1),
    )
    return results

# Concurrent: ~3 seconds (time of slowest task)
import time
start = time.time()
results = asyncio.run(main())
print(f"Total: {time.time() - start:.1f}s")  # ~3 seconds

How the Event Loop Actually Works: Handling Thousands of Connections on One Thread

The secret behind asyncio’s speed isn’t magic parallelism – it’s cooperative task switching inside a single OS thread. Understanding the exact mechanism makes the “don’t do this” rules in the edge-case sections below fall out naturally.

The cooperative scheduling loop

  1. The event loop picks one runnable coroutine and executes it until it hits an await.
  2. When it reaches await some_io(), the coroutine voluntarily yields control back to the event loop. This is the crucial difference from OS threads: nothing preempts the coroutine – the code itself chooses to hand back control.
  3. While that coroutine is suspended, the event loop runs other runnable coroutines.
  4. Once a socket or file descriptor that a suspended coroutine was waiting on becomes ready for reading/writing, the event loop detects this and reschedules the corresponding coroutine.

Step 4 is powered by OS-level I/O multiplexing. Under the hood, CPython’s asyncio uses the selectors module (a thin, high-level wrapper around the lower-level select module), which automatically picks the most efficient implementation available on the current platform.

import selectors

# DefaultSelector picks the best implementation per platform:
# Linux:      EpollSelector (epoll)
# macOS/BSD:  KqueueSelector (kqueue)
# fallback:   SelectSelector (select), etc.
sel = selectors.DefaultSelector()
print(type(sel).__name__)  # e.g. KqueueSelector on macOS, EpollSelector on Linux

select(2) scales linearly (O(n)) with the number of watched file descriptors, whereas epoll (Linux) and kqueue (BSD/macOS) let the kernel keep track of registered descriptors and notify you only about the ones that became ready (closer to O(1)). This is exactly why epoll/kqueue became the standard answer to the “C10K problem” of serving tens of thousands of simultaneous connections – and asyncio’s event loop is built directly on top of this mechanism.

The key point: a single OS thread drives this entire loop. Waiting on ten thousand connections does not require ten thousand OS threads; it only requires one syscall telling the kernel “wake me when any of these sockets are ready.”

Contrast with threading (the GIL) and multiprocessing

asyncio is not the only way to achieve I/O-bound concurrency. Here is an honest comparison of the trade-offs:

ApproachUnit of concurrencyMemoryEffect of the GILMain overheadBest suited for
asyncioCoroutine (within one thread)Shared within the processN/A (only one thread to begin with)Task switching is roughly the cost of a user-space function callI/O-bound work with many simultaneous connections
threadingOS threadShared within the processThe GIL lets only one thread execute Python bytecode at a time (CPU-bound work does not parallelize)OS context switches (register save/restore, kernel-mode transitions)Isolating blocking I/O calls (e.g. via to_thread)
multiprocessingOS processNot shared (separate address spaces; requires IPC)N/A (each process has its own independent GIL)Process creation (fork/spawn) cost and serialization cost for inter-process communicationCPU-bound work (true parallel computation)
  • threading: CPython’s GIL (Global Interpreter Lock) means only one OS thread can execute Python bytecode at any instant. The GIL is released while a thread waits on I/O, so other threads can run – but CPU-bound work does not get faster just by adding threads. On top of that, every OS-level context switch costs a kernel-mode transition and register save/restore, and that overhead becomes non-negligible as thread counts grow.
  • multiprocessing: processes have independent memory spaces, so they achieve true parallelism unconstrained by the GIL. The cost is that spawning a process (fork/spawn) is expensive, and data must be serialized (pickled) to cross process boundaries – you can’t just pass an object by reference like you can with shared memory.
  • asyncio: switching between coroutines inside one thread triggers neither an OS context switch nor a process spawn. That’s why handling thousands of simultaneous connections barely increases overhead. But this is fundamentally about “doing other work while waiting on I/O” – it does not make CPU-bound computation itself faster, which is the key premise of the next section.

(CPython 3.13+ also ships an optional “free-threaded” build that can disable the GIL entirely, but that change targets parallelizing CPU-bound multi-threaded code – it does not affect the design rationale behind I/O-bound asyncio. See the references at the end of this article for details.)

Coroutines and async/await

Defining Coroutines

Functions defined with async def are coroutine functions that return coroutine objects when called.

async def greet(name):
    return f"Hello, {name}"

# Returns a coroutine object (not executed)
coro = greet("Alice")

# Requires await or asyncio.run() to execute
result = asyncio.run(greet("Alice"))
print(result)  # "Hello, Alice"

The Role of await

await waits for async operation completion. While awaiting, the event loop can transfer control to other tasks.

async def process():
    data = await fetch_data("api/users", 1)  # Other tasks can run during wait
    return data

Concurrent Task Execution

asyncio.create_task()

Schedules a coroutine as a task, starting background execution.

async def main():
    task1 = asyncio.create_task(fetch_data("api/users", 2))
    task2 = asyncio.create_task(fetch_data("api/posts", 3))

    # Both tasks running concurrently
    result1 = await task1
    result2 = await task2
    return result1, result2

asyncio.gather()

Runs multiple coroutines concurrently and returns all results together.

async def main():
    results = await asyncio.gather(
        fetch_data("api/users", 2),
        fetch_data("api/posts", 3),
        fetch_data("api/comments", 1),
    )
    # results returned as a list in input order
    return results

asyncio.as_completed()

Iterate in completion order:

async def main():
    tasks = [
        fetch_data("api/users", 2),
        fetch_data("api/posts", 3),
        fetch_data("api/comments", 1),
    ]
    for coro in asyncio.as_completed(tasks):
        result = await coro
        print(f"Completed: {result}")
    # Output order: comments → users → posts (fastest first)

Practical Example: Async HTTP Requests

Concurrent HTTP requests using aiohttp:

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    """Async fetch of a single URL"""
    async with session.get(url) as response:
        data = await response.text()
        return {"url": url, "status": response.status, "length": len(data)}

async def fetch_all(urls):
    """Concurrent fetch of multiple URLs"""
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        return await asyncio.gather(*tasks)

# --- Run ---
urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/2",
    "https://httpbin.org/delay/1",
]

start = time.time()
results = asyncio.run(fetch_all(urls))
elapsed = time.time() - start

for r in results:
    print(f"{r['url']}: status={r['status']}, length={r['length']}")
print(f"Total: {elapsed:.1f}s")  # Sequential: 7s, Concurrent: ~2s

Async Patterns

Rate Limiting with Semaphore

Limit concurrent connections to avoid overloading servers:

async def fetch_with_limit(session, url, semaphore):
    async with semaphore:  # Limit concurrency
        async with session.get(url) as response:
            return await response.text()

async def main():
    semaphore = asyncio.Semaphore(5)  # Max 5 concurrent
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_with_limit(session, url, semaphore) for url in urls]
        return await asyncio.gather(*tasks)

Producer-Consumer with Queue

async def producer(queue):
    for i in range(10):
        await queue.put(f"item-{i}")
        await asyncio.sleep(0.1)
    await queue.put(None)  # Sentinel

async def consumer(queue, name):
    while True:
        item = await queue.get()
        if item is None:
            await queue.put(None)  # Propagate to other consumers
            break
        print(f"{name} processed {item}")
        await asyncio.sleep(0.2)

async def main():
    queue = asyncio.Queue(maxsize=5)
    await asyncio.gather(
        producer(queue),
        consumer(queue, "worker-1"),
        consumer(queue, "worker-2"),
    )

Edge Case #1: A Blocking Call Stalls the Entire Event Loop

The classic beginner mistake is calling a synchronously blocking function (time.sleep(), a non-awaited blocking I/O call, or heavy CPU-bound work) directly inside a coroutine. The common misconception is that this only blocks that one coroutine – but as explained above, since the event loop runs on a single thread, while that blocking call is running, the entire event loop is frozen and no other task can make any progress. The thread has no opportunity to hand control back to any other coroutine because the OS itself has taken control away from Python during time.sleep().

The code below compares the behavior of three gathered tasks under both scenarios.

import asyncio
import time


async def main_blocking():
    """One task uses time.sleep() (blocking)."""
    t0 = time.perf_counter()

    async def worker(name, use_blocking, duration):
        start = time.perf_counter() - t0
        print(f"[{start:6.3f}] {name} START")
        if use_blocking:
            time.sleep(duration)  # WRONG: stalls the entire event loop
        else:
            await asyncio.sleep(duration)
        end = time.perf_counter() - t0
        print(f"[{end:6.3f}] {name} END (took {end - start:.3f}s)")

    await asyncio.gather(
        worker("blocking-task", True, 2.0),
        worker("async-task-A", False, 0.5),
        worker("async-task-B", False, 0.5),
    )
    print(f"total: {time.perf_counter() - t0:.3f}s")


async def main_nonblocking():
    """All tasks use asyncio.sleep() (non-blocking)."""
    t0 = time.perf_counter()

    async def worker(name, duration):
        start = time.perf_counter() - t0
        print(f"[{start:6.3f}] {name} START")
        await asyncio.sleep(duration)
        end = time.perf_counter() - t0
        print(f"[{end:6.3f}] {name} END (took {end - start:.3f}s)")

    await asyncio.gather(
        worker("async-task-C", 2.0),
        worker("async-task-A", 0.5),
        worker("async-task-B", 0.5),
    )
    print(f"total: {time.perf_counter() - t0:.3f}s")


asyncio.run(main_blocking())
print()
asyncio.run(main_nonblocking())

Actual output (Python 3.14, macOS):

[ 0.000] blocking-task START
[ 2.005] blocking-task END (took 2.005s)
[ 2.005] async-task-A START
[ 2.005] async-task-B START
[ 2.506] async-task-A END (took 0.501s)
[ 2.506] async-task-B END (took 0.501s)
total: 2.507s

[ 0.000] async-task-C START
[ 0.000] async-task-A START
[ 0.000] async-task-B START
[ 0.501] async-task-A END (took 0.500s)
[ 0.501] async-task-B END (took 0.500s)
[ 2.001] async-task-C END (took 2.001s)
total: 2.001s

In the time.sleep() case, async-task-A and async-task-B – which should finish in 0.5 seconds – don’t even START until blocking-task’s time.sleep(2.0) finishes. The event loop was monopolized by blocking-task, with no opportunity to hand control to any other task. The total time is 2.5 seconds (the 2.0s block plus 0.5s), noticeably worse than the ~2.0s a correctly concurrent version would take. In the non-blocking case below it, all three tasks START simultaneously, and the total (2.001s) matches the slowest task (2.0s) almost exactly.

Here’s that timeline visualized:

asyncio task-execution timeline comparing a blocking time.sleep call versus non-blocking asyncio.sleep calls

Fix: whenever a coroutine needs to call something blocking (synchronous I/O, a sync HTTP client like requests, heavy CPU work), always offload it with asyncio.to_thread() (Python 3.9+) or loop.run_in_executor().

async def worker(name, duration):
    # Don't call time.sleep(duration) directly -- run it in a separate thread.
    await asyncio.to_thread(time.sleep, duration)

Edge Case #2: asyncio Gives No Speedup for CPU-Bound Work

asyncio makes I/O-bound work (waiting on the network, waiting on disk) efficient – it does not make raw computation faster. As explained above, the event loop runs on a single thread, so bundling CPU-bound coroutines with asyncio.gather() doesn’t run them concurrently in any meaningful sense; it just executes them one after another on that one thread.

The benchmark below compares four CPU-bound computations (a loop of 6,000,000 iterations each) run (1) sequentially, (2) via asyncio.gather, and (3) via multiprocessing (ProcessPoolExecutor).

import asyncio
import time
from concurrent.futures import ProcessPoolExecutor

N_TASKS = 4
N = 6_000_000


def cpu_bound_task(n):
    """A purely CPU-bound computation (no I/O wait)."""
    total = 0
    for i in range(n):
        total += i * i
    return total


async def cpu_bound_coro(n):
    # Contains no await at all -- just a sync function wearing a coroutine costume.
    return cpu_bound_task(n)


def run_sequential():
    start = time.perf_counter()
    results = [cpu_bound_task(N) for _ in range(N_TASKS)]
    return time.perf_counter() - start, results


def run_asyncio_gather():
    async def main():
        return await asyncio.gather(*[cpu_bound_coro(N) for _ in range(N_TASKS)])

    start = time.perf_counter()
    results = asyncio.run(main())
    return time.perf_counter() - start, results


def run_multiprocessing():
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=N_TASKS) as pool:
        results = list(pool.map(cpu_bound_task, [N] * N_TASKS))
    return time.perf_counter() - start, results


if __name__ == "__main__":
    t_seq, r_seq = run_sequential()
    t_async, r_async = run_asyncio_gather()
    t_mp, r_mp = run_multiprocessing()

    print(f"Sequential (for-loop):      {t_seq:.3f}s")
    print(f"asyncio.gather (1 thread):  {t_async:.3f}s")
    print(f"multiprocessing (4 procs):  {t_mp:.3f}s")
    print(f"asyncio vs sequential speedup: {t_seq / t_async:.2f}x (expect ~1x)")
    print(f"multiprocessing vs sequential speedup: {t_seq / t_mp:.2f}x")

Actual output (Python 3.14, 8-core macOS):

Sequential (for-loop):      1.223s
asyncio.gather (1 thread):  1.015s
multiprocessing (4 procs):  0.428s
asyncio vs sequential speedup: 1.20x (expect ~1x)
multiprocessing vs sequential speedup: 2.86x

asyncio.gather finishes in roughly the same time as sequential execution – the difference is noise, not a real speedup. multiprocessing, in contrast, measured about a 2.9x speedup (the theoretical ceiling with 4 processes is 4x; the gap comes from process-creation and pickle-serialization overhead). Exact numbers will vary by machine, so read this qualitatively: asyncio ~= sequential, multiprocessing is clearly faster.

Bar chart comparing wall-clock time for a CPU-bound task run sequentially, via asyncio.gather, and via multiprocessing

Fix: to parallelize CPU-bound work, use multiprocessing or concurrent.futures.ProcessPoolExecutor instead of asyncio. If you need to call CPU-bound code from within async code, offload it to a separate process with loop.run_in_executor(ProcessPoolExecutor(), func, arg).

Error Handling

Exception Propagation in gather (Verified)

When one of the coroutines passed to asyncio.gather() raises, the default behavior (return_exceptions=False) is to propagate that exception immediately to the caller. What’s easy to miss: the other, still-running tasks are not automatically cancelled. Tasks that were already created with create_task() keep running in the background even after gather() has raised.

import asyncio


async def risky_task(n, delay):
    await asyncio.sleep(delay)
    if n == 2:
        raise ValueError(f"error in task {n}")
    return f"result-{n}"


async def without_return_exceptions():
    t1 = asyncio.create_task(risky_task(1, 0.3), name="task-1")
    t2 = asyncio.create_task(risky_task(2, 0.1), name="task-2")  # fails first
    t3 = asyncio.create_task(risky_task(3, 0.5), name="task-3")  # slowest

    try:
        results = await asyncio.gather(t1, t2, t3)
        print("results:", results)
    except ValueError as e:
        print(f"gather raised: {e!r}")

    print("--- state immediately after the exception propagates ---")
    for t in (t1, t2, t3):
        print(f"  {t.get_name()}: done={t.done()} cancelled={t.cancelled()}")

    await asyncio.sleep(0.6)  # wait for background tasks to finish
    print("--- state after waiting ---")
    for t in (t1, t2, t3):
        print(f"  {t.get_name()}: done={t.done()} cancelled={t.cancelled()}")


asyncio.run(without_return_exceptions())

Actual output:

gather raised: ValueError('error in task 2')
--- state immediately after the exception propagates ---
  task-1: done=False cancelled=False
  task-2: done=True cancelled=False
  task-3: done=False cancelled=False
--- state after waiting ---
  task-1: done=True cancelled=False
  task-2: done=True cancelled=False
  task-3: done=True cancelled=False

The instant task-2 raises at 0.1s, gather() propagates that exception to the caller – but at that moment task-1 (due at 0.3s) and task-3 (due at 0.5s) are still done=False, i.e. not cancelled, and still running in the background. If the caller catches the exception and moves on, these tasks keep executing unobserved; their results are silently discarded, and if one of them later raises too, you can get an “Task exception was never retrieved” warning. This is the hidden trap in an except-and-move-on style use of gather().

Passing return_exceptions=True instead stores the exception as a value in the results list and waits for every task to finish before returning.

async def with_return_exceptions():
    t1 = asyncio.create_task(risky_task(1, 0.3), name="task-1")
    t2 = asyncio.create_task(risky_task(2, 0.1), name="task-2")
    t3 = asyncio.create_task(risky_task(3, 0.5), name="task-3")

    results = await asyncio.gather(t1, t2, t3, return_exceptions=True)
    for r in results:
        if isinstance(r, Exception):
            print(f"Error: {r}")
        else:
            print(f"OK: {r}")

Actual output:

OK: result-1
Error: error in task 2
OK: result-3

TaskGroup Cancels Siblings the Moment One Fails

If you want structured concurrency where one failure aborts the rest, use asyncio.TaskGroup (added in Python 3.11) instead of gather(). When one child task raises, TaskGroup explicitly cancels the other children before raising a combined ExceptionGroup.

async def with_taskgroup():
    tasks = {}
    try:
        async with asyncio.TaskGroup() as tg:
            tasks["task-1"] = tg.create_task(risky_task(1, 0.3), name="task-1")
            tasks["task-2"] = tg.create_task(risky_task(2, 0.1), name="task-2")
            tasks["task-3"] = tg.create_task(risky_task(3, 0.5), name="task-3")
    except* ValueError as eg:
        print(f"ExceptionGroup caught: {[repr(e) for e in eg.exceptions]}")

    for name, t in tasks.items():
        print(f"  {name}: done={t.done()} cancelled={t.cancelled()}")


asyncio.run(with_taskgroup())

Actual output:

ExceptionGroup caught: ["ValueError('error in task 2')"]
  task-1: done=True cancelled=True
  task-2: done=True cancelled=False
  task-3: done=True cancelled=True

Unlike the gather() case, task-1 and task-3 are now cancelled=TrueTaskGroup called cancel() on them the moment task-2 failed. In new code, use plain gather() for simple aggregation and TaskGroup when a failure in one task should abort the whole group.

Common Pitfalls

PitfallSolution
Blocking the event loopOffload blocking I/O or CPU-heavy work with asyncio.to_thread() / run_in_executor
Using asyncio for CPU-bound workNo speedup over sequential execution; use multiprocessing/ProcessPoolExecutor
Sibling tasks keep running after a gather() failureUse TaskGroup to abort the group, or return_exceptions=True to just aggregate
Forgetting awaitCoroutine won’t execute, RuntimeWarning raised
Calling from sync codeUse asyncio.run(), or nest_asyncio for nesting
Task reference lostStore create_task result in a variable

References