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

RuntimeSupportedWhy
asyncio (stdlib)Yes — nativeThe bridge targets it directly
uvloopYesA drop-in asyncio loop policy — the bridge sees an ordinary running asyncio loop
anyio on the asyncio backendYesanyio's asyncio backend runs a real asyncio loop; the bridged awaitables await normally
anyio on the trio backendNoNo asyncio loop is present
trio (native)NoNo asyncio loop, and the bridge has no trio backend
curioNoSame 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. For a quick symptom-to-solution map, see Troubleshooting.

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.

Interpreter shutdown and the async bridge

Completed async operations are handed back on the event-loop thread. The tokio task stores a type-erased outcome in Rust memory and wakes one shared socket per event loop; loop.sock_recv(...) receives the wakeup and performs the Python value conversion plus Future.set_result() / set_exception() on the loop itself. Repeated stream steps reuse that same dispatcher rather than opening a socket for every __anext__. sock_recv is supported by the standard selector and Windows proactor loops, uvloop, and anyio's asyncio backend, so this safety property does not narrow the supported event-loop set.

This design matters at process exit. The previous upstream completion path entered Python from a detached tokio blocking thread and called loop.call_soon_threadsafe(...). The awaiting coroutine could resume while that foreign thread was still returning through Python frames; a short-lived program whose last act was the await could then begin Py_FinalizeEx underneath it and intermittently die from SIGSEGV after all useful work had finished. The socket handoff never enters Python from the completing runtime thread, so once the await resumes there is no bridge thread left inside the interpreter. Ordinary interpreter finalization is safe; no os._exit or post-await delay is required.

Cancellation keeps the same contract: cancelling the backing asyncio Future signals the tokio work to drop and cancels the private socket receive, which preserves asyncio.CancelledError and process-tree teardown.

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.

sample_stats (see Process groups) is the same story: pure Python built on asyncio.sleep, needing the same running asyncio loop as everything else here.


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