Skip to content

worker: start worker threads from the built-in snapshot - #65336

Open
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:worker/start-from-snapshot
Open

worker: start worker threads from the built-in snapshot#65336
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:worker/start-from-snapshot

Conversation

@codebytere

Copy link
Copy Markdown
Member

new Worker() currently runs the whole internal bootstrap in the new isolate (realm, node, web exposure, the thread and
process-state switches), compiling ~80 builtins with the code cache before internal/main/worker_thread starts. Only the
main thread deserializes its principal context from the built-in snapshot, and that bootstrap is about half of a worker's
cold start.

The bootstrapped principal context in the snapshot is nearly thread-neutral already: is_not_main_thread.js and
does_not_own_process_state.js are written as overrides of the main-thread switches, and the only thread-specific data
baked into the context were six properties of the worker binding. This lets a worker deserialize the same
kNodeMainContextIndex context and EnvSerializeInfo the main thread uses, and applies the two worker-side switches on
top.

misc/startup-core.js mode='worker' script='test/fixtures/semicolon.js'                ***    98.45 %  ±0.82%   (21.1 -> 10.6 ms)
misc/startup-core.js mode='worker' script='benchmark/fixtures/empty.mjs'              ***    99.64 %  ±0.60%
misc/startup-core.js mode='worker' script='benchmark/fixtures/require-builtins.js'    ***    47.58 %  ±1.19%   (33.8 -> 22.9 ms)
misc/startup-core.js mode='worker' script='benchmark/fixtures/import-builtins.mjs'    ***    30.03 %  ±0.54%
misc/startup-core.js mode='worker' script='test/fixtures/snapshot/typescript.js'      ***     8.59 %  ±0.46%
misc/startup-core.js mode='process' (all five scripts)                                        ±0.5 %  n.s.

(x64 Linux, 30 runs. An idle worker's RSS also drops from ~12.2 to ~8.1 MiB, heapTotal 8.6 to 5.4 MiB.)

  • worker binding: threadId, threadName, isMainThread, isInternalThread, ownsProcessState and resourceLimits
    become lazy properties of the per-isolate template, computed from the Environment on first read, so no bootstrapped
    context carries them. Bootstrap itself only takes getEnvMessagePort from the binding.
  • CreateEnvironment(): an empty context already means "deserialize from the snapshot". When the caller is a worker (its
    IsolateData has a Worker), deserialize the main context as before, then run
    internal/bootstrap/switches/is_not_main_thread and, unless the worker owns process state,
    does_not_own_process_state after InitializeMainContext(). The isolate error-handler reset stays main-thread only.
  • Worker::Run(): take that path when the built-in snapshot is in use (SnapshotMetadata::Type::kDefault, so a
    --build-snapshot snapshot that has run the application's entry point is never reused), browser globals aren't disabled
    (kNoBrowserGlobals changes the bootstrap shape) and --no-worker-snapshot wasn't passed. Otherwise the worker
    bootstraps from scratch as today, which is also the path for embedders that create environments without a snapshot.
  • is_not_main_thread.js: also delete process._debugPause, _startProfilerIdleNotifier and
    _stopProfilerIdleNotifier, which is_main_thread.js installs.
  • --[no-]worker-snapshot per-isolate option, documented, as the escape hatch.

Modules read no options at bootstrap time (getCLIOptionsValues() throws before bootstrapping is done and
refreshOptions() runs in pre-execution), so a worker's execArgv can't disagree with anything captured in the context;
per-thread runtime state (argv, execArgv, title, env proxy contents, time origin, inspector, message port, stdio) is
established after context creation by Worker::Run() and prepareWorkerThreadExecution() as before.

Testing:

  • Object.getOwnPropertyNames() plus descriptor kinds of process and globalThis are identical between a from-snapshot
    and a --no-worker-snapshot worker, as are process listeners, features and versions.
  • Same results as main for env: SHARE_ENV and copied env, resourceLimits, piped stdio, nested workers, eval/CJS/ESM/
    data: entries, exit codes, early terminate(), uncaught errors, structured-clone workerData, argv/execArgv/name,
    --frozen-intrinsics, --disable-proto=throw, the permission model, and a --build-snapshot user snapshot (workers do
    not see its globals).
  • Full default suite on Release; worker, messageport, broadcastchannel, inspector-worker, async-hooks, process, bootstrap,
    snapshot and es-module suites on a Debug build (437/437).

An earlier version added a dedicated worker context to the snapshot instead. Any second bootstrapped context in the
node_mksnapshot isolate currently fails inside V8's serializer (SerializeBackingStore() on a typed array reached from
both contexts, or CHECK(!SerializePendingObject(*code)) in VisitJSDispatchTableEntry()), so reusing the existing
context is both the smaller change and the one that works today; i'll file the serializer limitation separately.


Disclosure: the code, tests, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/startup

@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 related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebytere codebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 16, 2026
@codebytere
codebytere marked this pull request as ready for review August 16, 2026 20:50
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from f15c413 to f7e1092 Compare August 16, 2026 21:05
Every `new Worker()` runs the whole internal bootstrap (realm, node, web
exposure, thread and process-state switches) in its fresh isolate,
compiling ~80 builtins with the code cache; only the main thread
deserializes its principal context from the built-in snapshot. That
bootstrap is about half of a worker's cold start.

The bootstrapped principal context in the snapshot is nearly
thread-neutral: the worker-side switch scripts (is_not_main_thread,
does_not_own_process_state) are written as overrides of the main-thread
ones, and the per-thread values of the `worker` binding were the only
thread-specific data baked into the context. Let a worker deserialize
that same context and EnvSerializeInfo and apply the two worker-side
switches on top:

- worker binding: threadId, threadName, isMainThread, isInternalThread,
  ownsProcessState and resourceLimits become lazy properties of the
  per-isolate template, computed from the Environment on first read.
- CreateEnvironment(): when a worker (its IsolateData has a Worker)
  passes an empty context, deserialize kNodeMainContextIndex and run
  internal/bootstrap/switches/is_not_main_thread and, unless the worker
  owns process state, does_not_own_process_state after
  InitializeMainContext(); skip the isolate error-handler reset.
- Worker::Run(): take that path when the built-in (kDefault) snapshot
  is in use, browser globals are not disabled and --no-worker-snapshot
  was not given; otherwise bootstrap as before.
- is_not_main_thread.js: also delete _debugPause and the profiler idle
  notifier helpers that is_main_thread.js installs.
- --[no-]worker-snapshot per-isolate option, documented.

Sequential new Worker() -> 'online' -> terminate goes from ~20.9 ms to
~10.3 ms per worker on x64 Linux; --no-worker-snapshot restores the old
number.

Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytere force-pushed the worker/start-from-snapshot branch from f7e1092 to b55b8e2 Compare August 16, 2026 21:05
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 related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. request-ci Add this label to start a Jenkins CI on a PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants