Skip to content

src: experimental green threads implementation - #67288

Draft
jasnell wants to merge 20 commits into
nodejs:mainfrom
jasnell:green-threads
Draft

jasnell wants to merge 20 commits into
nodejs:mainfrom
jasnell:green-threads

Conversation

@jasnell

@jasnell jasnell commented Nov 6, 2026

Copy link
Copy Markdown
Member

This is an experiment... opening for feedback.

This implements green threads for Node.js. A green thread is a lightweight kind of worker. It has its own V8 isolate with no Node.js APIs. They run on a small pool of physical threads that they share, instead of each getting an OS thread and event loop like a worker thread. Each one gets work from the host through messages, RPC calls, or its module's exports, and every piece of work runs in an invocation context of its own. Each green thread can have limits on CPU time and memory, and the host can terminate it. This makes them cheap to start and to keep around in large numbers, and suited to running many small, untrusted pieces of code, such as plugins, per-tenant handlers, or user-supplied scripts.

const { GreenThread } = require('node:green_threads');

const greenThread = new GreenThread(`
  onmessage = ({ data }) => postMessage(data * 2);
`);
greenThread.on('message', (value) => {
  console.log(value);  // Prints: 42
  greenThread.terminate();
});
greenThread.postMessage(21);

Compared with worker threads

Green threads have real advantages over workers, in specific areas:

  • Memory: 2–6× less per instance, depending on the build and the
    isolate-group setting. A thousand tenants take about 1 GB instead of 6.7 GB.
  • Startup: up to about 80× faster with a warm pool (about 20 µs each).
  • Many instances: 1000 instances share 8 OS threads instead of using
    1000, and serve about 15–20% more requests per second.
  • Sandboxing: no Node.js APIs, plus per-instance CPU-time and memory
    limits.

They don't win on the speed of a single message, and they can't spread heavy
computation across all cores, since the pool is capped at 8 threads.

Setup for this performance comparison

  • Machine: 88 CPUs, 250 GB RAM, Linux x64.
  • Two builds:
    • Release: the default build, without pointer compression. It doesn't
      support isolate groups, so every green thread is in V8's default group.
    • Pointer compression: supports isolate groups. By default, each green
      thread is in an isolate group of its own.
  • Green thread pool: 8 threads (kMaxPoolThreads).

Each measurement runs in a fresh child process, so that memory numbers don't
include what earlier measurements left behind.

Kinds of green threads measured:

Kind Meaning
green Default options: an isolate group of its own where supported
green-default isolateGroup: 'default', V8's default isolate group
green-shared One IsolateGroup shared by all instances
green-warm Default options, taken from a warm pool filled beforehand

Memory and startup

1000 idle instances, started at once, measured once each has sent a message.

RSS per instance Starts per second OS threads
Worker 6.7–7.9 MB ~720 1000
Green, Release build 5.2 MB ~750 8
Green, pointer compression (own isolate group) 2.8 MB ~1,220 8
Green, pointer compression (isolateGroup: 'default') 2.6 MB ~810 8
Green, shared IsolateGroup 1.03 MB ~1,590 8
Green, from a warm pool same as cold 44,000–66,000 8
  • Memory: a green thread uses 1.5× to 6.5× less memory than a worker. A
    worker carries a whole Node.js environment, an event loop and an OS thread
    with its stack. A green thread is a V8 isolate with a small runtime.
  • Threads: green threads share a pool of 8 threads, however many there
    are.
  • Warm pool: setWarmPoolSize(n) prepares isolates in advance, so
    starting a green thread takes about 20 µs. Workers have no equivalent.
  • Cold start is only slightly faster than a worker's, except with a
    shared isolate group.

Speed

Message round trips

Sequential round trips with one instance.

Round trips per second Latency
Green ~57,000 ~17.5 µs
Worker ~61,000 ~16.5 µs

Workers are about 5% faster per message.

CPU-bound tasks

2000 tasks of fib(27), each executor taking the next task once it's done.
Release build.

Tasks per second Time
Green ×8 2,466 811 ms
Green ×2000 (one per task) 2,226 898 ms
Worker ×8 2,478 807 ms
Worker ×88 9,759 205 ms

At equal parallelism, CPU-bound code runs at the same speed. Green threads
can't use more than 8 cores, though, so for heavy parallel computation
workers win clearly. The cap is a choice in this implementation, not
inherent, and could be made configurable.

Many tenants

1000 tenants, each with code and state of its own, serving 50,000 requests
spread over all of them, 64 at a time.

All started Requests per second RSS total
Worker (Release) 1,653 ms ~129,000 7,974 MB
Green (Release) 1,332 ms ~150,000 5,267 MB
Worker (pointer compression) 1,387 ms ~126,000 6,725 MB
Green (pointer compression) 836 ms ~149,000 2,883 MB
Green, shared IsolateGroup 632 ms ~149,000 1,112 MB

Green threads serve about 15–20% more requests, since 8 pool threads don't
compete for the CPU the way 1000 OS threads do.

Runaway code

One of 100 tenants runs into an endless loop, while the others keep serving.

Stopped by Stopped after Others served in 200 ms
Green ERR_GREEN_THREAD_CPU_LIMIT (maxTaskCpuTimeMs: 50) ~60 ms ~35,000
Worker A host timer, then terminate() ~70 ms ~31,500

Both stop it. Green threads do it with a limit on CPU time, which the runtime
enforces. Workers need a timer by wall time on the host, which also stops
code that's only waiting.

What's easier to build: a plugin host

The same plugin host, built with each. It runs five plugins, which are ES
modules exporting transform(text). Plugins can import a shared module, and
call back into the host for configuration and logging. Three of them
misbehave.

Plugin Green threads Workers
upper, reverse (well-behaved) Work Work
spin (endless loop) ERR_GREEN_THREAD_CPU_LIMIT after 100 ms of CPU time The host's own wall-clock timeout, which also stops plugins that are only waiting
hog (allocates ArrayBuffers) Allocation fails at the 32 MB maxMemoryMb limit Not limited, since resourceLimits only cover the JS heap. Reached 91 MB within the 100 ms timeout
fail (throws with a code) Error arrives with code: 'ERR_PLUGIN' Only with hand-written error forwarding

The green thread version is 43 lines:

const plugin = new GreenThread(source, {
  type: 'module',
  filename: `file:///plugins/${name}/index.js`,
  modules: plugins.shared,
  env: {
    async config(key) { return plugins.config[key]; },
    log(...args) { console.log(`[${name}]`, ...args); },
  },
  resourceLimits: { maxTaskCpuTimeMs: 100, maxMemoryMb: 32 },
});
const { transform } = await plugin.exports;
console.log(await transform('hello world'));

The worker version is 155 lines, since it has to build what green threads
provide:

  • Module loading: workers load modules from files or URLs, so the
    plugins are written to a temporary directory. Green threads take a
    modules bundle and resolve() / load() hooks.
  • Calls in both directions: workers need call IDs, a table of pending
    calls, and error forwarding, roughly what Comlink does. Green threads get
    exports and env as RPC stubs.
  • Limits: workers have no CPU-time limit and no ArrayBuffer limit.
    Green threads have both.
  • Isolation: worker plugins have all of Node.js' APIs, including
    require('fs'). The permission model applies to the whole process, so it
    can't restrict a single worker. Green threads have no Node.js APIs at all:
    they can only do what the host passes them. This is the biggest
    qualitative difference.

When to use which

Use green threads for:

  • Multi-tenant or serverless-style hosting of many small handlers.
  • Plugin and extension systems that run untrusted code.
  • User-supplied scripts, rules, or transforms that need CPU and memory
    limits.
  • Many short-lived or mostly idle units of code, where startup time and
    memory per instance matter.

Use workers for:

  • Code that needs Node.js' APIs (fs, net, child_process), or npm
    packages that use them.
  • Heavy parallel computation across all cores, while the green thread pool
    is capped at 8 threads.
  • Blocking code: Atomics.wait() and synchronous waits are disabled in green
    threads, since a blocked isolate would hold up a shared pool thread.
  • A few long-lived instances, where the cost per instance doesn't matter.

Caveats

  • The pointer-compression numbers come from the build at f14fd2d7548,
    which doesn't have the ES modules and errors commits. These scenarios don't
    use either.
  • The warm-pool numbers for the pointer-compression build come from a later
    run than the rest of its table. The first run used a setting that bypassed
    the pool.
  • Worker startup throughput depends on the number of cores. On smaller
    machines, the gap is likely larger.
  • Some JIT-compiled loops ignore termination, a V8 bug. Green threads rely on
    a watchdog that abandons such isolates. Workers are affected by the same
    bug when terminated.

The internal NewIsolate() always registers the isolate with the
platform using a libuv loop, which ties V8's foreground tasks to that
loop. Add an overload that registers an IsolatePlatformDelegate
instead, so that embedder-level schedulers can run an isolate's
foreground tasks themselves, and share the rest of the setup between
both overloads.

Assisted-by: Claude Opus 5.5
Add GreenThread, which runs JavaScript in its own V8 isolate without an
OS thread, event loop or Node.js environment of its own. Green threads
are scheduled onto a process-wide pool of threads, each with a libuv
loop. An isolate stays on the pool thread it was assigned to, and runs
one task at a time with the isolate locked, followed by a microtask
checkpoint.

A green thread only gets the ECMAScript built-ins, the default context
of the startup snapshot, plus postMessage(), onmessage, close(),
queueMicrotask(), timers and a console that is forwarded to the host.
The host can send messages, evaluate scripts and terminate the green
thread. Uncaught exceptions, unhandled rejections and reaching the heap
limit emit 'error' and stop the green thread.

V8 foreground tasks for green isolates go through their own task
runner, so they run on the right pool thread with the isolate locked.
Terminations requested while an isolate is unlocked are tracked
separately, because acquiring the lock discards them. Green threads are
stopped when their host Environment is torn down, and before the
platform is disposed when the process exits.

The module is only available with --experimental-green-threads.

Assisted-by: Claude Opus 5.5
Green isolates were pinned to the pool thread they were created on, so
one that never yielded blocked every other green thread assigned to the
same thread.

Queue the work of each isolate in FIFO order instead, and let whichever
pool thread holds the isolate's lock run a slice of it. Work is either
unbound, like messages, evaluations and V8 tasks, or bound to the pool
thread that owns the I/O it continues. Isolates with unbound work wait
in a process-wide ready queue, from which idle threads take them,
preferring the thread they last ran on. No thread ever blocks waiting
for an isolate.

Every message and evaluation now runs in its own invocation context,
bound to the thread that runs it. Timers belong to the current context,
which is carried through promise reactions as continuation-preserved
embedder data, and their handles live, fire and are closed on that
context's thread.

Also destroy V8 tasks that never ran before the isolate is freed.

Assisted-by: Claude Opus 5.5
Closing a GreenThread handle terminated the green thread and then
waited for a pool thread to dispose of it. When every pool thread was
busy with green threads that never yield, and those were only going to
be terminated after it, tearing down the Environment hung. If no pool
thread is running the isolate, shut it down on the calling thread
instead.

A delayed V8 task could be destroyed after its isolate was freed when
its timer fired while the isolate shut down, because the timer's
callback shared ownership of the task. Only let the scheduler own it.

After an uncaught error or reaching the heap limit, the microtask
checkpoint could still run, since IsExecutionTerminating() is false
again once the termination has unwound the stack.

Fire an interval at most once while a previous fire is still queued,
so an isolate that is busy on another thread doesn't run a backlog of
callbacks once it is free.

Assisted-by: Claude Opus 5.5
Add the maxCpuTimeMs and maxTaskCpuTimeMs resource limits. A
process-wide watchdog thread interrupts a green thread at the earliest
point at which its task could have used up its CPU time budget. The
interrupt then checks the CPU time of the thread that runs the task,
and either terminates the green thread or waits for the rest of the
budget. This preempts code that never yields, such as busy loops,
endless microtask chains and long-running built-ins.

Add the maxMemoryMb resource limit, which covers the heap and
ArrayBuffers together. ArrayBuffer allocations that would exceed it
throw a RangeError, and the green thread is terminated when the memory
it keeps alive exceeds it after a garbage collection.

Most of the time it takes to start a green thread is spent creating its
isolate, which deserializes the isolate part of the startup snapshot.
Add setWarmPoolSize(), which keeps isolates with the default isolate
options ready, created in the background when pool threads have nothing
else to do. Green threads now get an isolate in two steps: preparing
it, which a warm isolate already is, and activating it.

Green threads that no Environment owns, like warm ones, are now also
stopped in TearDownOncePerProcess().

Assisted-by: Claude Opus 5.5
Compare how fast green threads, warm green threads, workers and vm
contexts start, and how fast green threads and workers echo messages.

Assisted-by: Claude Opus 5.5
Return a promise from setWarmPoolSize() that is fulfilled once the warm
pool holds the requested number of initialized isolates, so that
callers, like the startup benchmark, no longer have to guess how long
filling it takes.

Assisted-by: Claude Opus 5.5
The ArrayBuffer allocator checked allocations against the size of the
heap as of the last garbage collection, which was 0 until the first
one. ArrayBuffers could then take up the whole memory limit, and the
next garbage collection terminated the green thread for being over the
limit, instead of the allocation throwing a RangeError. Even with an
accurate heap size, the heap grows a little between garbage collections,
for example by the ArrayBuffer objects themselves.

Record the size of the heap once the context is created, and leave a
sixteenth of the limit for the heap to grow into.

Also stop using `new Array(n)` in the test for heap and ArrayBuffer
memory together. V8 may compile such a loop with on-stack replacement
into code that no longer handles interrupts, so that the green thread
is never terminated.

Assisted-by: Claude Opus 5.5
With pointer compression, all isolates in a V8 isolate group share one
4 GB pointer compression cage. Where the build supports more than one
group, give each green thread a group of its own by default, like
Node.js does for its own isolates, so that green threads can't use up
the heap space of the main thread or of each other. Add the
isolateGroup option to select V8's default group, or an IsolateGroup
that green threads share, instead.

Isolates in groups that only green threads use start from V8's own
startup snapshot rather than from Node.js', since all isolates in a
group need the same snapshot and green threads don't need any of
Node.js' per-isolate state. With pointer compression, this makes
starting a green thread about twice as fast, and its memory use about
40% lower, or 65% lower when green threads share a group.

The ArrayBuffer allocator of a green thread now allocates from the
sandbox of its isolate group in sandbox builds.

Assisted-by: Claude Opus 5.5
V8 can compile code, such as some loops that it optimizes with on-stack
replacement, in a way that no longer handles interrupts. Such code
can't be terminated, so a green thread running it never reached its CPU
or memory limit, terminate() never completed, and a host that waited
for it, such as one that exits, waited forever.

Add a backup watchdog. When a green thread was asked to stop, or a CPU
time check that was requested never ran, it gets a grace period of one
second. After that, the green thread is abandoned: its host is told why
it stopped, it exits with code 1, a GreenThreadWarning is emitted, and
its isolate is never used again or freed.

The watchdog then signals the pool thread that runs it until the signal
arrives while the thread runs code that V8 generated, which holds no
locks. The signal handler then jumps back to the pool thread's loop,
leaving the frames of the green thread behind, and the pool thread is
cleaned up and runs other green threads again. Where that's not
supported, or doesn't succeed within 10 seconds, the process is aborted.

V8 and the platform are not disposed at exit if green threads were
abandoned, since their isolates still use them.

Assisted-by: Claude Opus 5.5
Make the grace period after which a green thread that doesn't stop is
abandoned, and the time after which the process is aborted if its pool
thread can't be cut off, configurable for the whole process. An abort
timeout of Infinity turns the abort off; the process then doesn't wait
for such threads when it exits.

This replaces the binding's setUnresponsiveTimeoutsForTesting().

Assisted-by: Claude Opus 5.5
V8 only defines v8::CppHeapPointerTag::kDefaultTag when the sandbox is
disabled, since embedders that use the sandbox are expected to use
specific tags, so building with
--experimental-pointer-compression-shared-cage failed. Use a tag of
Node.js' own with the same value instead.

Assisted-by: Claude Opus 5.5
Refilling the warm pool was only posted as background work to create
the isolate. Its initialization, which takes several milliseconds, was
then scheduled like any other work, so green threads that were started
in a burst waited for refills on all pool threads.

Schedule the initialization of warm isolates as background work too,
run background work on at most half of the pool threads at a time, and
only hand out warm isolates that are initialized.

Assisted-by: Claude Opus 5.5
Run a JS runtime in every green thread before any user code, and use it
to give green threads EventTarget, Event, AbortController, AbortSignal
and DOMException, which RPC and handlers will need.

The runtime's modules live in lib/internal/green_threads/, and can only
require each other. They use primordials of their own, with the same
names as Node.js' but only those the runtime needs, since creating all
of Node.js' primordials took about 3 ms per green thread. DOMException
is Node.js' own. Pool threads share the built-in sources and code cache
of the main thread, like workers do.

This makes starting a green thread without the warm pool about 1 ms
slower.

Assisted-by: Claude Opus 5.5
Add the env option, whose functions a green thread can call, and the
RpcTarget class, whose instances are passed as stubs with the same
methods. Functions passed directly as arguments or results, RpcTargets
anywhere in them, and stubs are passed in both directions, so that a
green thread can also pass its host callbacks and RpcTargets of its own.
A stub passed back to the side it came from is the original again.
Calls return promises; errors are cloned, DOMExceptions included.

AbortSignals can be passed anywhere in arguments and results, and abort
on the other side when the original does. Stubs that are garbage
collected or disposed release what they refer to on the other side.
When a green thread exits, pending calls are rejected.

The result of a call that a green thread makes continues in the
invocation context that made it, which is kept until then.

Both sides use the same RPC module, lib/internal/green_threads/rpc.js;
the modules that only green threads run move to
lib/internal/green_threads/runtime/. Values are serialized in C++, which
treats RpcTargets, stubs and AbortSignals as host objects, and writes
their descriptors ahead of the value, since V8 doesn't allow JS to run
while it deserializes.

Assisted-by: Claude Opus 5.5
Let postMessage() on both sides take a transfer list of ArrayBuffers,
as an array or an object with a `transfer` property, like
port.postMessage() and the web's postMessage(). Their backing stores
are moved to the receiver, and the senders' ArrayBuffers are detached.

With a memory limit, a green thread that transfers an ArrayBuffer away
copies it into memory that doesn't count towards the limit, and one
that receives an ArrayBuffer copies it into its own, so that buffers
count towards the limit only while the green thread has them.

SharedArrayBuffers are now shared rather than rejected. Atomics.wait()
throws in green threads, since it would block their pool thread.

Assisted-by: Claude Opus 5.5
Let something other than a MessagePort own a MessagePortData and be
notified of its incoming messages, through a MessagePortData::Owner
interface, so that ports can be received on threads without an
Environment, such as those of green threads.

Add what such owners need: setting the owner, taking incoming messages,
reading a message's contents without deserializing it, adding
transferred ArrayBuffers to a message that wasn't serialized with
Message::Serialize(), and telling MessagePortData transferables apart
without RTTI.

Assisted-by: Claude Opus 5.5
Give green threads the web's MessageChannel, MessagePort and
MessageEvent. Their ports are Node.js' MessagePorts underneath, whose
messages a green isolate receives on its pool threads, so a port can
connect green threads with each other, with their host and with
workers. Ports can be transferred with postMessage() on either side
and with ports' own postMessage().

Messages from Node.js with transferables other than ports, or with
WebAssembly modules, are 'messageerror' events in green threads.

Assisted-by: Claude Opus 5.5
Add a `type: 'module'` option, with which a green thread runs its source
as an ES module. Since green threads have no file system, modules come
from the host: from a `modules` bundle by URL, or from async `resolve()`
and `load()` hooks that run on the host's thread and are called through
RPC. Specifiers that are URLs or relative are resolved as URLs, others
with resolve(). JSON modules, import(), also from scripts, and
import.meta.url are supported. node: URLs can't be imported.

A module's graph is fetched before it is linked, since V8 links modules
synchronously.

greenThread.exports is a promise for the main module's exports, whose
functions are RPC stubs. Each call runs in an invocation context of its
own.

Assisted-by: Claude Opus 5.5
Errors that green threads throw reached the host as structured clones,
which drop all properties but a few, including `code`, and lose the
class of errors other than built-in ones.

Like workers' errors, send them with the nearest built-in error class
and all their data properties, own and inherited, with `cause` and the
`errors` of AggregateErrors sent the same way. Do so for 'error' events,
rejections of evaluate(), and RPC rejections in both directions. The
receiver creates real errors of that class, with the properties that
they don't inherit already. Properties that can't be cloned are left
out, and other thrown values that can't be cloned are sent as strings.

Assisted-by: Claude Opus 5.5
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/config
  • @nodejs/gyp
  • @nodejs/loaders
  • @nodejs/performance
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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