Async limits and protocol type hints

Where async stops helping, how CPU work stalls the loop, and how to type async boundaries

Async helps with I/O latency. CPU work still needs a separate parallelism strategy. In cooperative concurrency, a coroutine must reach an `await` point for other tasks to run. Pure CPU work has no suspension points, so it blocks the event loop regardless of async usage. `time.sleep()` blocks the loop. `asyncio.to_thread()` moves blocking work off the loop but pure Python CPU still contends on the GIL. The `sys.getswitchinterval` setting for thread switching has no effect on coroutines. For typing async boundaries, Python provides `Awaitable[T]`, `AsyncIterable[T]`, `AsyncIterator[T]`, and `AsyncGenerator[T, SendT]` in `collections.abc`. <a href="/async-foundations-awaitables">Master awaitable basics first</a>. <a href="/runtime-gil-performance">Understand GIL limits that affect to_thread</a>. <a href="/language-type-hints">General type hinting patterns for async code</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Type async boundaries precisely.

:)
Python version

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

TABLE OF CONTENTS
6.6Async limits and protocol type hints

Where async stops helping, how CPU work stalls the loop, and how to type async boundaries

Async code still has limits: CPU work blocks the loop, blocking libraries block the loop, and imprecise typing can erase the boundaries that matter.

Core answer

Use async for waiting-heavy concurrency. Offload blocking synchronous work deliberately. Type async APIs by the protocol callers consume: Awaitable, AsyncIterable, AsyncIterator, or concrete coroutine-returning functions.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
from dataclasses import dataclass
from collections.abc import Awaitable
@dataclass(frozen=True, slots=True)
class Parsed:
order_id: str
async def load_order(order_id: str) -> Parsed:
await asyncio.sleep(0.01)
return Parsed(order_id)
async def await_one(pending: Awaitable[Parsed]) -> Parsed:
return await pending
print(asyncio.run(await_one(load_order("ORD-7"))))
See Where Async Helps and Where It Stops

Contrast cooperative I/O waiting with blocking or CPU-heavy work that starves the event loop.

Why this design exists

Asyncio is cooperative. That model is efficient for waits because suspension is explicit. It is not preemptive CPU scheduling. Precise async types keep that distinction visible to callers and static tools.

Mechanics and CPython internals

A coroutine runs Python bytecode until it awaits an incomplete awaitable or finishes. Pure Python CPU loops do not yield just because they live inside async def. asyncio.to_thread can protect the event loop from blocking calls, while regular CPython still serializes Python bytecode behind the GIL across threads in one interpreter.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DigestWork:
size: int
def crunch(work: DigestWork) -> int:
total = 0
for value in range(work.size):
total += value * value
return total
async def main() -> None:
print(await asyncio.to_thread(crunch, DigestWork(20_000)))
asyncio.run(main())

Complexity and tradeoffs

Offloading preserves loop responsiveness; it does not guarantee CPU speedup for Python code. Type abstractions improve API clarity, but returning the broadest async protocol everywhere can hide ownership: some callers need a reusable async iterable, others need a one-shot coroutine.

Idiomatic patterns and refactoring

Refactor an async def wrapper that blocks the loop into an explicit blocking boundary.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
import time
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Snapshot:
name: str
def load_blocking(name: str) -> Snapshot:
time.sleep(0.01)
return Snapshot(name)
async def load_bad(name: str) -> Snapshot:
return load_blocking(name)
async def load(name: str) -> Snapshot:
return await asyncio.to_thread(load_blocking, name)
print(asyncio.run(load("orders")))

Common mistakes and edge cases

Do not assume to_thread fixes CPU throughput. Do not annotate every async function as Awaitable[Any] and erase useful result shape. Do not call blocking code inside a timeout and expect cancellation to interrupt arbitrary synchronous work safely.

When to use / When NOT to use

Use typed async boundaries when concurrency model, ownership, and result shape should be visible.

Do not use async to paper over CPU-bound Python algorithms or blocking libraries you have not isolated.

Further reading

  • Official docs: coroutines and tasks
  • Official docs: async typing ABCs
  • PEP 492: native coroutines
  • PEP 525: async generators
  • CPython source: asyncio threads helper
BOARD NOTESContext
WHY NO BENCHMARK?

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

Type async boundaries precisely.

RELATED GUIDES
NEXT CHECKS
Contribute