Repository navigation
Conversation
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
Collaborator
|
Review requested:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Compared with worker threads
Green threads have real advantages over workers, in specific areas:
isolate-group setting. A thousand tenants take about 1 GB instead of 6.7 GB.
1000, and serve about 15–20% more requests per second.
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
support isolate groups, so every green thread is in V8's default group.
thread is in an isolate group of its own.
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:
greengreen-defaultisolateGroup: 'default', V8's default isolate groupgreen-sharedIsolateGroupshared by all instancesgreen-warmMemory and startup
1000 idle instances, started at once, measured once each has sent a message.
isolateGroup: 'default')IsolateGroupworker 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.
are.
setWarmPoolSize(n)prepares isolates in advance, sostarting a green thread takes about 20 µs. Workers have no equivalent.
shared isolate group.
Speed
Message round trips
Sequential round trips with one instance.
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.
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.
IsolateGroupGreen 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.
ERR_GREEN_THREAD_CPU_LIMIT(maxTaskCpuTimeMs: 50)terminate()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, andcall back into the host for configuration and logging. Three of them
misbehave.
upper,reverse(well-behaved)spin(endless loop)ERR_GREEN_THREAD_CPU_LIMITafter 100 ms of CPU timehog(allocatesArrayBuffers)maxMemoryMblimitresourceLimitsonly cover the JS heap. Reached 91 MB within the 100 ms timeoutfail(throws with a code)code: 'ERR_PLUGIN'The green thread version is 43 lines:
The worker version is 155 lines, since it has to build what green threads
provide:
plugins are written to a temporary directory. Green threads take a
modulesbundle andresolve()/load()hooks.calls, and error forwarding, roughly what Comlink does. Green threads get
exportsandenvas RPC stubs.ArrayBufferlimit.Green threads have both.
require('fs'). The permission model applies to the whole process, so itcan'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:
limits.
memory per instance matter.
Use workers for:
fs,net,child_process), or npmpackages that use them.
is capped at 8 threads.
Atomics.wait()and synchronous waits are disabled in greenthreads, since a blocked isolate would hold up a shared pool thread.
Caveats
f14fd2d7548,which doesn't have the ES modules and errors commits. These scenarios don't
use either.
run than the rest of its table. The first run used a setting that bypassed
the pool.
machines, the gap is likely larger.
a watchdog that abandons such isolates. Workers are affected by the same
bug when terminated.