Repository: node
Issue: AsyncLocalStorage without Async Hooks
Labels: async_hooks

I've recently gone through and implemented a subset of the `AsyncLocalStorage` API for Cloudflare's workerd runtime, and I've done so *without* implementing any part of the async_hooks API under the covers, proving that it's not only possible but results in a *significant* performance improvement and greatly simplified implementation. I propose that we could greatly improve our implementation by eliminating dependency on async_hooks and adopting a model similar to what we've implemented in workerd.

First, an overview of how things currently work in Node.js...

* At any given time, there is a "current execution context". This context is strongly associated with specific individual objects (timers, nextticks, promises, uv handles, etc).
* For every `AsyncLocalStorage` instance, there is an associated hidden property on the resource associated with the current execution context. Whenever `als.run(...)` is called, we temporarily mutate the value associated with that key and run a function. Any async resources that are created while that function is running ends up receiving a full copy of that map.

This design ends up being very inefficient as it means we are allocating and copying the context for every single object. For applications that are particularly promise heavy, this gets very expensive very quickly.

The way we implemented this in workerd is far more efficient:

At any given time, there is a current AsyncContextFrame. By default there is a logical "root" frame but we do not actually need to allocate anything to represent it.

Whenever we call `als.run(...)`, we create a new `AsyncContextFrame` that inherits the context of the current, and set the new frame as current. Any async resource that is created simply acquires a reference to the current `AsyncContextFrame`.

The context is only copied when a new `AsyncContextFrame` is created, which happens far less frequently than creating a new async resource.

Further, we do not need to rely on V8's PromiseHooks API to propagate context through promises. There is a built-in v8 API `v8::Context::SetContinuationPreservedEmbedderData()` that can do the propagation for us much more efficiently. Whenever we enter an `AsyncContextFrame`, we set the `ContinuationPreservedEmbedderData` to an opaque object that wraps a reference to the frame. V8 takes care of properly associating that with all promise continuations.

There's a lot more to it that I'm glossing over here but the basic idea is that `AsyncLocalStorage` is absolutely possible to implement without `async_hooks` and I think there's a ton of value in doing so.

There are some caveats:

* In the workerd implementation, we have chosen not to implement the `als.enterWith(...)` and `als.disable()` APIs. With the new model, these would need to be removed as they make things way more complicated than they are worth.
* Handling around the `unhandledRejection` event would need to change (see https://github.com/nodejs/node/issues/46262)
* It's a massive change that touches lots of code. The way async context references are held on to by async resources would need to change dramatically, so it's not a small ask to change things.

The point of this issue is to see if there is interest in an approach to AsyncLocalStorage that is completely decoupled from `async_hooks` before @flakey5 and I decide whether or not to work on it.

/cc @mcollina @vdeturckheim @bmeck @bengl @nodejs/async_hooks