Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Structured Concurrency Concepts

Reference for python-trio-anyio-migrator · 317 lines

Programming / Pythonv1.0.0Updated OCT 21 20267 references1 script

What trio introduced, what anyio adds on top of asyncio, and why these ideas change how you write fan-out, timeouts and shutdown. Read this before migrating: most migration bugs come from carrying asyncio intuitions (free tasks, edge-triggered cancel, single exceptions) into a structured codebase.

Contents

The problem with free-floating tasks

asyncio.create_task() returns a task that can outlive the function that created it:

python
import asyncio


async def handler(request_id: int) -> str:
    asyncio.create_task(log_usage(request_id))   # who waits for this? who sees its error?
    return "ok"


async def log_usage(request_id: int) -> None:
    await asyncio.sleep(0.01)
    raise RuntimeError(f"metrics backend down for {request_id}")

Three classic failures follow:

  1. Lost errors. The RuntimeError is reported only as "Task exception was never retrieved" when the task is garbage-collected, maybe never.
  2. Lost tasks. The event loop keeps only weak references to tasks; a task without a strong reference can be collected mid-flight (the asyncio docs tell you to keep a set of tasks for this reason).
  3. No cancellation path. When the request is cancelled, log_usage keeps running; at shutdown nobody knows it exists.

asyncio.TaskGroup (3.11) fixes this when you use it; trio made it the only way to start a task, and anyio brings the same rule to code that runs on either backend.

Nurseries and task groups

A nursery (trio) / task group (anyio, asyncio 3.11+) is an async with block that owns its children:

  • children are started with start_soon(func, *args) (anyio/trio) or create_task(coro) (asyncio);
  • the block does not exit until every child has finished;
  • if any child (or the block body) raises, the remaining children are cancelled and the errors are raised from the async with as an ExceptionGroup;
  • cancelling the group's cancel_scope cancels the body and every child.
python
import anyio


async def embed(doc: str, out: dict) -> None:
    await anyio.sleep(0.01)                  # stands in for an embedding API call
    out[doc] = len(doc)


async def main() -> None:
    out: dict = {}
    async with anyio.create_task_group() as tg:
        for doc in ["intro", "methods", "results"]:
            tg.start_soon(embed, doc, out)
    # every child has finished here, guaranteed
    print(sorted(out.items()))


anyio.run(main)
# Output: [('intro', 5), ('methods', 7), ('results', 7)]

The guarantee "when the block exits, the work is done or cancelled" is what makes code easy to reason about: concurrency has the same shape as the call stack. Errors propagate to the caller, cancellation propagates to callees.

Rules that follow from it:

RuleConsequence
A task needs a group that outlives itBackground work for a server goes into a group opened in the server's lifespan, not into a request handler
start_soon takes a function and argsIt calls func(*args) itself, so the group controls when the coroutine starts; passing func(args) is a TypeError
No task handle (trio; anyio < 4.14)Results are written into a container or sent into a memory stream; cancellation goes through a scope
The group waitsA child that never returns keeps the block open forever: consumers must see their input closed

Cancel scopes

A cancel scope is a with block that can be cancelled, either explicitly (scope.cancel()) or by a deadline. Every task group has one (tg.cancel_scope). Timeouts are cancel scopes with a deadline:

ConstructOn deadlineRaises
with anyio.fail_after(5):cancels the blockTimeoutError (builtin) after the block exits
with anyio.move_on_after(5) as scope:cancels the blocknothing; scope.cancelled_caught is True
with anyio.CancelScope(deadline=anyio.current_time() + 5):cancels the blocknothing
with anyio.CancelScope(shield=True):never; also blocks cancellation from outsidenothing
trio: trio.fail_after, trio.move_on_after, trio.CancelScopesametrio.TooSlowError for fail_after

Scopes nest: the effective deadline is the earliest one in force (anyio.current_effective_deadline()). A shielded scope ignores outer cancellation but its own deadline still applies, which is how you bound cleanup:

python
import anyio


async def main() -> None:
    with anyio.move_on_after(0.05) as outer:
        try:
            await anyio.sleep(10)                    # the "request"
        finally:
            with anyio.CancelScope(shield=True) as cleanup:
                cleanup.deadline = anyio.current_time() + 1   # cleanup gets at most 1 s
                await anyio.sleep(0.01)              # e.g. flush buffered vectors
                print("cleanup finished")
    print("outer cancelled:", outer.cancelled_caught)


anyio.run(main)
# Output:
# cleanup finished
# outer cancelled: True

The cancellation is delivered as an exception (asyncio.CancelledError on the asyncio backend, trio.Cancelled on trio; anyio.get_cancelled_exc_class() returns the right one) and caught by the scope that issued it when it reaches the scope's with statement. Code in between sees an exception and runs its finally blocks, exactly like any other exception.

Level-triggered vs edge-triggered cancellation

This is the most important behavioural difference when migrating.

  • asyncio is edge-triggered. task.cancel() delivers one CancelledError at the next await. If the coroutine catches it and carries on, the task simply continues. (3.11 added Task.cancelling() / uncancel() so that asyncio.timeout and TaskGroup can tell their own cancellations apart.)
  • trio and anyio are level-triggered. While a task is inside a cancelled scope, every checkpoint raises the cancel exception again until the scope is exited. Swallowing it does not make it go away.
python
import asyncio

import anyio


async def asyncio_edge() -> None:
    async def child() -> str:
        try:
            await asyncio.sleep(1)
        except asyncio.CancelledError:
            print("asyncio: caught once, swallowing")
        await asyncio.sleep(0.01)
        return "asyncio: second sleep completed"

    task = asyncio.create_task(child())
    await asyncio.sleep(0.01)
    task.cancel()
    print(await task)


async def anyio_level() -> None:
    with anyio.move_on_after(0.01):
        try:
            await anyio.sleep(1)
        except anyio.get_cancelled_exc_class():
            print("anyio: caught once, swallowing")
        try:
            await anyio.sleep(1)
        except anyio.get_cancelled_exc_class():
            print("anyio: cancelled again at the next checkpoint")
            raise


async def main() -> None:
    await asyncio_edge()
    await anyio_level()


anyio.run(main)
# Output:
# asyncio: caught once, swallowing
# asyncio: second sleep completed
# anyio: caught once, swallowing
# anyio: cancelled again at the next checkpoint

Consequences for migration:

  • Code that "handles" cancellation by catching CancelledError and continuing (retry loops, best-effort cleanup with await) behaves differently: under anyio the next await raises again.
  • Cleanup that must await during cancellation needs a shielded scope (see cancellation-and-exception-groups.md).
  • In exchange, cancellation is reliable: a timeout cannot be accidentally defeated by a library that swallows CancelledError once.

Checkpoints

A checkpoint is a point where the task (a) checks for cancellation and (b) may let other tasks run. In trio and anyio, every async function of the library is a checkpoint, even if it would not block (e.g. await send.send(x) into a non-full buffer, await lock.acquire() on a free lock). In asyncio, an await on something already done may not yield at all.

Needanyiotrio
Yield and check for cancellationawait anyio.lowlevel.checkpoint() (or await anyio.sleep(0))await trio.lowlevel.checkpoint()
Check for cancellation without yieldingawait anyio.lowlevel.checkpoint_if_cancelled()await trio.lowlevel.checkpoint_if_cancelled()
Yield without checking cancellationawait anyio.lowlevel.cancel_shielded_checkpoint()await trio.lowlevel.cancel_shielded_checkpoint()

Practical rule: a CPU-heavy loop with no awaits cannot be cancelled and starves other tasks on every backend. Move it to a thread (anyio.to_thread.run_sync) or process, or add a checkpoint per batch:

python
import anyio


def tokenize(text: str) -> list:
    return text.split()


async def main() -> None:
    corpus = ["structured concurrency in python"] * 3000
    total = 0
    for i, doc in enumerate(corpus):
        total += len(tokenize(doc))
        if i % 500 == 0:
            await anyio.lowlevel.checkpoint()     # cancellable, lets other tasks run
    print(total)


anyio.run(main)
# Output: 12000

Readiness with start and task_status

tg.start(func) starts a child and waits until the child calls task_status.started(value); the value is returned to the parent. This replaces the asyncio "create a task plus an Event" dance for servers, workers and connection pools:

python
import anyio


async def vector_db_proxy(*, task_status=anyio.TASK_STATUS_IGNORED) -> None:
    await anyio.sleep(0.01)                  # bind socket, warm caches...
    task_status.started(6333)               # parent resumes here with the port
    await anyio.sleep_forever()              # serve until cancelled


async def main() -> None:
    async with anyio.create_task_group() as tg:
        port = await tg.start(vector_db_proxy)
        print("proxy ready on", port)
        tg.cancel_scope.cancel()            # shut everything down


anyio.run(main)
# Output: proxy ready on 6333

If the child raises before calling started(), tg.start() raises that exception in the parent: startup failures are not lost. trio has the same API: await nursery.start(func) with task_status=trio.TASK_STATUS_IGNORED.

Errors: ExceptionGroup

When several children fail at once, there is no single "the" exception. Task groups therefore raise an ExceptionGroup (PEP 654, builtin on 3.11+, exceptiongroup backport on 3.9/3.10):

  • anyio 4.x always raises an ExceptionGroup from a task group, even for a single error;
  • asyncio.TaskGroup does the same;
  • trio nurseries do the same by default since trio 0.25 (strict_exception_groups=True).

Handle with except* (3.11+) or exceptiongroup.catch() on older Pythons. Full treatment in cancellation-and-exception-groups.md.

AnyIO 4 on the asyncio backend

anyio is a layer, not a new event loop:

AspectBehaviour (verified with anyio 4.14 and 4.15)
Event loopanyio.run(main) runs a normal asyncio loop (backend="asyncio" is the default); backend_options={"use_uvloop": True} selects uvloop when installed
Native asyncio codeWorks inside anyio code: asyncio.Queue, asyncio.wait_for, asyncpg, aiohttp
Cancel scopes vs native awaitsAn anyio scope cancels native asyncio awaits inside it (they see CancelledError)
Cancel exceptionanyio.get_cancelled_exc_class() is asyncio.CancelledError
Level-triggered cancelEmulated: anyio keeps re-delivering cancellation to tasks inside a cancelled scope
Primitivesanyio.Lock/Event/Semaphore/CapacityLimiter work on both backends; they can be created outside a running loop (adapters), but are used inside one
Threadsanyio.to_thread.run_sync uses anyio's own worker threads limited by a default CapacityLimiter(40), not the loop's default executor
Native tasks started by librariesNot part of any anyio task group; they behave as in plain asyncio

On the trio backend, anything that touches the asyncio loop (asyncpg, aiohttp, asyncio.*) fails, which is why an incremental migration keeps the asyncio backend until the asyncio-only pieces are gone or isolated.

Version notes

VersionChange
Python 3.11asyncio.TaskGroup, asyncio.timeout, ExceptionGroup/except*, asyncio.TimeoutError becomes an alias of builtin TimeoutError
anyio 4.0Task groups always raise ExceptionGroup; create_memory_object_stream[T](...) subscript form
anyio 4.14 / 4.15 metadataRequires-Python >=3.10; on 3.9 pin an older anyio 4.x (check that release's Requires-Python)
anyio 4.1to_thread.run_sync(..., abandon_on_cancel=True) (replaces cancellable=)
anyio 4.14TaskGroup.create_task(coro) and start_soon returning a TaskHandle; tg.cancel() shortcut
anyio 4.15anyio.gather, anyio.as_completed, anyio.amap, anyio.Future, fail_at / move_on_at
trio 0.23trio.to_thread.run_sync(..., abandon_on_cancel=...) (replaces cancellable=)
trio 0.25strict_exception_groups=True by default: nurseries always raise ExceptionGroup

The 4.14/4.15 additions were read from the docstrings of the installed anyio releases. They are recent, so code that must support older anyio versions (and trio) should stick to start_soon plus result containers, which is what this skill's rewriter emits.

More in Python

python-async-deadlock-debugger

Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.

python-asyncio-pattern-advisor

Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.