6

Microvium async – Part 3 – Coder Mike

 8 months ago
source link: https://coder-mike.com/blog/2023/11/10/microvium-async-part-3/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Microvium async – Part 3Making promises

Microvium async – Part 3Making promises

TL;DR: Microvium’s async/await uses continuation-passing style (CPS) at its core for efficiency, but automatically creates promises as well when required. It does so by defining a handshake protocol between the caller and callee to establish when promises are required. Promises in Microvium also pretty compact, but not as compact as raw CPS.


This is part 3 of a 4-part series on async-await in the Microvium JavaScript engine:

In the previous posts, I talked about why async-await is useful, and how a suspended function in Microvium can be as small as 6 bytes by using continuation-passing style (CPS) rather than promises at its core. In this post, I’ll talk more about promises and how they interact with Microvium’s CPS core.

What is a Promise?

Async functions in JavaScript return promises, which are objects that represent the future return value. This post will mostly assume that you’re already familiar with the concept of a Promise in JavaScript. Take a look at MDN for a more detailed explanation.

Microvium doesn’t support the full ECMAScript spec of Promise objects. Like everything in Microvium, it supports a useful subset of the spec:

  • Promises are objects which are instances of the Promise class and inherit from Promise.prototype.
  • You can manually construct promises with new Promise(...).
  • You can await promises.
  • Async functions return promises, if you observe the result (more on that later).
  • Async host1 functions will also automatically return promises (more on that later).

Notably, Microvium promises don’t have a then or catch method, but you could implement these yourself in user code by adding a function to Promise.prototype which is an async function that awaits the given promise and calls the handlers. Microvium also doesn’t have built-in functions like Promise.all, but these can similarly be implemented in user code if you need them. The general philosophy of Microvium has been to keep it small by omitting things that can be added in user code, since that gives the user control over the space trade-off.

It’s interesting to note then that Microvium doesn’t support thenables. Firstly, promises do not have a then method out of the box. Secondly, you cannot await something that isn’t a promise (e.g. a different object which happens to have a then method).

Memory structure

The memory structure of a promise object is as follows, with 4 slots:

2023-10-24-promise.png

The next and __proto__ slots are common to all objects, and I discuss these more in Microvium has Classes.

The status slot is an enumeration indicating whether the promise is pending, resolved, or rejected.

To squeeze the size down as small as possible, the out slot is overloaded and can be any of the following:

  • A single subscriber (the slot points to a closure)
  • A list of subscribers (the slot points to an array)
  • No subscribers (the slot is empty)
  • The resolved value (if the promise is resolved)
  • The error value (if the promise is rejected)

With this design, a promise requires exactly 10 bytes of memory (4 slots plus the allocation header), which isn’t too bad. To put this in context by comparison, a single slot (e.g. a single variable) in the XS JavaScript engine is already 16 bytes.

An interesting thing to note is that there is no separate resolve and reject list of subscribers, and instead just one list of subscribers. My early designs of promises had separate resolve and reject subscribers, because this seemed natural given that JavaScript naturally has separate then and catch handlers. But after several iterations, I realized that it’s significantly more memory efficient to combine these. So now, a subscriber is defined as a function which is called with arguments (isSuccess, result). You may notice this is exactly the same signature as a CPS continuation function as I defined in the previous post, meaning a continuation can be directly subscribed to a promise.

Await-calling

So, we’ve discussed how Microvium’s async-await uses CPS under the hood (the previous post) and how promises look, but there’s a missing piece of the puzzle: how do promises interact with CPS? Before getting into the details, I need to lay some groundwork.

The most common way that you use an async function in JavaScript is to call it from another async function and await the result. For example:

const x = await myAsyncFunction(someArgs);
const x = await myAsyncFunction(someArgs);

This syntactic form, where the result of a function call is immediately awaited, is what I call an await-call, and I’ll use this terminology in the rest of the post. Await-calling is the most efficient way of calling an async function in Microvium because the resulting Promise is not observable to the user code and is completely elided in favor of using the CPS protocol entirely.

CPS Protocol

As covered in the previous post, Microvium uses CPS under the hood as the foundation for async-await (see wikipedia’s Continuation-passing style). I’ve created what I call the “Microvium CPS protocol” as a handshake between a caller and callee to try negotiate the passing of a continuation callback. The handshake works as follows.

An async caller’s side of the the handshake:

  1. If a caller await-calls a callee, it will pass the caller’s continuation to the callee in a special callback VM register, to say “hey, I support CPS, so please call this callback when you’re done, if you support CPS as well”.
  2. When control returns back to the caller, the returned value is either a Promise or an elided promise2. The latter is a special sentinel value representing the absence of a promise. If it’s a promise, the caller will subscribe its continuation to the promise. If the promise is elided, it signals that the callee accepted the callback already and will call it when it’s finished, so there’s nothing else to do.

An async callee’s side of the handshake:

  1. An async callee is either called with a CPS callback or it isn’t (depending on how it was called). If there is a callback, the callee will remember it and invoke it later when it’s finished the async operation. The synchronous return value to the caller will be an elided promise to say “thanks for calling me; just to let you know that I also support CPS so I’ll call your callback when I’m done”.
  2. If no callback was passed, the engine synthesizes a Promise which it returns to the caller. When the async callee finishes running, it will invoke the promise’s subscribers.

These callbacks are defined such that you call them with (isSuccess, result) when the callee is finished the async operation. For example callback(true, 42) to resolve to 42, or callback(false, new Error(...)) to reject to an error.

If both the caller and callee support CPS, this handshake completely elides the construction of any promises. This is the case covered in the previous post.

But this post is about promises! So let’s work through some of the cases where the promises aren’t elided.

Observing the result of an async function

Let’s take the code example from the previous post but say that instead of foo directly awaiting the call to bar, it stores the result in a promise, and then awaits the promise, as follows:

async function foo() {
const promise = bar();
await promise;
async function bar() {
let x = 42;
await baz();
async function foo() {
  const promise = bar();
  await promise;
}

async function bar() {
  let x = 42;
  await baz();
}

Note: Like last time, the variable x here isn’t used but is just to show where variables would go in memory.

The key thing here is that we’re intentionally breaking CPS by making the promise observable, so we can see what happens.

The memory structure while bar is awaiting will look like this:

2023-10-24-normal-call-to-async-1024x474.png

The memory structure looks quite similar to that showed in the previous post, but now with a promise sitting between foo continuation and bar continuation. Foo’s continuation is subscribed to the promise (foo will continue when the promise settles), and bar‘s “callback” is the promise. A promise is not a callable object, so the term “callback” is not quite correct here, but when bar completes, the engine will call of the subscribers of the promise. (Or more accurately, it will enqueue all of the subscribers in the job queue, which is the topic of the next post.)

This structure comes about because when bar is called, it will notice that it wasn’t provided with a callback (because the call was not an await-call) and so it will create the promise. The await promise statement also isn’t an await-call (it’s not a call at all), but since the awaitee is a promise, foo will subscribe its continuation to that promise.

The end result here is that we’ve introduced another 10 bytes of memory overhead and inefficiency by making the promise observable, but we’ve gained some level of flexibility because we pass the promise around and potentially have multiple subscribers.

A host async function

We can gain some more insight into what’s happening here if we consider the case where bar is actually a host function implemented in C rather than JavaScript. I gave an example of this in the first post in this series. Since you’ve made it this far in the series, let’s also make this example a little more complete, using an mvm_Handle to correctly anchor the global callback variable to the GC.

mvm_Handle globalCallback;
mvm_TeError bar(
mvm_VM* vm,
mvm_HostFunctionID hostFunctionID,
mvm_Value* pResult, // Synchronous return value
mvm_Value* pArgs,
uint8_t argCount
// Get a callback to call when the async operation is complete
mvm_Value callback = mvm_asyncStart(vm, pResult);
// Let's save the callback for later
mvm_handleSet(&globalCallback, callback);
/* ... */return MVM_E_SUCCESS;
mvm_Handle globalCallback;

mvm_TeError bar(
  mvm_VM* vm,
  mvm_HostFunctionID hostFunctionID,
  mvm_Value* pResult, // Synchronous return value
  mvm_Value* pArgs,
  uint8_t argCount
) {
  // Get a callback to call when the async operation is complete
  mvm_Value callback = mvm_asyncStart(vm, pResult);

  // Let's save the callback for later
  mvm_handleSet(&globalCallback, callback);

  /* ... */return MVM_E_SUCCESS;
}

An async host function is just a normal host function but which calls mvm_asyncStart. The function mvm_asyncStart encapsulates all the logic required for the callee side of the CPS handshake:

  1. If the caller await-called bar, it will have passed a callback, which mvm_asyncStart will return as the callback variable. In this case, it will set *pResult to be an elided promise, so that the caller knows we accepted the callback.
  2. Otherwise, mvm_asyncStart will set *pResult to be a new Promise, and will return a callback closure which settles that Promise (resolves or rejects it).

In this case, foo didn’t await-call bar, so this promise will be created and the returned callback will be a closure that encapsulates the logic to resolve or reject the promise:

I think there’s a beautiful elegance here in that mvm_asyncStart accepts as an argument a writeable reference to the synchronous return value (as a pointer) and returns essentially a writable reference to the asynchronous return value (as a callback).

One of the big design goals of the Microvium C API is to be easy to use, and I think this design of mvm_asyncStart really achieves this. In other JavaScript engines, an async host function would typically have to explicitly create a promise object and return it, and then later explicitly resolve or reject the promise, which is not easy. Microvium not only makes it easy, but also allows the engine to elide the promise altogether.

Side note: if foo did directly await-call bar, the promise would not be created, but the aforementioned callback closure would still exist as a safety and convenience layer between the host function and foo‘s continuation. It serves as a convenient, unified way for the host to tell the engine when the async operation is complete, and it encapsulates the complexity of scheduling the continuation on the job queue, as well as providing a safety layer in case the host accidentally calls the callback multiple times or with the wrong arguments.

Using the Promise constructor

The last, and least memory-efficient way to use async-await in Microvium, is to manually create promises using the Promise constructor, as in the following example:

async function foo() {
const promise = bar();
await promise;
function bar() {
return new Promise((resolve, reject) => {
let x = 42;
// ...
async function foo() {
  const promise = bar();
  await promise;
}

function bar() {
  return new Promise((resolve, reject) => {
    let x = 42;
    // ...
  });
}

Syntactically, this example looks pretty simple. But there are a lot of implicit objects created here:

  • The Promise object itself.
  • The resolve closure to resolve the promise.
  • The reject closure to reject the promise.
  • The executor closure (the arrow function passed to the Promise constructor in the above code) which captures both the resolve and reject closures.

So, while this looks syntactically like the lowest-level use of promises, it’s actually the most complicated behind the scenes. The suspended form of the “async” operation bar here has ballooned from the 8 bytes shown in the previous post to now 32 bytes!

Conclusion

Microvium async at its core uses a CPS protocol to maximize memory efficiency, requiring as little as 6 bytes for a suspended async function (and 2 additional bytes per variable), but at the boundaries between pure CPS and promise-based async, the introduction of promises and closures as protocol adapters brings additional overhead, with the worst case being where you create a promise manually.

The CPS handshake allows Microvium to dynamically decide when promises are required. The careful design of mvm_asyncStart allows even native host functions to participate in this handshake without having to worry about the details. This is important because async JS code needs to await something, and at some point the stack of awaits will ideally bottom-out at a natively-async host API. Microvium’s unique design allows the whole await stack to be defined in terms of pure CPS at least some of the time, without a single promise — turtles all the way down.

Even in the worst case, async/await in Microvium is still much more memory-efficient than other JavaScript engines. Engines like Elk, mJS, and Espruino, don’t support async-await at all — I’m not aware of any engine that even comes close to the size of Microvium which supports async-await. I haven’t measured the size of async-await and promises in XS, but bear in mind that a single slot in XS is already 16 bytes, and even a single closure in XS may take as much as 112 bytes. In V8 on x64, I measure a suspended async function to take about 420 bytes.

Of course, be aware that Microvium doesn’t support the full spec, so it’s not an apples-to-apples comparison. But however you look at it, Microvium’s design of async-await makes it feasible to use it on a completely new class of embedded devices where it was not possible before.


  1. The term “host” refers the outer C program, such as the firmware, and the term “guest” refers to the inner JavaScript program. 

  2. Microvium doesn’t support using await on a value isn’t a promise or elided promise. It will just produce an error code in that case. This is part of the general philosophy of Microvium to support a useful subset of the spec. Here, awaiting a non-promise is likely a mistake. 


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK