Skip to main content

08. Async bridge

This note covers the async bridge between Mochi (which has async fn from MEP-13) and Python (which has async def plus asyncio). Async is the hardest cross-language concern in the bridge because both languages have first-class concurrency models that do not naturally compose.

The asyncio model

Python's asyncio runs coroutines on an event loop:

async def fetch(url):
async with httpx.AsyncClient() as client:
return await client.get(url)

asyncio.run(fetch("https://example.com"))

asyncio.run does the following:

  1. Creates a new event loop (asyncio.new_event_loop()).
  2. Sets it as the current loop (asyncio.set_event_loop(loop)).
  3. Runs the coroutine to completion (loop.run_until_complete(coro)).
  4. Cancels all remaining tasks, runs the loop until they finish.
  5. Closes the loop (loop.close()).
  6. Resets the current loop to None.

The cost is ~0.5-1ms on a warm interpreter; the cost on a cold interpreter (first call) is closer to 5-10ms because of asyncio module import.

The alternative is to keep a loop alive across calls and run_coroutine_threadsafe onto it from outside. This avoids the per-call setup cost but introduces shared mutable state (the running loop) with all the lifetime hazards that entails.

The MEP-71 choice: per-call by default, persistent opt-in

The bridge defaults to per-call asyncio.run:

import python "httpx" as httpx

async fn fetch(url: string): string {
let client = httpx.AsyncClient() // Mochi value, Python AsyncClient handle
let resp = await client.get(url) // Each await crosses the boundary
return resp.text // Mochi string
}

Under the hood, the synthesised wrapper translates each await client.get(url) into:

def _bridge_get(client_handle, url):
coro = client_handle.get(url)
return asyncio.run(coro)

The Mochi-side await is the Mochi runtime suspending until the wrapper returns; the Python side runs the coroutine to completion synchronously. Each Mochi await is one full asyncio.run cycle.

To enable persistent mode:

[python]
async-mode = "persistent"

In persistent mode, a singleton event loop is created on first use and kept alive on a dedicated Python thread. Mochi await translates to asyncio.run_coroutine_threadsafe(coro, _PERSISTENT_LOOP).result(). Subsequent calls reuse the loop; cost amortises.

The cross-loop hazard

The persistent-mode hazard: Python objects bound to a specific event loop (Future, Task, AsyncIterator, AsyncContextManager) can only be awaited on that loop. If a Mochi function captures such an object and passes it to another Mochi function running on a different loop, behaviour is undefined.

Per-call mode avoids the hazard by construction: every loop is created and destroyed in the same call, so capturing a loop-bound object outside that call gives a stale reference that the next call's wrapper detects and rejects.

Persistent mode has the hazard. The bridge mitigates by:

  1. One singleton loop per Mochi process. Persistent mode does not create multiple loops; the single loop is the only loop.
  2. Capturing loop-bound objects is explicit. Returning a Future, Task, or AsyncIterator from a Python call requires the caller to hold the result as a PyObject handle; the wrapper does not auto-convert.
  3. Re-entrancy detection. If a Mochi await is in flight on the persistent loop and the inner Python code tries to await a Mochi callback that itself awaits the persistent loop, the wrapper detects the cycle and refuses.

Mochi callbacks into Python async

The opposite direction: a Mochi async fn is passed to Python as a callable, and Python awaits it.

import python "asyncio" as asyncio

async fn worker(item: int): int {
let result = compute(item)
return result
}

async fn main() {
let items = [1, 2, 3, 4, 5]
let coros = items.map(worker) // [Coroutine[int], ...] in Python
let results = await asyncio.gather(*coros)
return results.sum()
}

The Mochi async fn worker becomes a Python async def wrapper that awaits the Mochi runtime. The wrapper:

async def _bridge_worker(item):
return await _MOCHI_RUNTIME.await_async(worker_handle, item)

_MOCHI_RUNTIME.await_async is a Python coroutine that suspends until the Mochi runtime signals completion of the Mochi-side worker. The mechanism uses a loop.create_future() + loop.call_soon_threadsafe pattern: the Mochi runtime invokes the future's set_result from a Mochi thread, and asyncio resumes the Python coroutine.

This works under both per-call and persistent modes. Under per-call mode, the Python asyncio.run calls the wrapped Mochi callback, which suspends Python, runs Mochi work on a separate runtime, and resumes Python when done. Under persistent mode, the same pattern works on the singleton loop.

Cancellation

asyncio cancellation propagates via CancelledError. When the Mochi side cancels an await (e.g., via select from MEP-13), the wrapper must propagate that cancellation into the Python coroutine.

Per-call mode: cancellation happens by signaling the running asyncio.run. The wrapper installs a cancel handler on the loop and triggers task.cancel() when Mochi cancels.

Persistent mode: cancellation is sent via loop.call_soon_threadsafe(task.cancel). The Python coroutine receives CancelledError. The Mochi side waits for the wrapper to return (which it does after cancellation is processed) before treating the Mochi await as cancelled.

Edge case: a Python coroutine that catches CancelledError and continues running. The wrapper does not force-kill; cancellation is cooperative. Mochi's runtime times out after [python].async-cancel-timeout (default 30s) and treats the call as failed.

Timeouts

Mochi's await accepts a timeout (MEP-13 §3.3). The bridge translates this to asyncio.wait_for(coro, timeout=t). If the timeout fires, Python raises asyncio.TimeoutError, which the wrapper coerces to Mochi's Error::Timeout.

Persistent mode reuses the same wait_for pattern; the timeout is per-call, not per-loop.

The uvloop / trio question

Python has alternative event-loop implementations:

  • uvloop: a libuv-based replacement for asyncio's selector_events loop. 2-4x faster, drop-in compatible. Users opt in via uvloop.install().
  • trio: a structured-concurrency runtime with a different cancellation model. Not asyncio-compatible.
  • anyio: a compatibility layer over asyncio + trio. Allows code to run on either.

MEP-71's stance:

  • uvloop: works transparently. If the user's Python deps include uvloop and the user calls uvloop.install() before the bridge's first asyncio.run, the bridge picks up uvloop's loop policy automatically. No bridge-side configuration needed.
  • trio: not supported. The bridge's asyncio.run does not work for trio coroutines. A Python dep that uses trio internally surfaces a SkipReason::IncompatibleAsyncRuntime at lock time (detected by walking the dep's import trio AST).
  • anyio: works because anyio code is asyncio-compatible by default.

The Mochi runtime hook

The Mochi runtime exposes an entry point at runtime/python/mochi_runtime/async_bridge.py:

class AsyncBridge:
def __init__(self, mode: str):
self.mode = mode
self._loop = None
self._loop_thread = None
if mode == "persistent":
self._start_persistent_loop()

def run_coro(self, coro):
if self.mode == "per-call":
return asyncio.run(coro)
else:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result()

def _start_persistent_loop(self):
import threading
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._loop.run_forever,
daemon=True,
name="mochi-asyncio-loop",
)
self._loop_thread.start()

The wrapper imports this and calls _BRIDGE.run_coro(coro) for every Mochi-to-Python await.

Performance characteristics

ModeCost per awaitLoop countShared state
Per-call (cold)5-10ms (first call)1 transientNone
Per-call (warm)0.5-1ms1 transientNone
Persistent (warm)50-100µs1 persistentYes
Native Python asyncio.run once0.5-1ms once + 50-100µs per inner await1 transientNone inside

The persistent mode is ~10x faster per await. The trade-off is the shared-state hazard.

The default per-call mode is correct for the typical use case: each Mochi function does one Python async call and returns. The persistent mode is for hot loops (thousands of small async calls).

Cross-references