Skip to content

Async runtimes & event loops

‹ docs index

processkit's async surface is asyncio-native. Every a-prefixed verb (aoutput, arun, astart, …) and every streaming handle (stdout_lines(), output_events(), interactive stdin) is bridged onto the running asyncio event loop by [pyo3-async-runtimes], so it needs a real asyncio loop underneath. This page says exactly which runtimes provide one — and which don't.

Support at a glance

Runtime Supported Why
asyncio (stdlib) Yes — native The bridge targets it directly
uvloop Yes A drop-in asyncio loop policy — the bridge sees an ordinary running asyncio loop
anyio on the asyncio backend Yes anyio's asyncio backend runs a real asyncio loop; the bridged awaitables await normally
anyio on the trio backend No No asyncio loop is present
trio (native) No No asyncio loop, and the bridge has no trio backend
curio No Same reason as trio

The dividing line is simple: is a real asyncio event loop running? If yes (plain asyncio, uvloop, or anyio-on-asyncio), the whole async surface works unchanged. If no (trio, anyio-on-trio, curio), the a-prefixed verbs can't be awaited — the sync surface (output(), run(), ProcessGroup, …) still works from any thread, since it doesn't touch an event loop at all.

asyncio & uvloop

The default. Nothing to configure:

import asyncio
from processkit import Command

async def main():
    result = await Command("git", ["rev-parse", "HEAD"]).aoutput()
    print(result.stdout.strip())

asyncio.run(main())

uvloop is a faster asyncio loop implementation, installed as the loop policy. Because it is an asyncio loop, processkit needs no special handling — install the policy and every verb behaves identically (only with faster I/O scheduling):

import asyncio
import uvloop
from processkit import Command

async def main():
    await Command("./build.sh").arun()

uvloop.install()      # or asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
asyncio.run(main())   # 3.12+: asyncio.run(main(), loop_factory=uvloop.new_event_loop)

anyio

anyio runs on one of two backends. On its default asyncio backend, processkit works today with no changes — anyio does not hide the underlying asyncio loop, so the bridged awaitables await normally, and asyncio cancellation (which anyio maps onto its own cancel scopes) still tears the tree down:

import anyio
from processkit import Command

async def main():
    result = await Command("git", ["status", "--short"]).aoutput()
    print(result.stdout)

anyio.run(main)   # default backend="asyncio" — supported

On the trio backend (anyio.run(main, backend="trio")) there is no asyncio loop, so the a-prefixed verbs cannot be awaited — see below.

trio

Native trio (and anyio's trio backend, and curio) are not supported. A trio program runs trio's own scheduler, not an asyncio loop, so the awaitables processkit hands back — asyncio.Futures produced by the asyncio-wired bridge — aren't trio-awaitable, and the binding refuses with a clear "no running asyncio event loop" error anyway.

If you're on trio and need processkit, the pragmatic bridge is [trio-asyncio], which runs an asyncio loop inside a trio program; processkit's verbs then execute in that asyncio context. That is a user-side integration this package does not ship or test — treat it as unsupported-but-possible, not a guarantee. The reliable alternative is the synchronous surface (output(), run(), ProcessGroup, …), which needs no event loop and is usable from a trio worker thread.

Why asyncio-native

This is a deliberate, standing decision (project ROADMAP, Open decision #2), not an oversight or a v1-only stopgap:

  • The async surface is bridged tokio ↔ asyncio by [pyo3-async-runtimes], which targets asyncio and ships no trio backend. Native trio would mean writing a loop-agnostic bridge from scratch.
  • That bridge is the single highest-risk part of the binding. Re-implementing it against trio's cancellation model — level-triggered cancel scopes and checkpoints, versus asyncio's edge-triggered CancelledError — while preserving the kill-on-cancel no-orphan guarantee is a research effort in its own right, on a binding whose whole thesis is to stay thin and not reimplement hard concurrency logic.
  • The anyio ecosystem is not actually shut out — anyio-on-asyncio works — so the excluded slice is specifically the trio-family loops, a smaller segment.

The path if this is ever revisited: port the pure-Python readiness helpers to anyio primitives first (cheap, and it makes wait_for_port / wait_until loop-agnostic), then evaluate a loop-agnostic compiled bridge once pyo3-async-runtimes grows a trio backend or a concrete demand signal appears.

The readiness helpers

The readiness helpers (wait_for_port, wait_for_line, wait_for_path, wait_until) are pure Python but built on asyncio primitives, so they follow the same rule as the rest of the surface: they need a running asyncio loop (asyncio, uvloop, or anyio-on-asyncio). In particular wait_for_line consumes a RunningProcess stream, which is itself asyncio-bridged — so there is no configuration in which the streaming surface is asyncio-only while the helpers are not.


Next: Timeouts & cancellation · Streaming & interactive I/O · Platform support · Cookbook