Background

WebAssembly is a technology for executing compiled programs in the web browser at near-native speeds. However, it has a number of current limitations, including that it does not support coroutines/asynchronicity.

In OpenTally, WebAssembly is used to run code for counting an election. This runs inside a Web Worker, so that the counting does not block the UI thread. However, on occasion, we may need to temporarily interrupt the counting – for example, when there is a tie, to prompt the user for input to break the tie. This is an asynchronous mechanism – we need to pause the counting, return control to the UI thread to display a prompt, wait for the user to provide input, then return to the Web Worker and resume counting. (This could be implemented using async/await or Promises, but in OpenTally we simply use callbacks.)

Asyncify is a tool that enables WebAssembly execution to be interrupted and resumed, to interact with async JavaScript code, but it is not directly compatible with Rust or with Rust's wasm-bindgen.

In this post, we present a method of leveraging the Asyncify technology in a way that is compatible with wasm-bindgen, to enable WebAssembly to be interrupted and resumed in an asynchronous way.

The approach

Suppose we have a simple Rust WebAssembly binary which adds two numbers from JavaScript:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
	fn get_second_number() -> u32;
}

#[wasm_bindgen]
pub fn add_two_numbers(arg: u32) -> u32 {
	let first_number;
	first_number = arg;
	
	let second_number = get_second_number();
	return first_number + second_number;
}

The JavaScript code to execute the WebAssembly is:

function get_second_number() {
	return 5;
}

async function init() {
	await wasm_bindgen("asyncify_vanilla_bg.wasm");
	alert(wasm_bindgen.add_two_numbers(10)); // Should alert "15"
}

init();

We can compile this code using:

$ cargo build --target wasm32-unknown-unknown
    Compiling asyncify-vanilla v0.1.0 (/home/runassudo/git/asyncify-vanilla)
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s

$ wasm-bindgen --target no-modules target/wasm32-unknown-unknown/debug/asyncify_vanilla.wasm --out-dir .

Opening the webpage and executing the JavaScript confirms that this outputs the expected value, 15.

Now let us suppose that the result of get_second_number may be available only asynchronously – for example, if the result depends on a network access or awaiting user interaction.

WebAssembly does not have very good support for asynchronous execution or threading. We also cannot directly pause execution of a WebAssembly function and resume it later – on an x86-like platform, I could do something like push the instruction pointer and local state to the stack, jump to another routine to handle the asynchronous process, and later pop the local state and instruction pointer from the stack to resume execution, but WebAssembly does not have instructions for manipulating the stack like this.

Enter Asyncify. Asyncify is a technology designed for Emscripten which enables WebAssembly to use synchronous APIs, essentially by allowing code to be interrupted and resumed.

A detailed explanation of Asyncify is available from Google here. At a high level, when add_two_numbers calls into get_second_number, we can instruct Asyncify to ‘unwind’ the stack. Asyncify has modified the compiled WebAssembly so that when this happens, instead of continuing normal execution, we immediately return from add_two_numbers (and any other functions higher in the call stack), storing the current local state and current position, essentially recreating a snapshot of the call stack. This would look something like:

pub fn add_two_numbers(arg: u32) -> u32 {
	let first_number;
	first_number = arg;
	
	let second_number = get_second_number();
	if __asyncify_state == Unwinding {
		save_locals(first_number);
		return;
	}
	return first_number + second_number; // Unreachable
}

Once the result of get_second_number becomes available, we then instruct Asyncify to ‘rewind’ the stack, and call add_two_numbers again. Asyncify has modified the compiled WebAssembly so that when this happens, instead of starting from the top of the function as normal, we skip to the point where the stack was unwound and restore the local variables, until we reach the call to get_second_number again. This would look something like:

pub fn add_two_numbers(arg: u32) -> u32 {
	let first_number;
	if __asyncify_state == Rewinding {
		first_number = restore_locals();
	} else {
		first_number = arg; // Skipped
	}
	
	let second_number = get_second_number();
	return first_number + second_number;
}

Now we are inside get_second_number again. We tell Asyncify that we have finished rewinding the stack and are ready to resume normal execution. We return the correct value from get_second_number, and resume normal execution in WebAssembly.

In order to achieve this, we do not need to make any changes to the Rust code. Emscripten's wasm-opt tool can be used to directly modify our compiled WebAssembly to add the stack unwinding/rewinding logic. First we need to determine the mangled name of the get_second_number import, so that we can instruct wasm-opt to only look at calls to that function:

$ wasm-dis asyncify_vanilla_bg.wasm | grep '(import "wbg" "__wbg_getsecondnumber_'
(import "wbg" "__wbg_getsecondnumber_6541641df44d6876" (func $asyncify_vanilla::get_second_number::__wbg_getsecondnumber_6541641df44d6876::h38e9ba5b71edf6c6 (result i32)))

Now we can pass this to wasm-opt:

$ wasm-opt --asyncify --pass-arg asyncify-imports@wbg.__wbg_getsecondnumber_6541641df44d6876 asyncify_vanilla_bg.wasm -o asyncify_vanilla_async.wasm

We also need to modify the JavaScript to activate the stack unwinding/rewinding logic. The folks at Google have created asyncify-wasm, a wrapper around the vanilla JS WebAssembly API to do this, but a side effect is that all exported WebAssembly functions become async, which is not supported by the bindings created by wasm-bindgen. Instead, we will implement this code manually.

Firstly, we must reserve some space in WebAssembly memory for storing the unwound stack:

var wasmRaw;
const DATA_ADDR = 16;
const DATA_START = DATA_ADDR + 8;
const DATA_END = 1024;

async function init() {
	wasmRaw = await wasm_bindgen("asyncify_vanilla_async.wasm");

	new Int32Array(wasmRaw.memory.buffer, DATA_ADDR).set([DATA_START, DATA_END]);
	
	let result = wasm_bindgen.add_two_numbers(10);
	// ...
}

The call to add_two_numbers may result in the stack being unwound, and when this occurs, the return value is meaningless, so we need to detect this:

async function init() {
	// ...
	let result = wasm_bindgen.add_two_numbers(10);
	
	if (wasmRaw.asyncify_get_state() !== 0) { // 0 if normal, 1 if unwinding, 2 if rewinding
		// Stack unwinding so ignore the result
	} else {
		alert(result); // Unreachable
	}
}

Next, we need to modify get_second_number to have different behaviour depending on whether this is the first or second time the function is called. The first time the function is called, the result is not yet available, so we have to unwind the stack and wait for the result to become available (for example, send the network request or wait for user input). In this case, we will just set a short timeout:

function get_second_number() {
	if (wasmRaw.asyncify_get_state() === 0) { // Normal execution
		setTimeout(secondNumberCallback, 1000); // Simulate async function queued
		
		// Unwind the stack
		wasmRaw.asyncify_start_unwind(DATA_ADDR);
		return null;
	} else {
		// ...
	}
}

When the result becomes available (in the example, when the secondNumberCallback timeout expires), we need to rewind the stack and call add_two_numbers again to resume execution. In a real setting, we would also need to store the result somewhere so it can be returned by get_second_number.

function secondNumberCallback() {
	// Rewind the stack and resume execution
	wasmRaw.asyncify_start_rewind(DATA_ADDR);
	
	alert(wasmRaw.add_two_numbers()); // Should alert "15"
}

Note that, this time, we have passed no arguments to add_two_numbers. This is because any relevant local variables will have been stored the first time when the stack was unwound, and will be automatically restored. For this reason, we are also using the raw version of add_to_numbers rather than the version wrapped by wasm-bindgen, to avoid wasm-bindgen attempting to handle these missing arguments.

Eventually, control will return to get_second_number. As discussed above, we will tell Asyncify that we have finished rewinding the stack, return the correct value, and resume normal execution:

function get_second_number() {
	if (wasmRaw.asyncify_get_state() === 0) {
		// ...
	} else {
		// Finished rewinding
		wasmRaw.asyncify_stop_rewind();
		return 5;
	}
}

Opening the webpage now and executing the JavaScript confirms that this outputs the expected value, 15, after a 1 second delay. This demonstrates that, during that 1 second, we were able to interrupt, then resume, the execution of the WebAssembly.

This methodology can be readily extended to more complex use cases, such as where wasm-bindgen is used to pass Strings or other data. A caveat is that, since we must return something from the first call to get_second_number, we need to make sure this is a value that can be correctly interpreted by wasm-bindgen. For example, if we declared get_second_number as fn get_second_number() -> String and then returned null from get_second_number on rewinding, the wasm-bindgen wrapper code would attempt to interpret null as a String, which is invalid. We would instead need to write something like:

#[wasm_bindgen]
extern "C" {
	fn get_second_number() -> Option<String>;
}

#[wasm_bindgen]
pub fn add_two_numbers(first_number: u32) -> u32 {
	let second_number: u32 = match get_second_number() {
		Some(s) => s.parse(),
		None => unreachable!(), // Unreachable because we would be unwinding instead
	};
	return first_number + second_number;
}

Something else worth noting is that if, when unwinding, Asyncify overflows the memory allocated for its storage, it will result in a WebAssembly unreachable. If this occurs, consider increasing the value of DATA_END.