Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib: rewrite AsyncLocalStorage without async_hooks #48528

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

Qard
Copy link
Member

@Qard Qard commented Jun 22, 2023

I'm working on rewriting AsyncLocalStorage to use v8::SetContinuationPreservedEmbedderData(...) in a similar model to Cloudflare and in anticipation of the upcoming AsyncContext proposal.

This is not done yet but is mostly working as-is. I have not got to benchmarking or writing tests for it yet, but it seems to be passing most of the existing AsyncLocalStorage. 😄

cc @nodejs/diagnostics @nodejs/performance

@Qard Qard added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. performance Issues and PRs related to the performance of Node.js. async_hooks Issues and PRs related to the async hooks subsystem. diag-agenda Issues and PRs to discuss during the meetings of the diagnostics working group. labels Jun 22, 2023
@Qard Qard requested review from jasnell and legendecas June 22, 2023 23:56
@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/gyp
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added the needs-ci PRs that need a full CI run. label Jun 22, 2023
@Qard
Copy link
Member Author

Qard commented Jun 23, 2023

Also worth considering that as this no longer depends on async_hooks in any way it might make sense to give it a separate module name at some point too and just have async_hooks re-export it.

@Qard Qard force-pushed the async-local-storage-rewrite branch 2 times, most recently from b35d35c to 8b30200 Compare June 23, 2023 01:07
@Qard
Copy link
Member Author

Qard commented Jun 23, 2023

Some additional notes:

  • This will have to be behind a build flag because of a conflict with Chromium using the same single-tenant API in Electron.
  • I have not yet wired this up to AsyncWrap. It will need some linkage there to actually work across real async boundaries.
    • current() and exchange() are exposed on the C++ side to make this straightforward but may do some scope helpers for that. We'll see if it's necessary.
  • I also need to make a PR to V8 to make it propagate embedder data through thenables.
  • There are probably still lots of bugs and the existing tests don't have great coverage. I'll improve those while I'm working on this as I want to be sure we're not breaking any edge cases.

@jasnell
Copy link
Member

jasnell commented Jun 23, 2023

Woo! Thanks for getting this started. The need for the flag is unfortunate but definitely required given how chromium is using the API and the conflict that causes with electron. But otherwise this should be a major improvement over the current state. Will do a thorough review this weekend.

@Qard
Copy link
Member Author

Qard commented Jun 23, 2023

Another note:

This currently uses a model of building a linked-list of AsyncContextFrames which each contain only the key and value they were constructed with. The alternative which is what was originally proposed was to use a map which is cloned from the parent frame at each frame construction and then the single additional entry for that frame is added but it is considered otherwise immutable.

I went with the current model mostly because it was easier and made debugging simpler but I plan to spend some time benchmarking the two approaches to identify the trade-offs and figure out which approach would be generally better. The one definite downside to the current model is it holds past values for the same store from further up the call graph alive longer which may be undesirable but may only really apply to top-level scopes that will need to be kept alive anyway.

Further analysis should provide some better insight into the behaviour.

@mmarchini
Copy link
Contributor

This is awesome!

Also worth considering that as this no longer depends on async_hooks in any way it might make sense to give it a separate module name at some point too and just have async_hooks re-export it.

That'd be a welcome change. We've been trying to eliminate async_hooks usage in our apps (as we've detected performance issues when async_hooks are misused), and having to import AsyncLocalStorage from the async_hooks module messes with how we detect if an app is using async_hooks or not.

@Qard
Copy link
Member Author

Qard commented Jul 9, 2023

Thenables propagation fix is here: https://chromium-review.googlesource.com/c/v8/v8/+/4674242

@mcollina
Copy link
Member

mcollina commented Jul 9, 2023

I propose tagging this as semver-major.

@Qard
Copy link
Member Author

Qard commented Jul 10, 2023

@mcollina Could do that. Though the way in which we land it is still not entirely decided. Due to the Electron conflict it needs build-flagging anyway, so this could be landed as a minor if it's by default turned off. It could land off by default and then have a follow-up to turn it on by default as a major. What do you think?

@mcollina
Copy link
Member

That works

@Qard
Copy link
Member Author

Qard commented Jul 20, 2023

I've got all but four two of the existing tests passing at this point:

  • thenables I have a fix for this here.
  • http.Agent, which I haven't yet figured out why my existing AsyncResource patches don't cover it.
  • storage.disable(), which currently only blocks propagation after the call but also needs to block for the rest of the tick before the call too in order to pass the existing tests.
  • unhandledRejection, which does not match the context of the promise. James said he'll point me at how Cloudflare did it.

@theanarkh
Copy link
Contributor

theanarkh commented Jul 21, 2023

Hi, @Qard, Can you explain how the API v8::SetContinuationPreservedEmbedderData(...) work ? I have read some docs and code, but I don't understand it very well, thank you ! It looks a bit like SetEmbedderData/GetEmbedderData API.

@Qard
Copy link
Member Author

Qard commented Jul 24, 2023

The SetContinuationPreservedEmbedderData API stores a v8::Value in the v8::Context. Whenever a promise reaction job is created (meaning when it resolves or rejects) it captures the value held at that time and stores it on that reaction job. Whenever the reaction job runs (meaning a then/catch/finally continuation runs) it restores that value as the current stored value and then reverts to the previous value when the job is done.

This is essentially what async_hooks does currently with executionAsyncResource but exclusively for promises. However we can also manipulate that current value externally to capture and restore the value around AsyncWrap, AsyncResource, and any other scope we need to manipulate to match our execution flow.

@Qard Qard force-pushed the async-local-storage-rewrite branch from e009382 to 6d26f70 Compare July 28, 2023 03:20
@Qard Qard marked this pull request as ready for review July 28, 2023 03:20
@Qard Qard force-pushed the async-local-storage-rewrite branch from 6d26f70 to 5a7b995 Compare July 28, 2023 03:23
@Qard
Copy link
Member Author

Qard commented Aug 24, 2023

I've now got the builtins working against active context rather than creation context. With that all existing tests are now passing. Final benchmark results are:

                                                                                        confidence improvement accuracy (*)     (**)    (***)
async_hooks/async-local-storage-getstore-nested-resources.js n=10000 resourceCount=10          ***     37.01 %       ±5.19%   ±6.91%   ±9.00%
async_hooks/async-local-storage-getstore-nested-resources.js n=10000 resourceCount=100         ***     19.49 %       ±2.97%   ±3.96%   ±5.15%
async_hooks/async-local-storage-getstore-nested-resources.js n=10000 resourceCount=1000        ***      7.55 %       ±3.28%   ±4.36%   ±5.68%
async_hooks/async-local-storage-getstore-nested-run.js n=10000 storageCount=1                  ***     25.97 %       ±2.66%   ±3.54%   ±4.62%
async_hooks/async-local-storage-getstore-nested-run.js n=10000 storageCount=10                 ***     28.12 %       ±2.37%   ±3.16%   ±4.13%
async_hooks/async-local-storage-getstore-nested-run.js n=10000 storageCount=100                ***     10.92 %       ±2.90%   ±3.86%   ±5.03%
async_hooks/async-local-storage-propagate-asyncresource.js n=1000 storageCount=0               ***     11.72 %       ±2.49%   ±3.32%   ±4.33%
async_hooks/async-local-storage-propagate-asyncresource.js n=1000 storageCount=1               ***    137.10 %       ±3.89%   ±5.20%   ±6.81%
async_hooks/async-local-storage-propagate-asyncresource.js n=1000 storageCount=10              ***    263.46 %       ±5.99%   ±8.07%  ±10.70%
async_hooks/async-local-storage-propagate-asyncresource.js n=1000 storageCount=100             ***   1650.06 %      ±41.27%  ±55.62%  ±73.83%
async_hooks/async-local-storage-propagate-promise.js n=100000 storageCount=0                            0.16 %       ±2.10%   ±2.80%   ±3.65%
async_hooks/async-local-storage-propagate-promise.js n=100000 storageCount=1                   ***    195.33 %       ±3.87%   ±5.21%   ±6.90%
async_hooks/async-local-storage-propagate-promise.js n=100000 storageCount=10                  ***    807.12 %      ±11.39%  ±15.35%  ±20.38%
async_hooks/async-local-storage-propagate-promise.js n=100000 storageCount=100                 ***  14494.18 %     ±190.06% ±256.14% ±340.05%
async_hooks/async-local-storage-run.js n=10000000                                              ***     41.39 %       ±1.67%   ±2.22%   ±2.89%

Be aware that when doing many comparisons the risk of a false-positive result increases.
In this case, there are 15 comparisons, you can thus expect the following amount of false-positive results:
  0.75 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.15 false positives, when considering a   1% risk acceptance (**, ***),
  0.01 false positives, when considering a 0.1% risk acceptance (***)

I'll get started on a CR to add the builtins to V8 and will update this again once we can get the changes for that and thenables support backported.

@Qard
Copy link
Member Author

Qard commented Aug 24, 2023

I've submitted a V8 CR to add the TurboFan builtins for the get/set functionality. I'm also still waiting for reviews on the thenables fix. Feel free to review/comment on either. Once those both land I'll get them backported to Node.js and then update this PR. 🎉

@Qard
Copy link
Member Author

Qard commented Aug 26, 2023

How would people feel about this being behind a runtime flag rather than a build flag? I'm trying to figure out how to get CI testing against both implementations and it seems like it would be a lot easier if it was a runtime flag instead. I feel like it should be fine as the conflict with Electron would only happen if we actually call the get/set functions so we could just escape before those would be run and have basically the same behaviour as we have now. 🤔

The actual changes to the original version are very minimal as the new version is basically entirely standalone. The only really notable change is just moving some things out to separate files and the mode switching on detection of the AsyncContextFrame class.

As long as I configure it to never call the set/get functions in either C++ or JS, it should actually be safe to land as a minor behind a flag, at least in my opinion. That also gives us the potential to backport which would be highly desirable for us APM providers as we could at least advise customers on older versions to use the flag for better performance in the event they have issues with the performance of the existing code.

Copy link
Member

@RafaelGSS RafaelGSS left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work! This has been on my notification list for so long, but I couldn't look at it earlier - sorry about that.

How would people feel about this being behind a runtime flag rather than a build flag?

What's the reasoning behind using it through a build flag? Looks like the parts added by NODE_USE_NATIVE_ALS via pre-processor could be easily handled by if (UNLIKELY(native_als_enabled)). Until we make the full transition to native als, that would be the best option to keep on track.

I'm +1 landing it as an experimental runtime flag. That said, it's missing some docs.

@RafaelGSS
Copy link
Member

Would be worth it to trigger https://ci.nodejs.org/job/node-test-commit-v8-linux/ since it manually patches v8.

@richardlau
Copy link
Member

How would people feel about this being behind a runtime flag rather than a build flag? I'm trying to figure out how to get CI testing against both implementations and it seems like it would be a lot easier if it was a runtime flag instead.

Runtime flag would be preferable if possible. We don't test every build flag in the CI. For CI runs, the majority of the time is compilation/building rather than test execution.

@Qard
Copy link
Member Author

Qard commented Aug 29, 2023

BTW, on vacation this week, so there won't be any movement on this until next week. Shouldn't take too long to figure out runtime flagging though. 🙂

@joyeecheung
Copy link
Member

joyeecheung commented Sep 6, 2023

I feel like it should be fine as the conflict with Electron would only happen if we actually call the get/set functions so we could just escape before those would be run and have basically the same behaviour as we have now. 🤔

It would not even happen unless someone turns on the runtime flag right? So I don't think conflicts with Electron would be a problem, there are probably plenty of flags out there that do not work quite well with Electron anyway...

On the other hand I wonder why the V8 API cannot use e.g. an FixedArray of continuation data, instead of just allowing one entry. The builtins can just be extended to take an index to that array. It doesn't even have to grow, a fixed number of entries may already be enough (like how each isolate only have 4 fixed data slots). That's been how we handle the multi-tenancy issue with e.g. context embedder data too (which is growable though)

Copy link
Member

@Flarna Flarna left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried my best to review this, it's not that easy.
I hope I understood it correct and haven't overseen vital areas which need to be touched.

src/async_context_frame.h Outdated Show resolved Hide resolved
src/async_context_frame.h Outdated Show resolved Hide resolved
src/api/callback.cc Show resolved Hide resolved
src/api/callback.cc Outdated Show resolved Hide resolved
src/api/async_resource.cc Show resolved Hide resolved
lib/internal/async_local_storage/native.js Outdated Show resolved Hide resolved
lib/internal/async_local_storage/native.js Outdated Show resolved Hide resolved
lib/internal/async_local_storage/native.js Outdated Show resolved Hide resolved
lib/internal/async_local_storage/native.js Outdated Show resolved Hide resolved
lib/internal/async_local_storage/native.js Outdated Show resolved Hide resolved
@Qard Qard force-pushed the async-local-storage-rewrite branch from 193052a to 161d72b Compare September 12, 2023 04:11
const { AsyncContextFrame } = require('internal/async_context_frame');

let hasAsyncContextFrame_;
function hasAsyncContextFrame() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a significant performance improvement in copying this into several files?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not. Could be moved into internal/async_context_frame. It was just a direct conversion of the previous code which checked by existence of the AsyncContextFrame export of the native binding.

@Flarna
Copy link
Member

Flarna commented Sep 12, 2023

I think there is a difference regarding behavior after disable.
The async hooks based implementation doesn't propagate anymore after disable() was called.
The new implementation will still propagate AsyncContextFrames even if the store is disabled.

I doubt that this difference is critical because disable is usually not the main API used in the wild and as disable is still marked as experimental we have no semver impact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
async_hooks Issues and PRs related to the async hooks subsystem. c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. performance Issues and PRs related to the performance of Node.js. wip Issues and PRs that are still a work in progress.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet