How Monty Implements Async/Await Support: A Deep Dive into the Python Runtime

Monty implements async/await support through a three-layer architecture spanning syntax parsing, bytecode generation, and a specialized async executor that manages coroutines, external futures, and gather operations within the VM.

Monty is Pydantic's Rust-based Python implementation designed for high-performance async execution. Understanding how Monty implements async/await support requires examining its tightly integrated compiler and runtime layers, from the initial parsing of await expressions to the VM's task scheduling logic.

Syntax and Bytecode Layer

The journey of an await expression begins in the compiler frontend. When the parser encounters an await expression, it creates an Expr::Await(Box<ExprLoc>) node in crates/monty/src/prepare.rs. This representation wraps the inner expression that produces the awaitable object.

The bytecode compiler in crates/monty/src/bytecode/compiler.rs handles this node by first compiling the inner expression, then emitting Opcode::Await. This opcode definition lives in crates/monty/src/bytecode/op.rs. When the VM executes this opcode, it pops the top-of-stack value, which must be a valid awaitable, and initiates the suspension logic.

The runtime result of an await operation is defined by the AwaitResult enum in crates/monty/src/bytecode/vm/mod.rs. This enum distinguishes between several outcomes: pushing a new frame for a coroutine, yielding to the host for external futures, or returning a ready value.

Runtime Async Types

Monty represents async operations through specialized heap-allocated types defined in crates/monty/src/asyncio.rs. These types bridge the gap between Python-level async objects and the VM's execution engine.

Coroutine objects represent Python coroutines created by calling async def functions. The Coroutine struct stores the function ID, bound arguments, captured closure cells, and execution state (New, Running, or Completed). These are allocated on the Monty heap as HeapData::Coroutine.

ExternalFuture objects wrap async operations provided by the host environment. These appear as Value::ExternalFuture(CallId) where CallId is a monotonically increasing identifier allocated by the scheduler. This design allows Monty to await Rust async functions from Python code without blocking the VM.

GatherFuture objects implement asyncio.gather(*awaitables) semantics. The GatherFuture struct contains a list of GatherItem entries (either coroutine heap IDs or external future call IDs), task IDs for spawned coroutines, a results vector, the waiting task ID, and any pending external calls. This type enables concurrent execution of multiple awaitables while maintaining proper suspension semantics.

The Async Executor

The core async logic resides in crates/monty/src/bytecode/vm/async_exec.rs. When the VM encounters Opcode::Await, it invokes exec_get_awaitable, which acts as a dispatcher for the three awaitable types.

For coroutines, await_coroutine pushes a new frame onto the call stack and returns AwaitResult::FramePushed. The current task remains paused until the coroutine frame completes and returns a value via handle_task_completion.

For external futures, await_external_future records the current task as a waiter for the specific CallId and returns AwaitResult::Yield(pending_calls). This yields control to the host, which must resolve the pending async operations.

For gather operations, await_gather_future performs three critical actions: it spawns a new TaskId for each coroutine item in the gather list, aggregates any external CallIds into pending_calls, and returns AwaitResult::Yield(pending_calls). This allows the host to resolve external futures while the VM concurrently executes the spawned coroutine tasks.

When external futures resolve, the host calls FutureSnapshot::resume with the results. The VM performs an O(1) lookup via pending_calls to match each CallId to its awaiting task, stores the result in the appropriate GatherFuture.results slot, and either returns AwaitResult::ValueReady when all items complete or yields again with remaining pending calls.

Error Propagation

Monty's async implementation handles errors through the ExternalResult type. When a host-resolved future returns ExternalResult::Error(MontyException(...)), the VM immediately converts this into a Python exception that propagates up the call stack through normal try/except handling.

For gather operations, the first error encountered cancels all other pending tasks. The error is immediately returned as the result of the await gather(...) expression, following Python's asyncio.gather semantics with return_exceptions=False.

Host Integration and Python Bindings

Monty bridges async execution with host Python code through the monty-python crate. External async functions are declared in the external_functions list when creating a MontyRun instance. The host receives CallId values via the AwaitResult::Yield path, executes the corresponding Rust async code, and later calls FutureSnapshot::resume with the results.

The Rust-side bridge lives in crates/monty-python/src/convert.rs and crates/monty-python/src/external.rs. This architecture allows Monty to execute Python async code while delegating I/O-bound operations to the host's async runtime (such as Tokio) without blocking the VM's execution loop.

Practical Code Examples

Concurrent Execution with asyncio.gather

import asyncio

async def foo():
    return 10

async def bar():
    return 32

async def main():
    a, b = await asyncio.gather(foo(), bar())
    return a + b

await main()  # → 42

During execution, foo() and bar() produce awaitable objects. The asyncio.gather call creates a GatherFuture containing these awaitables. The VM yields control to the host to resolve any external futures while concurrently executing the coroutines, eventually assembling the result list [10, 32].

Awaiting External Host Functions

async def fetch():
    return await http_get('https://example.com')  # host-provided async function

result = await fetch()

Here, http_get returns an ExternalFuture with a unique CallId. When the VM executes the await opcode, it yields this ID to the host. The host performs the HTTP request asynchronously, then calls FutureSnapshot::resume with the result, allowing the VM to continue execution with the fetched data.

Error Handling in Async Code

async def failing_task():
    raise ValueError('boom')

try:
    await failing_task()
except ValueError as e:
    print(e)  # prints "boom"

When the host resolves the external call with an error result, the VM converts the ExternalResult::Error into a Python ValueError that propagates through the normal exception handling mechanism.

Summary

  • Monty implements async/await support through a three-layer architecture spanning syntax parsing, bytecode generation, and VM execution.
  • The parser creates Expr::Await nodes in prepare.rs, while the compiler emits Opcode::Await for the VM to execute.
  • Runtime types including Coroutine, ExternalFuture, and GatherFuture in asyncio.rs represent different awaitable objects on the Monty heap.
  • The async executor in async_exec.rs handles suspension logic, spawning tasks for coroutines, yielding to the host for external futures, and managing concurrent gather operations.
  • Error propagation converts host-side ExternalResult::Error values into Python exceptions that traverse the normal try/except stack.
  • Host integration through monty-python allows Monty to delegate I/O operations to external async runtimes while maintaining its own VM execution loop.

Frequently Asked Questions

How does Monty handle the await keyword at the bytecode level?

Monty compiles await expressions into the Opcode::Await instruction defined in crates/monty/src/bytecode/op.rs. When the VM executes this opcode, it calls exec_get_awaitable in crates/monty/src/bytecode/vm/async_exec.rs, which identifies whether the awaitable is a coroutine, external future, or gather object, then dispatches to the appropriate handler to manage suspension and task scheduling.

What is the difference between a Coroutine and an ExternalFuture in Monty?

A Coroutine represents a Python async function written in Monty, stored as HeapData::Coroutine in crates/monty/src/asyncio.rs, containing the function ID, arguments, and execution state. An ExternalFuture (represented as Value::ExternalFuture(CallId)) wraps async operations provided by the host environment, such as I/O operations implemented in Rust, allowing Monty to await external async functions without blocking the VM.

How does asyncio.gather work internally in Monty?

The asyncio.gather implementation creates a GatherFuture object that stores a list of GatherItem entries representing each awaitable. When awaited, the VM's await_gather_future function spawns separate tasks for each coroutine while aggregating external call IDs. The VM yields to the host with pending external calls, allowing concurrent execution. As results arrive via FutureSnapshot::resume, the VM populates the results vector, returning the final list once all tasks complete or propagating the first error encountered.

How does Monty propagate errors from async operations?

When a host-resolved external future returns ExternalResult::Error containing a MontyException, the VM immediately converts this into a Python exception that propagates through the normal try/except handling mechanism. For gather operations, the first error cancels all other pending tasks and immediately returns as the result of the await expression, matching standard Python asyncio.gather behavior with return_exceptions=False.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →