Skip to content

src: skip the async id stack when nobody sees it - #66511

Open
nigrosimone wants to merge 2 commits into
nodejs:mainfrom
nigrosimone:lazy-async-ids
Open

nigrosimone wants to merge 2 commits into
nodejs:mainfrom
nigrosimone:lazy-async-ids

Conversation

@nigrosimone

@nigrosimone nigrosimone commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

With no async hook and no executionAsyncResource() user, every InternalCallbackScope pushes and pops the async ids on the stack, only for executionAsyncId() and triggerAsyncId() to read the top. This swaps the two ids in place instead: they are all that those two and process.nextTick() read, so the readers pay nothing.

executionAsyncResource() is the only reader of the stack. The scopes that skip it form a chain in AsyncHooks, counted in a new field kLazyScopes, and JS asks C++ for the innermost one only while that count is not zero. The checks for a corrupted stack stay: test/async-hooks test-improper-order and test-emit-*-on-destroyed fail without them, because the main script runs in a scope that starts before any hook.

compare.js on napi/make_callback, 30 runs, Linux x64, one core, on main (3e5c8af, with #66395):

                                                     confidence improvement accuracy (*)   (**)  (***)
napi/make_callback n=1000000 type='AsyncResource'            **      6.57 %  ±4.56% ±6.07% ±7.91%
napi/make_callback n=1000000 type='Call'                            -2.91 %  ±5.61% ±7.51% ±9.86%
napi/make_callback n=1000000 type='MakeCallback'              *      7.47 %  ±5.64% ±7.50% ±9.77%
napi/make_callback n=10000000 type='AsyncResource'          ***      8.54 %  ±3.42% ±4.55% ±5.93%
napi/make_callback n=10000000 type='Call'                           -1.02 %  ±4.35% ±5.79% ±7.54%
napi/make_callback n=10000000 type='MakeCallback'           ***      7.30 %  ±2.85% ±3.80% ±4.96%

MakeCallback goes from 128.0 to 119.3 ns at n=1e7. test/async-hooks, the async hooks, AsyncLocalStorage, AsyncResource and nextTick tests in test/parallel, and the new addon test pass.

The new field and the chain are the part to look at first, the design was discussed in nodejs/performance#24.

Disclosure: I used Opus 5.5 (Max) as coding assistant

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Oct 4, 2026
With no async hook and no executionAsyncResource() user, every
InternalCallbackScope pushed and popped the async ids on the stack, only
for executionAsyncId() and triggerAsyncId() to read the top. Swap the
two ids in place instead: they are all that those two and
process.nextTick() read. executionAsyncResource() is the only reader of
the stack: the scopes that skip it form a chain in AsyncHooks, counted
in a new field, kLazyScopes, and JS asks C++ for the innermost one only
while that count is not zero. The checks for a corrupted stack stay.

Refs: nodejs/performance#24
Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
@nigrosimone
nigrosimone marked this pull request as ready for review October 21, 2026 03:22
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.15789% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.57%. Comparing base (3e5c8af) to head (21a6ea2).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/async_hooks.js 70.58% 5 Missing ⚠️
src/api/callback.cc 94.11% 1 Missing and 1 partial ⚠️
src/async_wrap.cc 92.30% 0 Missing and 1 partial ⚠️
src/env.cc 90.90% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66511      +/-   ##
==========================================
- Coverage   92.86%   90.57%   -2.29%     
==========================================
  Files         428      797     +369     
  Lines      195100   278432   +83332     
  Branches    30147    53510   +23363     
==========================================
+ Hits       181175   252200   +71025     
- Misses      13597    16582    +2985     
- Partials      328     9650    +9322     
Files with missing lines Coverage Δ
src/env.h 97.40% <ø> (ø)
src/node_internals.h 80.70% <100.00%> (ø)
src/async_wrap.cc 83.58% <92.30%> (ø)
src/env.cc 84.26% <90.90%> (ø)
src/api/callback.cc 81.78% <94.11%> (ø)
lib/internal/async_hooks.js 98.60% <70.58%> (+0.35%) ⬆️

... and 498 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

executionAsyncResource() asked first inside a native callback, where the
scope skipped the id stack and holds its resource as a Global, and with
a context pushed on the stack above that scope.

Signed-off-by: Nigro Simone <nigro.simone@gmail.com>
@nigrosimone

Copy link
Copy Markdown
Contributor Author

About the Codecov report: 21a6ea2 adds a test for the two gaps that can be covered, the lazy resource held as a Global (an fs callback) and a context pushed on the stack above a lazy scope.

The lines still reported, popAsyncContext() in lib/internal/async_hooks.js and the check in AsyncHooks::pop_async_context(), are the corrupted stack path. test-improper-order and test-emit-*-on-destroyed go through them, but the child process exits from C++ before it writes its coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants