Microvium Async – Part 1
Introduction to async-await for embedded systems

Microvium Async – Part 1
Introduction to async-await for embedded systems

TL;DR: The async/await feature of JavaScript is an alternative to multithreading, state machines, or callback-based code for managing long-running operations. Microvium’s design of async-await makes it one of the most memory-efficient and ergonomic ways of writing such code for small devices.


This is the first of a series of posts on the design of async-await in the Microvium JavaScript engine:

The target audience of this post is primarily embedded programmers who may or may not be very familiar with async/await and might be interested in how it could help with embedded software development. In this post, I’ll talk about what it is, how to use it in Microvium, and how it compares to some of the other techniques that solve a similar problem. In later posts, I’ll dive deeper into some of the inner workings.

What is Async/Await?

In the world of programming, there are concepts that fundamentally shift how we write and reason about code. Async/await is one of these game-changing concepts. Primarily seen in languages like JavaScript and C#, the async/await pattern has revolutionized how we deal with asynchronous operations and manage flow control.

Async/await in JavaScript allows individual functions to be suspended and resumed, similar to suspending and resuming a thread but much safer, more ergonomic and memory-efficient.

When you declare a JavaScript function as async, its variables and state will be stored on the heap instead of the stack, so that the function can be suspended mid-execution without blocking other functions on the stack. It gets suspended at await points in the code.

Here’s a hypothetical example async function that describes the sequence of operations one might use to connect to an HTTP server and send a message1:

async function sendMessageToServer(message) {
  console.log('Turning on the modem...');
  await powerOnModem();

  console.log('Connecting to server...');
  const connection = await connectToServer('http://my-server.com');

  console.log('Sending message...');
  await connection.sendMessage(message);

  console.log('Message sent');
  await connection.disconnect();
  await powerOffModem();
}

At each await, the function sendMessageToServer is suspended. The engine transforms the remainder of the function into a callback that can be later invoked to resume execution, such as when powerOnModem completes. This callback is known as a continuation because when called, it will continue the async function where it left off.

Awaiting a C Function

If you’re a C programmer, the concept of async/await may be easiest to understand by looking at how you might implement powerOnModem in C, leveraging the builtin Microvium C function mvm_asyncStart which returns the aforementioned callback:

mvm_Value callback; // Global variable to hold the callback

mvm_TeError powerOnModem(mvm_VM* vm, ...) {
  callback = mvm_asyncStart(vm, ...);
  // ...
}

// Later, invoke the callback when the modem is powered on.
// This will continue `sendMessageToServer`.
mvm_call(vm, callback, ...);

Side note: this is not a complete example. It doesn’t show the glue code to make powerOnModem accessible to the JS code or how to prevent the callback from being freed by the garbage collector. Refer to the Microvium documentation for a more detailed example.

When Microvium encounters the statement await powerOnModem(), it suspends sendMessageToServer on the heap as a continuation callback. The call to mvm_asyncStart in powerOnModem returns this continuation callback so that the C code can later call it2.

The arguments that the callback expects are (isSuccess, result), where isSuccess must be true or false depending on whether the asynchronous operation logically succeeded or failed, and the result must be whatever you want the result of the await to be. In our example, the result of await powerOnModem() isn’t used, so it doesn’t matter. But if you were implementing connectToServer, you would want the result to be the connection, whatever that might be, since we can see that the async function uses the a result as the connection:

const connection = await connectToServer('http://my-server.com');

Awaiting a JavaScript Function

If instead of C, you wanted to implement powerOnModem in JavaScript, you don’t need to call mvm_asyncStart — you instead just declare the function as async, similar to before:

async function powerOnModem() {
  modemPowerPin.setHigh();
  await modemPowerOnDetected();
}

By declaring powerOnModem to be async, Microvium automatically calls the continuation callback of the caller (sendMessageToServer in this case) when powerOnModem finishes executing completely. So the caller resume automatically when the callee finishes.

In a sense, the async-await feature in Microvium transforms your async functions to callback-based functions under the hood. The return from one async function automatically calls the callback of the calling async function.

Await Anywhere

You can use await expressions pretty much anywhere in an async function. Examples include loops, conditionals, function call arguments — await can appear anywhere in an async function where an expression can appear, allowing the function to pause at that point and transforming the remainder of the function into a continuation.

This makes async-await a really convenient alternative to the complicated state machines you might otherwise use for this kind of logic in C. Managing counters, conditional paths, and nesting in a state machine can become a nightmare, but async-await can make these things can trivial.

What if you don’t await?

So far, we’ve seen examples where an async caller is suspended while waiting for an async callee. But that’s not much different to the behavior you get with normal function calls: when you call a function normally, the caller is suspended on the stack until the callee completes. What makes async functions different?

The difference becomes most apparent when consider the alternative: not awaiting. For example, let’s say we call sendMessageToServer without awaiting:

function processMessage(message) {
  sendMessageToServer(message);
  saveMessageToFlash(message);
}

In this hypothetical example, we’re not awaiting sendMessageToServer, so control will move on to saveMessageToFlash before sendMessageToServer has run to completion.

There’s no multithreading here. sendMessageToServer does not continue in the background on a thread. Rather, the call to sendMessageToServer will just return early, when sendMessageToServer reaches its first await point, which in this case is when it’s waiting for the modem to power on. So in this example, once the program is waiting for the modem to power on, it will start to save the message to flash.

This allows you to do event-driven multi-tasking without multithreading and without manually creating state machines or callbacks. Unlike with multithreading, you don’t need to worry about locking and synchronization, which makes the code much simpler and safer.

Memory efficiency

Microvium async functions are very lightweight compared to most other ways of achieving the same objective. Async functions like those shown in this post take just 6 bytes of RAM while they’re awaiting something3, plus an additional 2 bytes for each local variable.

6 bytes is tiny! If you wanted to use multithreading for this instead, the stack you dedicate to it might be hundreds of bytes. For example, on a Cortex-M MCU, this article says that a context switch uses at least 17 to 51 words (68 to 204 bytes) on the stack.

In an RTOS environment, such stacks are typically permanently allocated, whether the corresponding task is busy doing work or not. For example, a task for managing the modem might have a dedicated stack which is nearly empty most of the time while the modem is disconnected or idle, but requires enough space to handle bursts of activity for connecting and sending messages.

Microvium async-await gives the syntactic convenience of a dedicated thread while only using the minimum amount of memory required at any one time. Async frames that are no longer in use are freed by the garbage collector so the memory can be reused by other parts of the program.

I’ll note that the 6-byte figure in Microvium is not a characteristic of JavaScript but of the particular implementation and tradeoffs that Microvium makes. For comparison, async-await in node.js uses about 420 bytes per suspended async function, as measured on my desktop machine4.

Microvium closures are also tiny, so you’ll get similar memory efficiency if you implement your asynchronous code in Microvium using callbacks, but async/await gives you much better syntactic convenience.

It would even be hard to beat this level of memory efficiency using hand-crafted state machines in C, not to mention being substantially more complicated to implement.

Conclusion

In the world of embedded systems, managing asynchronous operations can be a complex and often cumbersome task. The introduction of the async/await pattern to the Microvium JavaScript engine not only simplifies these asynchronous operations but also brings efficiency and elegance to the code. By allowing functions to be suspended and resumed at specific points, asynchronous code becomes more legible and maintainable.

Microvium’s implementation of async/await takes memory efficiency to a new level, allowing such code to be targeted to much smaller devices than was possible before. The next smallest JavaScript engine that supports async-await is an order of magnitude larger than Microvium5. Even Espruino, which is quite large and well used

Stay tuned for the next episode where I pop the hood and show you how Microvium achieves this 6-byte target size.


  1. All the functions in these examples are made up. Microvium as an engine doesn’t give you any of these functions out-of-the-box. 

  2. This is a simplification. The mvm_asyncStart function actually returns a wrapper for either the continuation or a promise value, and the wrapper provides extra safety as well as executing the continuation or promise subscribers from the job queue rather than synchronously. mvm_asyncStart also manages the wrapping of the async C function in a promise if required. More on that in later posts. 

  3. That’s 6 bytes including a 2-byte allocation header. 

  4. That’s measured on a 64-bit Windows machine 

  5. I’m referring to QuickJS and XS, which both require at least 10x the flash and RAM space of Microvium. 

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.