Description
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. Wheneverals.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(...)
andals.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 AsyncLocalStorage and deferred promises #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