Async foundations: awaitables and scheduling

Native coroutines, await boundaries, event-loop control flow, and blocking hazards

`await` is a suspension point that pauses a coroutine so the event loop can run other tasks. Native coroutines, introduced in PEP 492 (Python 3.5), are defined with `async def`. Calling one creates a coroutine object without executing any code. Only `await` drives execution. CPython implements coroutines on the same frame-evaluation machinery as generators, using the `RESUME` opcode for suspension and resumption. The event loop schedules tasks cooperatively: a coroutine must reach an `await` point for other tasks to run. `asyncio.run()` (Python 3.7+) creates and manages the event loop. `time.sleep()` blocks the loop because it does not yield control. `asyncio.sleep()` is the correct non-blocking alternative. <a href="/async-context-backpressure">Learn backpressure and concurrency limiting for async code</a>. <a href="/async-iterators-generators">See async generators for lazy streaming</a>. <a href="/runtime-gil-performance">Compare async with threading and multiprocessing</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Every blocking boundary is everyone's problem.

:)
Python version

Targets Python 3.10–3.14. Python 3.9 and below are End-of-Life.

TABLE OF CONTENTS
6.2Async foundations: awaitables and scheduling

Native coroutines, await boundaries, event-loop control flow, and blocking hazards

async def creates coroutine functions. Calling one creates a coroutine object. Scheduling or awaiting that object is what gives it execution time.

Core answer

Use async code when the work can suspend at explicit await boundaries while other work remains useful. Keep blocking synchronous calls off the event-loop thread.

# [CURRENT - 3.10-3.14] Works on Python 3.10+ [PEP 492]
import asyncio
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Quote:
symbol: str
cents: int
async def fetch_quote(symbol: str) -> Quote:
await asyncio.sleep(0.01)
return Quote(symbol, 4200)
async def main() -> None:
quotes = await asyncio.gather(fetch_quote("PY"), fetch_quote("CPY"))
print(quotes)
asyncio.run(main())

Why this design exists

PEP 492 made native coroutines and await explicit so asynchronous control flow stops pretending to be ordinary generator control flow. The event loop gets cooperation points it can reason about; the code gets visible suspension points reviewers can audit.

Mechanics and CPython internals

An awaitable can be a coroutine or another object exposing the await protocol. CPython resumes coroutine frames until they return, raise, or suspend on an awaitable that is not ready. The GIL still exists in ordinary CPython builds; async concurrency is scheduling within threads, not automatic CPU parallelism.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
from dataclasses import dataclass
from inspect import isawaitable
@dataclass(frozen=True, slots=True)
class Receipt:
order_id: str
async def create_receipt(order_id: str) -> Receipt:
await asyncio.sleep(0)
return Receipt(order_id)
async def main() -> None:
coroutine = create_receipt("ORD-7")
print(isawaitable(coroutine))
print(await coroutine)
asyncio.run(main())

Complexity and tradeoffs

Async reduces retained thread cost for many waiting operations, but every task still holds state and every suspension has scheduler overhead. It improves latency overlap for I/O; it does not make CPU-bound Python loops stop blocking the loop.

Idiomatic patterns and refactoring

Refactor blocking sleeps or clients at the loop boundary before scaling fan-out.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
import time
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Probe:
name: str
async def blocks(probe: Probe) -> str:
time.sleep(0.01)
return probe.name
async def cooperates(probe: Probe) -> str:
await asyncio.sleep(0.01)
return probe.name
async def main() -> None:
print(await cooperates(Probe("ready")))
print(await blocks(Probe("blocked")))
asyncio.run(main())

Common mistakes and edge cases

Do not call a coroutine and forget to await or schedule it. Do not assume an await exists merely because a function name starts with async. Do not run synchronous I/O or large CPU loops inside a coroutine and expect unrelated tasks to progress.

When to use / When NOT to use

Use async for high-concurrency I/O and APIs that already expose awaitable operations.

Do not use async as a style preference around sequential CPU work or around libraries that force blocking calls without an offload boundary.

Further reading

  • Official docs: asyncio
  • Official docs: await expressions
  • PEP 492: coroutines with async and await
  • CPython source: coroutine and generator objects
BOARD NOTESContext
WHY NO BENCHMARK?

This topic is better taught with structure, semantics, and cross-references than with a synthetic chart.

Every blocking boundary is everyone's problem.

RELATED GUIDES
NEXT CHECKS
Contribute