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.

:)
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

You write async def and suddenly every function becomes a coroutine. But async is not free. CPU-bound work still starves the event loop, and type annotations collapse to Any without the right protocol types.

Think of async like a restaurant kitchen with one chef. When every order is quick prep plus waiting for ingredients, the chef handles dozens of tables. But when one order requires 30 minutes of continuous stove time, every other table waits. That is the limit of cooperative concurrency: a single long CPU task blocks everyone.

Core answer

Async Python is a concurrency model for waiting workloads.

  • it helps when work spends time waiting for sockets, timers, subprocesses, or other external readiness
  • CPU-bound Python bytecode still runs exclusively inside one event-loop thread
  • it needs accurate protocol types if you want APIs to stay clear under maintenance
# [CURRENT - 3.10-3.14] Works on Python 3.10+
from collections.abc import AsyncIterator
import asyncio
async def ticker() -> AsyncIterator[int]:
for value in (1, 2, 3):
await asyncio.sleep(0.1)
yield value
See Where Async Helps and Where It Stops

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

Where async helps and where it stops

Async code is valuable when each unit of work naturally alternates between:

  • brief CPU work
  • an await on external readiness

That gives the event loop scheduling opportunities between tasks. If those await points are frequent and honest, one thread can keep many I/O-bound operations in flight.

If a coroutine does long CPU work without awaiting, that advantage disappears.

# [OLDER / 3.7-3.8, CURRENT - 3.10-3.14] Works on Python 3.7+
async def cpu_heavy():
total = 0
for i in range(50_000_000):
total += i
return total

This function is legal async code, but operationally it is a bad citizen. Once it starts running, the loop cannot preempt it at Python level. Other tasks wait until the function either returns or reaches an await, and here it never does.

That is the important engineering boundary:

  • async improves overlap for waiting workloads
  • process-based CPU parallelism requires multiprocessing or similar
  • a blocking library behind async syntax still freezes the loop
Mechanism: why the loop stalls

The event loop is cooperative. It resumes a task, the task runs Python bytecode, and the loop gets control back only when the task:

  • awaits another awaitable
  • returns
  • raises

There is no general "timeslice the coroutine after N Python instructions" contract at the language level. That is why event-loop starvation is easy to create with:

  • CPU-heavy loops
  • synchronous compression or parsing
  • blocking file or network APIs
  • accidental time.sleep(...) inside async def
# [CURRENT - 3.10-3.14] Works on Python 3.x
import asyncio
import time
async def frozen():
time.sleep(0.5)
return "done"

The presence of async def does not make time.sleep cooperative. The coroutine simply blocks the event-loop thread.

See for the base scheduling model and for practical offloading boundaries such as asyncio.to_thread.

Typing async protocols precisely

The async side of the type system is about protocol clarity, not decoration.

The main interfaces are:

  • Awaitable[T]: something you can await to get T
  • Coroutine[Any, Any, T]: a coroutine object whose final result is T
  • AsyncIterable[T]: something usable in async for
  • AsyncIterator[T]: an async iterable that also provides the next-item protocol
  • AsyncGenerator[T, SendT]: an async generator function result

For modern Python, prefer the abstract protocol type that matches the boundary you are exposing.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from collections.abc import AsyncIterable
async def consume_ids(source: AsyncIterable[int]) -> list[int]:
return [value async for value in source]

This signature is stronger than source: object and more reusable than forcing callers to hand you one concrete async generator implementation.

When the function specifically manufactures and returns a native coroutine object, the most useful public annotation is usually still the final awaited result type on the function itself:

# [CURRENT - 3.10-3.14] Works on Python 3.10+
import asyncio
async def fetch_total() -> int:
await asyncio.sleep(0.1)
return 3

Callers see async def ... -> int because the annotation describes the value produced when awaited. If you are typing a variable that stores the created coroutine object, that is when Coroutine[..., T] or Awaitable[T] becomes useful.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from collections.abc import Awaitable
async def fetch_total() -> int:
return 3
pending: Awaitable[int] = fetch_total()
pending.close() # native coroutine object in this example

This connects directly to : accept the most general correct input protocol, and return the type your caller actually needs.

What not to promise in async APIs

Do not promise more than the runtime model actually provides.

Bad assumptions include:

  • "async means parallel"
  • "async means non-blocking by default"
  • "an async iterator is automatically buffered"
  • "a typed awaitable is safe to forget about"

Typed async APIs still need lifecycle discipline:

  • awaited completion
  • timeout policy
  • cancellation policy
  • ownership of spawned tasks

That is why structured concurrency is separate from basic async syntax. For failure boundaries and task ownership, use .

Version context

Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.

Relevant version markers:

  • native coroutines and await: Python 3.5+ PEP 492
  • async generators: Python 3.6+ PEP 525
  • async comprehensions: Python 3.6+ PEP 530
  • collections.abc generic aliases such as AsyncIterator[int]: standard modern spelling in current supported Python

For this repo's supported range, these async protocol types are stable baseline tools. Older typing-module spellings remain historically relevant, but they are not the best default for new code unless compatibility constraints force them.

Edge cases and gotchas

sys.getswitchinterval() and the thread scheduler do not solve coroutine starvation inside one event loop. Thread scheduling is a different mechanism from async task cooperation.

Annotating everything as Coroutine[Any, Any, Any] leaks implementation detail and makes public APIs harder to use. Most callers care that they can await a result, iterate an async stream, or pass an async source into your function.

Async generators are not general-purpose containers. They are one-pass streams with awaited production boundaries.

If the workload is CPU-bound, your first async design question should be whether the work belongs in another process, native extension, or service boundary. Pretending the event loop will absorb it is operationally dishonest.

Production usage

Use async when:

  • throughput depends on overlapping waits
  • you need many concurrent sockets or timers
  • the system is mostly I/O-bound and cancellation-aware

Do not use async as a reflex when:

  • the hot path is CPU-heavy Python code
  • the dependency stack is fundamentally blocking
  • the team cannot maintain task ownership and timeout discipline

Type async APIs with protocol intent:

  • Awaitable[T] when the boundary is "I can await this"
  • AsyncIterable[T] when the boundary is "I can consume this stream"
  • AsyncIterator[T] when the consumer pulls values step by step
  • concrete return annotations on async def for the final awaited result
Further depth
  • asyncio
  • Coroutines and Tasks
  • collections.abc — AsyncIterable, AsyncIterator, Awaitable, Coroutine
  • typing — annotating generators and coroutines
  • PEP 492: Coroutines with async and await syntax
  • PEP 525: Asynchronous Generators
  • PEP 530: Asynchronous Comprehensions
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