worker: start worker threads from the built-in snapshot

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().
- Worker::Run(): take that path when the embedded built-in snapshot is
  in use (not an embedder's or a --snapshot-blob one, which has run
  application code), 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.
- pre_execution: run the snapshot's deserialize callbacks (Buffer pool,
  default resolver, cached cwd) on worker threads too, now that a worker
  can come from a snapshot.
- --[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>
PR-URL: https://github.com/nodejs/node/pull/65336
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
This commit is contained in:
Shelley Vohr committed 2026-09-04 13:28:27 +00:00
1 parent 5ceeb6e31c
commit ddc0a0aa28
10 files changed
+162 -61

No files matched your search

+12
View File
@@ -2402,6 +2402,17 @@ added: v6.0.0
Silence all process warnings (including deprecations).
### `--no-worker-snapshot`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
Start worker threads by running the internal bootstrap from scratch instead of
deserializing the bootstrapped context from the built-in startup snapshot.
### `--node-memory-debug`
<!-- YAML
@@ -4102,6 +4113,7 @@ one is included in the list below.
* `--no-strip-types`
* `--no-warnings`
* `--no-webstorage`
* `--no-worker-snapshot`
* `--node-memory-debug`
* `--openssl-config`
* `--openssl-legacy-provider`
+6
View File
@@ -1209,6 +1209,10 @@ For more information, see the TypeScript type-stripping documentation.
.It Fl -no-warnings
Silence all process warnings (including deprecations).
.
.It Fl -no-worker-snapshot
Start worker threads by running the internal bootstrap from scratch instead of
deserializing the bootstrapped context from the built-in startup snapshot.
.
.It Fl -node-memory-debug
Enable extra debug checks for memory leaks in Node.js internals. This is
usually only useful for developers debugging Node.js itself.
@@ -2198,6 +2202,8 @@ one is included in the list below.
.It
\fB--no-webstorage\fR
.It
\fB--no-worker-snapshot\fR
.It
\fB--node-memory-debug\fR
.It
\fB--openssl-config\fR
@@ -6,6 +6,12 @@ const {
delete process._debugProcess;
delete process._debugEnd;
// Also drop the other main-thread-only helpers is_main_thread.js installs, so
// that this switch can be applied on top of a context bootstrapped for the
// main thread (as when a worker starts from the built-in snapshot).
delete process._debugPause;
delete process._startProfilerIdleNotifier;
delete process._stopProfilerIdleNotifier;
function defineStream(name, getter) {
ObjectDefineProperty(process, name, {
+1 -3
View File
@@ -163,14 +163,12 @@ function prepareExecution(options) {
// channel. This needs to be done before any user code gets executed
// (including preload modules).
initializeClusterIPC();
// TODO(joyeecheung): do this for worker threads as well.
runDeserializeCallbacks();
} else {
assert(!internalBinding('worker').isMainThread);
// The setup should be called in LOAD_SCRIPT message handler.
assert(!initializeModules);
}
runDeserializeCallbacks();
const { initializeExtensionFormatMap } = require('internal/modules/esm/get_format');
initializeExtensionFormatMap();
+24
View File
@@ -425,6 +425,11 @@ Environment* CreateEnvironment(
const bool use_snapshot = context.IsEmpty();
const EnvSerializeInfo* env_snapshot_info = nullptr;
// A worker thread (its IsolateData knows its Worker) deserializes the same
// bootstrapped principal context the main thread uses and then has the
// worker-side bootstrap switches applied on top of it (they are written as
// overrides of the main-thread setup).
const bool for_worker = isolate_data->worker_context() != nullptr;
if (use_snapshot) {
CHECK_NOT_NULL(isolate_data->snapshot_data());
env_snapshot_info = &isolate_data->snapshot_data()->env_info;
@@ -466,6 +471,25 @@ Environment* CreateEnvironment(
Context::Scope context_scope(context);
env->InitializeMainContext(context, env_snapshot_info);
if (use_snapshot && for_worker) {
// The deserialized context went through is_main_thread /
// does_own_process_state when the snapshot was built; the worker-side
// switches redefine exactly those pieces (stdio getters, signal wiring,
// process.abort/chdir/umask/..., debug helpers).
if (env->principal_realm()
->ExecuteBootstrapper(
"internal/bootstrap/switches/is_not_main_thread")
.IsEmpty() ||
(!env->owns_process_state() &&
env->principal_realm()
->ExecuteBootstrapper(
"internal/bootstrap/switches/does_not_own_process_state")
.IsEmpty())) {
FreeEnvironment(env);
return nullptr;
}
}
#if HAVE_INSPECTOR
if (env->should_create_inspector()) {
if (inspector_parent_handle) {
+6
View File
@@ -1366,6 +1366,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
PerIsolateOptionsParser::PerIsolateOptionsParser(
const EnvironmentOptionsParser& eop) {
AddOption("--worker-snapshot",
"start worker threads from the bootstrapped context in the "
"built-in startup snapshot",
BOOL_FIELD(worker_snapshot),
kAllowedInEnvvar,
true);
AddOption("--track-heap-objects",
"track heap object allocations for heap snapshots",
BOOL_FIELD(track_heap_objects),
+1
View File
@@ -319,6 +319,7 @@ class EnvironmentOptions : public Options {
class PerIsolateOptions : public Options {
public:
bool worker_snapshot = true; // --[no-]worker-snapshot
PerIsolateOptions() = default;
PerIsolateOptions(PerIsolateOptions&&) = default;
+97 -53
View File
@@ -16,6 +16,7 @@
#include "v8-profiler.h"
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -292,6 +293,18 @@ size_t Worker::NearHeapLimit(void* data, size_t current_heap_limit,
return new_limit;
}
// Can this worker start by deserializing the bootstrapped principal context
// from the embedded snapshot (plus the worker-side switches) instead of
// bootstrapping from scratch? Only that snapshot qualifies: an embedder's own
// or a --snapshot-blob one has run application code in its main context.
// --no-worker-snapshot opts out; kNoBrowserGlobals changes the bootstrap.
bool Worker::UseWorkerContextSnapshot() const {
return snapshot_data_ != nullptr &&
snapshot_data_ == SnapshotBuilder::GetEmbeddedSnapshotData() &&
!(environment_flags_ & EnvironmentFlags::kNoBrowserGlobals) &&
per_process::cli_options->per_isolate->worker_snapshot;
}
void Worker::Run() {
std::string trace_name = "[worker " + std::to_string(thread_id_.id) + "]" +
(name_ == "" ? "" : " " + name_);
@@ -340,7 +353,15 @@ void Worker::Run() {
// resource constraints, we need something in place to handle it,
// though.
TryCatch try_catch(isolate_);
if (snapshot_data_ != nullptr) {
if (UseWorkerContextSnapshot()) {
// Leave `context` empty: CreateEnvironment() deserializes the
// bootstrapped principal context (kNodeMainContextIndex) and the
// Environment state that goes with it, applies the worker-side
// switches, and skips RunBootstrapping().
Debug(this,
"Worker %llu deserializes the bootstrapped context\n",
thread_id_.id);
} else if (snapshot_data_ != nullptr) {
Debug(this,
"Worker %llu uses context from snapshot %d\n",
thread_id_.id,
@@ -357,7 +378,7 @@ void Worker::Run() {
this, "Worker %llu builds context from scratch\n", thread_id_.id);
context = NewContext(isolate_);
}
if (context.IsEmpty()) {
if (context.IsEmpty() && !UseWorkerContextSnapshot()) {
// TODO(joyeecheung): maybe this should be kBootstrapFailure instead?
Exit(ExitCode::kGenericUserError,
"ERR_WORKER_INIT_FAILED",
@@ -367,8 +388,8 @@ void Worker::Run() {
}
if (is_stopped()) return;
CHECK(!context.IsEmpty());
Context::Scope context_scope(context);
std::optional<Context::Scope> context_scope;
if (!context.IsEmpty()) context_scope.emplace(context);
{
#if HAVE_INSPECTOR
environment_flags_ |= EnvironmentFlags::kNoWaitForInspectorFrontend;
@@ -384,6 +405,7 @@ void Worker::Run() {
name_));
if (is_stopped()) return;
CHECK_NOT_NULL(env_);
if (!context_scope) context_scope.emplace(env_->context());
env_->set_env_vars(std::move(env_vars_));
SetProcessExitHandler(env_.get(), [this](Environment*, int exit_code) {
Exit(static_cast<ExitCode>(exit_code));
@@ -1416,8 +1438,70 @@ void GetEnvMessagePort(const FunctionCallbackInfo<Value>& args) {
}
}
// Per-thread values of the `worker` binding are lazy properties of the
// per-isolate template, so that a bootstrapped context carries none of them
// and can be deserialized by any thread.
void ThreadIdGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
Environment* env = Environment::GetCurrent(info);
info.GetReturnValue().Set(static_cast<double>(env->thread_id()));
}
void ThreadNameGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
Environment* env = Environment::GetCurrent(info);
Local<String> name;
if (String::NewFromUtf8(info.GetIsolate(),
env->thread_name().data(),
NewStringType::kNormal,
env->thread_name().size())
.ToLocal(&name)) {
info.GetReturnValue().Set(name);
}
}
void IsMainThreadGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
info.GetReturnValue().Set(Environment::GetCurrent(info)->is_main_thread());
}
void IsInternalThreadGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
Worker* worker =
Environment::GetCurrent(info)->isolate_data()->worker_context();
info.GetReturnValue().Set(worker != nullptr && worker->is_internal());
}
void OwnsProcessStateGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
info.GetReturnValue().Set(
Environment::GetCurrent(info)->owns_process_state());
}
void ResourceLimitsGetter(Local<v8::Name>,
const v8::PropertyCallbackInfo<Value>& info) {
Environment* env = Environment::GetCurrent(info);
if (env->worker_context() != nullptr) {
info.GetReturnValue().Set(
env->worker_context()->GetResourceLimits(info.GetIsolate()));
}
}
void CreateWorkerPerIsolateProperties(IsolateData* isolate_data,
Local<ObjectTemplate> target) {
{
Isolate* isolate = isolate_data->isolate();
auto lazy = [&](const char* name, v8::AccessorNameGetterCallback getter) {
target->SetLazyDataProperty(OneByteString(isolate, name), getter);
};
lazy("threadId", ThreadIdGetter);
lazy("threadName", ThreadNameGetter);
lazy("isMainThread", IsMainThreadGetter);
lazy("isInternalThread", IsInternalThreadGetter);
lazy("ownsProcessState", OwnsProcessStateGetter);
lazy("resourceLimits", ResourceLimitsGetter);
}
Isolate* isolate = isolate_data->isolate();
{
@@ -1522,55 +1606,9 @@ void CreateWorkerPerContextProperties(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
Isolate* isolate = env->isolate();
target
->Set(env->context(),
env->thread_id_string(),
Number::New(isolate, static_cast<double>(env->thread_id())))
.Check();
target
->Set(env->context(),
env->thread_name_string(),
String::NewFromUtf8(isolate,
env->thread_name().data(),
NewStringType::kNormal,
env->thread_name().size())
.ToLocalChecked())
.Check();
target
->Set(env->context(),
FIXED_ONE_BYTE_STRING(isolate, "isMainThread"),
Boolean::New(isolate, env->is_main_thread()))
.Check();
Worker* worker = env->isolate_data()->worker_context();
bool is_internal = worker != nullptr && worker->is_internal();
// Set the is_internal property
target
->Set(env->context(),
FIXED_ONE_BYTE_STRING(isolate, "isInternalThread"),
Boolean::New(isolate, is_internal))
.Check();
target
->Set(env->context(),
FIXED_ONE_BYTE_STRING(isolate, "ownsProcessState"),
Boolean::New(isolate, env->owns_process_state()))
.Check();
if (!env->is_main_thread()) {
target
->Set(env->context(),
FIXED_ONE_BYTE_STRING(isolate, "resourceLimits"),
env->worker_context()->GetResourceLimits(isolate))
.Check();
}
// threadId, threadName, isMainThread, isInternalThread, ownsProcessState
// and resourceLimits are lazy properties of the per-isolate template (see
// CreateWorkerPerIsolateProperties).
NODE_DEFINE_CONSTANT(target, kMaxYoungGenerationSizeMb);
NODE_DEFINE_CONSTANT(target, kMaxOldGenerationSizeMb);
NODE_DEFINE_CONSTANT(target, kCodeRangeSizeMb);
@@ -1580,6 +1618,12 @@ void CreateWorkerPerContextProperties(Local<Object> target,
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(GetEnvMessagePort);
registry->Register(ThreadIdGetter);
registry->Register(ThreadNameGetter);
registry->Register(IsMainThreadGetter);
registry->Register(IsInternalThreadGetter);
registry->Register(OwnsProcessStateGetter);
registry->Register(ResourceLimitsGetter);
registry->Register(Worker::New);
registry->Register(Worker::StartThread);
registry->Register(Worker::StopThread);
+1
View File
@@ -42,6 +42,7 @@ class Worker : public AsyncWrap {
// Run the worker. This is only called from the worker thread.
void Run();
bool UseWorkerContextSnapshot() const;
// Forcibly exit the thread with a specified exit code. This may be called
// from any thread. `error_code` and `error_message` can be used to create
+8 -5
View File
@@ -118,12 +118,14 @@ expected.atRunTime = new Set([
const { isMainThread } = require('worker_threads');
// Binaries built without the snapshot (e.g. cross-compiled) and
// --no-node-snapshot bootstrap the main context from scratch, like a worker.
const mainContextFromSnapshot = isMainThread &&
// --no-node-snapshot bootstrap the context from scratch; so do workers under
// --no-worker-snapshot.
const contextFromSnapshot =
process.config.variables.node_use_node_snapshot &&
!process.execArgv.includes('--no-node-snapshot');
!process.execArgv.includes('--no-node-snapshot') &&
(isMainThread || !process.execArgv.includes('--no-worker-snapshot'));
if (mainContextFromSnapshot) {
if (contextFromSnapshot) {
[
'Internal Binding cjs_lexer',
'NativeModule internal/modules/esm/assert',
@@ -148,7 +150,8 @@ if (mainContextFromSnapshot) {
} else if (isMainThread) {
expected.beforePreExec.delete(getFormatNativeModule);
expected.atRunTime.add(getFormatNativeModule);
} else { // Worker.
}
if (!isMainThread) {
[
'NativeModule diagnostics_channel',
'NativeModule internal/abort_controller',