Cloud Tech by Victor
BackendAdvanced

Python Async Programming

How asyncio's single-threaded event loop achieves concurrency without threads, why blocking calls silently defeat it, and when async actually helps versus when it's pure overhead.

Updated 2026-07-223 min read

Overview

asyncio provides concurrency within a single thread by running many coroutines cooperatively on an event loop, each coroutine runs until it hits an await on I/O, voluntarily yields control, and the event loop runs something else while that I/O completes. This works well specifically for I/O-bound workloads with high concurrency (many simultaneous network connections), because the CPU is never actually idle waiting on any single one of them. It does nothing for CPU-bound work, and it has one sharp, easy-to-hit failure mode: any blocking (non-await-yielding) call anywhere in the path freezes the entire event loop, silently serializing everything that was supposed to run concurrently.

Quick Reference

ConceptWhat it means
CoroutineA function defined with async def, resumable at each await
Event loopSchedules and runs coroutines, resuming them when their awaited I/O completes
awaitYields control back to the event loop until the awaited operation completes
TaskA coroutine scheduled to run concurrently via asyncio.create_task
Blocking callAny synchronous I/O/CPU work that doesn't yield, freezes the whole loop

Syntax

python
import asyncio

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

async def main():
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(
            *(fetch(session, url) for url in urls)
        )

Examples

python
# A single blocking call anywhere in an async path stalls the
# entire event loop - every other concurrent task is frozen too.
async def handle_request(request):
    data = requests.get(EXTERNAL_API)  # blocking! [!code highlight]
    return data.json()

# Fixed: use an async-compatible HTTP client instead
async def handle_request_fixed(request, session):
    async with session.get(EXTERNAL_API) as response:
        return await response.json()

asyncio does not help CPU-bound work

A CPU-heavy coroutine blocks the event loop exactly like any other blocking call, since there's still only one thread. Use multiprocessing or a process pool for CPU-bound work, asyncio only helps when the workload is genuinely I/O-bound.

Visual Diagram

Common Mistakes

  • Calling a synchronous, blocking library (a non-async HTTP client, time.sleep, synchronous file I/O) inside an async function, silently freezing the entire event loop for its duration.
  • Reaching for asyncio to speed up CPU-bound work, which it cannot do, CPU-bound work needs multiprocessing, not asyncio.
  • Forgetting await on a coroutine call, which creates a coroutine object without ever running it, a silent no-op bug, not an error.
  • Mixing sync and async code paths without a clear boundary, making it unclear which parts of a codebase are safe to call blocking operations from.

Performance

  • asyncio's benefit scales with I/O concurrency, a program making one request at a time gets little to no benefit over synchronous code, while one making thousands of concurrent requests can see dramatic throughput improvements.
  • Context-switching between coroutines is far cheaper than OS thread context-switching, which is part of why asyncio can handle far more concurrent I/O operations than a thread-per-connection model at the same resource cost.
  • A single blocking call in a hot path doesn't just slow down its own coroutine; it stalls every other concurrently scheduled coroutine, making its performance impact disproportionate to its own cost.

Best Practices

  • Use async-compatible libraries end-to-end for any I/O in an async codebase, a synchronous library anywhere in the path defeats the purpose.
  • Reach for asyncio specifically for I/O-bound, high-concurrency workloads; use multiprocessing for CPU-bound work instead.
  • Use asyncio.gather (or TaskGroup in newer Python versions) to run independent I/O operations concurrently rather than awaiting them one at a time in sequence.
  • Keep a clear boundary between sync and async code, and run genuinely blocking operations that can't be made async in a thread/process pool executor instead of directly in a coroutine.

Interview questions

How does asyncio achieve concurrency with a single thread?

asyncio runs an event loop that manages many coroutines cooperatively, a coroutine runs until it hits an `await` on an I/O operation, at which point it voluntarily yields control back to the event loop, which then runs another ready coroutine. While one coroutine is waiting on network I/O, the CPU isn't idle; the event loop is running other coroutines. This works because I/O waiting doesn't need the CPU at all; the concurrency comes from overlapping wait times, not from parallel execution, which is why it's a single thread the whole time and no locks are needed between coroutines.

Why does calling a blocking function inside an async function defeat the purpose of using asyncio?

A blocking call (synchronous file I/O, a synchronous HTTP request, `time.sleep`) occupies the single thread the event loop runs on, and unlike `await`, it does not yield control back, the entire event loop is frozen for the duration of that blocking call, so every other coroutine that could otherwise be making progress is stalled too. This is why async code requires async-compatible libraries throughout the I/O path; a single accidental blocking call anywhere in a hot path can silently serialize what was supposed to be concurrent work, and the bug often doesn't show up until real concurrent load exposes it.

When does asyncio actually help, and when is it not worth the added complexity?

asyncio helps specifically for I/O-bound workloads with many concurrent operations, handling thousands of simultaneous network connections, making many concurrent API calls, where most of the time is spent waiting, not computing. It does not help CPU-bound work at all, since the event loop is still single-threaded and a CPU-heavy coroutine blocks everything else exactly like any other blocking call; CPU-bound work needs `multiprocessing` or a separate process pool instead. For a workload with low concurrency or that's primarily CPU-bound, asyncio adds real complexity (colored functions, async-compatible libraries everywhere) without a corresponding benefit.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement