443 lines
14 KiB
Markdown
443 lines
14 KiB
Markdown
|
|
# AnyIO Reference: Replacing asyncio Idioms
|
||
|
|
|
||
|
|
> **Skill mandate**: `import asyncio` is BANNED. Use `import anyio` exclusively.
|
||
|
|
> This reference targets AnyIO 4.x (2026 Python projects).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Task Groups (The Core Primitive)
|
||
|
|
|
||
|
|
AnyIO uses **structured concurrency** via task groups. A task group is an async context manager that guarantees all child tasks finish before the block exits.
|
||
|
|
|
||
|
|
### `start_soon` — fire-and-forget
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
async def worker(n: int) -> None:
|
||
|
|
await anyio.sleep(1)
|
||
|
|
print(f"task {n} done")
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with anyio.create_task_group() as tg:
|
||
|
|
for i in range(3):
|
||
|
|
tg.start_soon(worker, i)
|
||
|
|
print("all tasks finished")
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Signature**: `tg.start_soon(func, *args, name=None)`
|
||
|
|
- `func` must be a **coroutine function** (not a coroutine object).
|
||
|
|
- `name` is optional, for introspection/debugging.
|
||
|
|
- No return value; exceptions propagate as `ExceptionGroup` on exit.
|
||
|
|
|
||
|
|
### `start` — wait for ready signal
|
||
|
|
|
||
|
|
Use when a task must initialize before the caller proceeds (e.g., starting a server and then connecting to it).
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import TASK_STATUS_IGNORED, create_task_group, run
|
||
|
|
from anyio.abc import TaskStatus
|
||
|
|
|
||
|
|
async def start_server(port: int, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
|
||
|
|
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=port)
|
||
|
|
task_status.started() # unblocks tg.start()
|
||
|
|
await listener.serve(handler)
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with create_task_group() as tg:
|
||
|
|
await tg.start(start_server, 8080) # blocks until task_status.started()
|
||
|
|
# server is guaranteed ready here
|
||
|
|
async with await anyio.connect_tcp("127.0.0.1", 8080) as client:
|
||
|
|
...
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rule of thumb**:
|
||
|
|
- Use `start_soon` when you don't need to know when the task is ready.
|
||
|
|
- Use `start` when the task must signal readiness before you continue.
|
||
|
|
|
||
|
|
### `create_task` — retrieving return values (AnyIO 4.14+)
|
||
|
|
|
||
|
|
```python
|
||
|
|
async def add(x: int, y: int) -> int:
|
||
|
|
return x + y
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with anyio.create_task_group() as tg:
|
||
|
|
handle = tg.create_task(add(2, 4))
|
||
|
|
result = await handle # == 6
|
||
|
|
print(handle.return_value) # also 6
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Signature**: `tg.create_task(coro, *, name=None, context=None) -> TaskHandle[T]`
|
||
|
|
- Returns a `TaskHandle` you can `await` for the result.
|
||
|
|
- If the task raises, awaiting raises `TaskFailed` (or `TaskCancelled`).
|
||
|
|
- This is the canonical replacement for `asyncio.gather` when you need results.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. asyncio → anyio Cheat Sheet
|
||
|
|
|
||
|
|
| asyncio | anyio | Notes |
|
||
|
|
|---------|-------|-------|
|
||
|
|
| `asyncio.gather(a, b, c)` | `tg.create_task(a); tg.create_task(b); tg.create_task(c); results = [await h for h in handles]` | No direct gather; structured concurrency requires explicit task group scope. For fire-and-forget, use `tg.start_soon`. |
|
||
|
|
| `asyncio.create_task(coro)` | `tg.start_soon(func, *args)` or `tg.create_task(coro)` | `start_soon` takes a coroutine **function** + args. `create_task` takes a coroutine **object** and returns a handle. |
|
||
|
|
| `asyncio.sleep(n)` | `anyio.sleep(n)` | Identical semantics. |
|
||
|
|
| `asyncio.wait_for(coro, timeout)` | `with anyio.fail_after(timeout): await coro` | Raises `TimeoutError`. Use `move_on_after` for silent timeout. |
|
||
|
|
| `asyncio.Event()` | `anyio.Event()` | AnyIO events are **not reusable**; create a new one instead of `.clear()`. |
|
||
|
|
| `asyncio.Lock()` | `anyio.Lock()` | Use `async with lock:`. Pass `fast_acquire=True` if performance-critical. |
|
||
|
|
| `asyncio.Semaphore(n)` | `anyio.Semaphore(n)` | Same. Pass `fast_acquire=True` if performance-critical. |
|
||
|
|
| `asyncio.Condition()` | `anyio.Condition()` | Same semantics. |
|
||
|
|
| `asyncio.run(main())` | `anyio.run(main)` | Backend-agnostic entry point. |
|
||
|
|
| `asyncio.Queue(maxsize=N)` | `anyio.create_memory_object_stream[T](max_buffer_size=N)` | Returns `(send_stream, receive_stream)`. Supports `async for` on receive end. |
|
||
|
|
| `asyncio.to_thread(fn, *args)` | `anyio.to_thread.run_sync(fn, *args)` | Supports `abandon_on_cancel=True` and custom `limiter`. |
|
||
|
|
| `asyncio.run_coroutine_threadsafe(coro, loop)` | `anyio.from_thread.run(func, *args)` | Call async code from a worker thread. |
|
||
|
|
| `loop.call_soon_threadsafe(callback)` | `anyio.from_thread.run_sync(func, *args)` | Call sync code in event loop thread from worker thread, **with return value**. |
|
||
|
|
| `asyncio.shield(coro)` | `with anyio.CancelScope(shield=True): ...` | AnyIO shielding does not orphan tasks. |
|
||
|
|
| `asyncio.timeout(delay)` | `with anyio.fail_after(delay): ...` | AnyIO uses level cancellation, not edge cancellation. |
|
||
|
|
| `asyncio.CancelledError` | `anyio.get_cancelled_exc_class()` | Use this to catch cancellation portably across backends. |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Cancellation & CancelScope
|
||
|
|
|
||
|
|
AnyIO uses **level cancellation** (inspired by Trio), not asyncio's **edge cancellation**.
|
||
|
|
|
||
|
|
- **Edge cancellation** (asyncio): A `CancelledError` is injected once. If caught and not re-raised, the task keeps running.
|
||
|
|
- **Level cancellation** (anyio): As long as a task is inside an effectively cancelled scope, every yield point raises a new cancellation exception.
|
||
|
|
|
||
|
|
### Basic CancelScope
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import CancelScope, create_task_group, get_cancelled_exc_class, sleep, run
|
||
|
|
|
||
|
|
async def worker() -> None:
|
||
|
|
try:
|
||
|
|
await sleep(10)
|
||
|
|
except get_cancelled_exc_class():
|
||
|
|
print("cancelled!")
|
||
|
|
raise # ALWAYS re-raise cancellation exceptions
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with create_task_group() as tg:
|
||
|
|
tg.start_soon(worker)
|
||
|
|
await sleep(0.1)
|
||
|
|
tg.cancel_scope.cancel() # cancels all children
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Shielding
|
||
|
|
|
||
|
|
Shield a block from external cancellation. Essential for cleanup.
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import CancelScope, create_task_group, sleep, run
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with create_task_group() as tg:
|
||
|
|
with CancelScope(shield=True):
|
||
|
|
tg.start_soon(some_task)
|
||
|
|
tg.cancel_scope.cancel() # shielded block is protected
|
||
|
|
await sleep(1) # this still runs
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Combine with timeouts for graceful shutdown**:
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import CancelScope, move_on_after
|
||
|
|
|
||
|
|
async def do_something(resource) -> None:
|
||
|
|
try:
|
||
|
|
await run_async_stuff()
|
||
|
|
except BaseException:
|
||
|
|
# Allow up to 10s for cleanup, then move on
|
||
|
|
with move_on_after(10, shield=True):
|
||
|
|
await resource.aclose()
|
||
|
|
raise
|
||
|
|
```
|
||
|
|
|
||
|
|
### Structured Concurrency Guarantee
|
||
|
|
|
||
|
|
A task group contains its own `CancelScope`. If any child task raises an exception:
|
||
|
|
1. The task group's cancel scope is cancelled.
|
||
|
|
2. All other child tasks receive cancellation.
|
||
|
|
3. The task group waits for all children to finish.
|
||
|
|
4. The original exception (wrapped in `ExceptionGroup` if multiple) is re-raised.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Timeouts
|
||
|
|
|
||
|
|
Two context managers. Both create a `CancelScope` internally.
|
||
|
|
|
||
|
|
### `fail_after` — raises on timeout
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import fail_after, sleep, run
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
try:
|
||
|
|
with fail_after(5) as scope:
|
||
|
|
await sleep(10)
|
||
|
|
except TimeoutError:
|
||
|
|
print("timed out")
|
||
|
|
print(scope.cancelled_caught) # True
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### `move_on_after` — silent timeout
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import move_on_after, sleep, run
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
with move_on_after(5) as scope:
|
||
|
|
await sleep(10)
|
||
|
|
print("this never prints")
|
||
|
|
|
||
|
|
print("exited scope, cancelled =", scope.cancelled_caught)
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Combined with shielding
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import move_on_after
|
||
|
|
|
||
|
|
# Give cleanup 10 seconds, but don't let outer cancellation interrupt it
|
||
|
|
with move_on_after(10, shield=True):
|
||
|
|
await resource.aclose()
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Memory Object Streams (Queue Replacement)
|
||
|
|
|
||
|
|
Replaces `asyncio.Queue` with a safer, typed, structured-concurrency-friendly construct.
|
||
|
|
|
||
|
|
```python
|
||
|
|
from anyio import create_task_group, create_memory_object_stream, run
|
||
|
|
from anyio.streams.memory import MemoryObjectReceiveStream
|
||
|
|
|
||
|
|
async def consumer(stream: MemoryObjectReceiveStream[str]) -> None:
|
||
|
|
async with stream: # closes receive end on exit
|
||
|
|
async for item in stream:
|
||
|
|
print("received", item)
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
# Type-annotated stream creation (AnyIO 4+ syntax)
|
||
|
|
send_stream, receive_stream = create_memory_object_stream[str](max_buffer_size=10)
|
||
|
|
|
||
|
|
async with create_task_group() as tg:
|
||
|
|
tg.start_soon(consumer, receive_stream)
|
||
|
|
async with send_stream:
|
||
|
|
for i in range(5):
|
||
|
|
await send_stream.send(f"item {i}")
|
||
|
|
# send_stream closed → consumer's async for loop exits naturally
|
||
|
|
|
||
|
|
run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Key differences from `asyncio.Queue`**:
|
||
|
|
- **Bounded by default**: `max_buffer_size=0` means send blocks until a receiver is ready.
|
||
|
|
- **Cloneable**: Each producer/consumer can close its own clone. The stream only ends when **all** clones of one end are closed.
|
||
|
|
- **Async iterable**: `async for item in receive_stream:` works out of the box.
|
||
|
|
- **Type-safe**: Generic `create_memory_object_stream[T]()`.
|
||
|
|
- **Synchronous close**: Both `close()` and `async with` work.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Backend Selection
|
||
|
|
|
||
|
|
AnyIO is backend-agnostic. Code written against AnyIO APIs runs on both asyncio and Trio.
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
print("running on", anyio.current_async_library())
|
||
|
|
await anyio.sleep(1)
|
||
|
|
|
||
|
|
# Default backend (asyncio)
|
||
|
|
anyio.run(main)
|
||
|
|
|
||
|
|
# Explicit backend
|
||
|
|
anyio.run(main, backend="trio")
|
||
|
|
anyio.run(main, backend="asyncio", backend_options={"debug": True})
|
||
|
|
```
|
||
|
|
|
||
|
|
**Library design rule**: Never hardcode a backend. Let the application choose via `anyio.run()`. Libraries should only import `anyio` and avoid backend-specific APIs.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Compatibility with asyncio-only libraries
|
||
|
|
|
||
|
|
### Using asyncio libraries under the asyncio backend
|
||
|
|
|
||
|
|
If a third-party library exposes only an asyncio interface (returns asyncio coroutine objects), it works directly under the asyncio backend because AnyIO runs on top of asyncio's event loop:
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
import some_asyncio_only_lib # returns asyncio.Future/coroutine objects
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
# This works because under the asyncio backend, await passes through
|
||
|
|
result = await some_asyncio_only_lib.fetch_data()
|
||
|
|
|
||
|
|
anyio.run(main, backend="asyncio")
|
||
|
|
```
|
||
|
|
|
||
|
|
**Important**: This only works on the `asyncio` backend. On the `trio` backend, asyncio-native objects will not work.
|
||
|
|
|
||
|
|
### When you MUST use asyncio APIs
|
||
|
|
|
||
|
|
Some APIs have no AnyIO equivalent and require direct event loop access:
|
||
|
|
|
||
|
|
| Scenario | asyncio API | AnyIO approach |
|
||
|
|
|----------|-------------|----------------|
|
||
|
|
| Signal handlers | `loop.add_signal_handler()` | `anyio.open_signal_receiver()` |
|
||
|
|
| Custom protocols | `asyncio.Protocol` | Use AnyIO streams / sockets |
|
||
|
|
| Direct Future manipulation | `asyncio.Future` | Avoid; use AnyIO primitives |
|
||
|
|
| Eager task factories | `asyncio.eager_task_factory` | Experimental in AnyIO; avoid |
|
||
|
|
|
||
|
|
If you absolutely need the running loop:
|
||
|
|
|
||
|
|
```python
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
# ... do something loop-specific ...
|
||
|
|
# WARNING: this breaks backend-agnosticism
|
||
|
|
|
||
|
|
anyio.run(main, backend="asyncio")
|
||
|
|
```
|
||
|
|
|
||
|
|
**Best practice**: Wrap asyncio-only code in a backend-agnostic facade, and document that the feature requires the asyncio backend.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Idiomatic Code Snippets
|
||
|
|
|
||
|
|
### Snippet 1: Parallel HTTP requests with timeout and cleanup
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
async def fetch(url: str) -> bytes:
|
||
|
|
await anyio.sleep(0.5) # simulate
|
||
|
|
return b"data"
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
urls = ["a", "b", "c"]
|
||
|
|
async with anyio.create_task_group() as tg:
|
||
|
|
with anyio.move_on_after(5):
|
||
|
|
for url in urls:
|
||
|
|
tg.start_soon(fetch, url)
|
||
|
|
# All tasks are cancelled on timeout; task group waits for cleanup
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Snippet 2: Producer-consumer with memory object stream
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
from anyio.streams.memory import MemoryObjectReceiveStream
|
||
|
|
|
||
|
|
async def producer(send_stream: anyio.streams.memory.MemoryObjectSendStream[int]) -> None:
|
||
|
|
async with send_stream:
|
||
|
|
for i in range(100):
|
||
|
|
await send_stream.send(i)
|
||
|
|
|
||
|
|
async def consumer(receive_stream: MemoryObjectReceiveStream[int]) -> None:
|
||
|
|
async with receive_stream:
|
||
|
|
async for item in receive_stream:
|
||
|
|
print(f"consumed {item}")
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
send, receive = anyio.create_memory_object_stream[int](max_buffer_size=5)
|
||
|
|
async with anyio.create_task_group() as tg:
|
||
|
|
tg.start_soon(producer, send)
|
||
|
|
tg.start_soon(consumer, receive)
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Snippet 3: Calling sync code from async
|
||
|
|
|
||
|
|
```python
|
||
|
|
import time
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
# Run blocking function in worker thread
|
||
|
|
result = await anyio.to_thread.run_sync(time.sleep, 2)
|
||
|
|
print("done")
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Snippet 4: Calling async code from a worker thread
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
def blocking_callback() -> None:
|
||
|
|
# Inside a worker thread, call back into the event loop
|
||
|
|
anyio.from_thread.run(anyio.sleep, 1)
|
||
|
|
anyio.from_thread.run_sync(print, "hello from thread")
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
await anyio.to_thread.run_sync(blocking_callback)
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Snippet 5: Graceful shutdown with shielded cleanup
|
||
|
|
|
||
|
|
```python
|
||
|
|
import anyio
|
||
|
|
|
||
|
|
async def worker() -> None:
|
||
|
|
try:
|
||
|
|
await anyio.sleep_forever()
|
||
|
|
except anyio.get_cancelled_exc_class():
|
||
|
|
with anyio.CancelScope(shield=True):
|
||
|
|
await anyio.sleep(0.5) # cleanup
|
||
|
|
print("cleaned up")
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def main() -> None:
|
||
|
|
async with anyio.create_task_group() as tg:
|
||
|
|
tg.start_soon(worker)
|
||
|
|
await anyio.sleep(1)
|
||
|
|
tg.cancel_scope.cancel()
|
||
|
|
|
||
|
|
anyio.run(main)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Sources
|
||
|
|
|
||
|
|
- AnyIO Documentation (stable): https://anyio.readthedocs.io/en/stable/
|
||
|
|
- AnyIO GitHub (HEAD `cb245dba`): https://github.com/agronholm/anyio
|
||
|
|
- Task Groups: https://anyio.readthedocs.io/en/stable/tasks.html
|
||
|
|
- Cancellation & Timeouts: https://anyio.readthedocs.io/en/stable/cancellation.html
|
||
|
|
- Streams: https://anyio.readthedocs.io/en/stable/streams.html
|
||
|
|
- Synchronization: https://anyio.readthedocs.io/en/stable/synchronization.html
|
||
|
|
- Threads: https://anyio.readthedocs.io/en/stable/threads.html
|
||
|
|
- Basics / Backends: https://anyio.readthedocs.io/en/stable/basics.html
|
||
|
|
- Design Rationale (why asyncio is problematic): https://anyio.readthedocs.io/en/stable/why.html
|