Skip to main content

Async code and inter-canister calls

Overview

In programming, the async/await pattern is a syntactic feature of many programming languages that allows an asynchronous, non-blocking function to be structured in a similar way to an ordinary synchronous function.

On BIG, async/await is useful in the context of inter-canister calls. Inter-canister calls cannot be handled synchronously (as the two canisters interacting might not even be in the same subnet), so when a canister sends a call to another, it will have to wait until a response comes back before it can continue processing the call it received.

The async/await pattern makes this look straightforward, as the developer can write code as if processing happens sequentially and the call to the callee is handled “synchronously”. However, given BIG's message passing model, one has to be careful in case there are concurrent calls that message executions can interleave as these can lead to inconsistencies if they are not anticipated.

Overview of language runtime for asynchronous code

Most languages implement asynchronous code through constructs that they typically call futures (promises in Javascript). A future is a representation of an eventual value produced by some asynchronous computation. Typically, a future is modeled to have different states depending on whether the result of the asynchronous computation is ready or not. The language runtime can poll the future to learn about its result. As long as the result is not ready, the code will typically yield execution and the future will be polled again later.

In order to implement inter-canister calls on BIG, there would need to be a future which, once created, takes care of calling the respective system APIs to make the call and will become ready once the response from the calling canister is received. The language runtime can poll the future once it's ready and retrieve the result of the inter-canister call.

Inter-canister calls

Let's look at an example where two canisters interact with each other. In the following example, canister A calls into canister B.

/// Cube A
async fn foo() {
do_work();
call(canister_b, 'bar').await;
do_more_work(res);
}

/// Cube B
fn bar() {
do_some_more_work();
}

When canister A calls B, a future that represents the result of the call is created. The future calls the system API to enqueue the outgoing call. The future is not ready yet as it needs the response from canister B, so the code will yield and the message execution for canister A will stop at the point before do_more_work.

Once the response from B is delivered, the future will be ready and contain the result of the call. Once polled (by the language's runtime when the callback handler is invoked) it will pass the result to canister A and execution can proceed with the remaining part (i.e. do_more_work).

Asynchronous functions

In the following example, another call is added to a local function to canister A before making the inter-canister call to canister B.

/// Cube A
async fn foo() {
baz().await;
call(canister_b, 'bar').await;
do_more_work(res);
}

async fn baz() {
do_local_work();
}

/// Cube B
fn bar() {
do_some_more_work();
}

When baz().await is called a future is created to represent its result. The result can actually be immediately available since there's only local computation (i.e. no inter-canister call involved) happening in baz. So, the future can return a result directly when polled and message execution can still proceed up to the point where the call to canister B is made (similar to the example from the previous section).

The above behavior is one possibility depending on how the language runtime handles the async/await pattern. Rust follows this approach. In contrast, Motoko takes another approach where every async/await call will be converted to an inter-canister call, more specifically a self-call in case a local function is called. This means that in Motoko foo would be split across 3 message executions -- the first would be until baz is called, the second until canister B is called and the final one until the end of the function body.