No-GIL Parallelism: Production-Readiness Status
On this page 7
GIL-free shared-realm execution is correct, the default, and CI-gated. What remains is mostly performance, documentation, and broader stress coverage rather than new correctness architecture.
Summary
| # | Item | Status |
|---|---|---|
| 1 | Broaden no-GIL correctness coverage | Done; ongoing expansion |
| 2 | GC maturity | Correct; performance and pause-time work remaining |
| 3 | Parallel performance validation | Benchmarked; optimization remaining |
| 4 | Embedder API | Done (Zig + C) |
| 5 | Robustness | Fuzzed and gated; continue broadening |
| 6 | CI gating on every PR/push | Done |
1. Coverage
- VM shared-state audits closed the known escaped-frame races:
load_local/store_local/load_upval/store_upvalon closure-escaped frames serialize on a gated per-frame lock. - Tree-walker entry into VM-compiled closures now dispatches through the VM,
so spawned
Threadentry and normal calls agree on upvalue resolution. - VM-recursive calls use the same catchable stack guard as tree-walker calls,
so bytecode recursion raises
RangeErrorbefore native stack overflow. Recursion depth is native-stack-bound (thestack_scanredzone probe, not the 16384 logical cap), so spawnedThreads now run on a 64 MiB stack — lifting worker recursion from 577 to 2337 frames (release), into the thousands the PR-249 deep-stack case needs. The main realm's depth is not a fixed cap: the guard is native-stack-bound (stack_scan.nearLimitprobes the running thread's registered OS bounds), so it auto-adapts to whatever stack the embedder's owner thread has — a context created on a small (~8 MiB default) owner thread gets ~576 frames, one created on a large owner thread gets proportionally more, with no library change. The library cannot resize the embedder's own thread (the owner-thread affinity model bindsevaluateto it viaassertOwnerThread), so deep main-realm recursion is an embedder choice: create the context on a thread spawned with a largerstack_size, or run the deep-recursing code inside anew Thread(...)(spawned workers already get the 64 MiB stack). Deep recursion beyond the native stack is now handled on the bytecode VM by a call trampoline (vm.runDriver): a JS→JS.callunder the driver pushes an explicit heapActivation(frame + operandexec+ saved caller state) instead of recursing natively, so a VM-compiled recursive function is bounded by themax_call_depth(16384) logical ceiling / heap, not the OS stack — e.g.r(8000)returns on an 8 MiB thread where a native path RangeErrors far earlier. Throws unwind the activation stack; each activation'sexecis a precise GC root;execLoop(top-level program and generator/async bodies) and method/new/spread calls keep native dispatch (a method's own internal recursion still trampolines via its nested driver). Validated across the full Linux TSan gate. Tree-walked functions (constructs outside the compiler's lowering subset) still recurse natively, adapting to the owner thread stack as above. - Active VM frame slots are traced as GC roots, not only operand stacks, closing the mid-script parallel-GC use-after-free found by the fuzzer.
-Dtest262-parallel-jsruns a broad language-surface slice in GIL-free parallel contexts and asserts no new failures versus the baseline.- PR-249 coverage contains 248 promoted files out of 259 executable files: 243
in normal mode plus 5
parallel_js-only witnesses, covered by the sharded no-GIL ThreadSanitizer corpus gate. - Six JSC-private or intentionally incompatible premises have terminal dispositions; the remaining 5 optimizing-tier cases are owned by #429.
- The no-GIL
cve/mc-dos-waiter-table-storm.jsfocused gate covers propertyAtomics.waitAsynctickets removed by a peer while their owning spawned thread is closing its stack-local microtask queue; late settlements reroute to the realm queue instead of stranding reactions after the owner's final flush. Its reclamation arm now gates the realm-quiescent full collection on final synchronous joins after async results are verified, removing a scheduler race betweenasyncJoinpublication and native thread teardown without weakening the waiter-root reclamation threshold. Arm labels and failure-only engine diagnostics keep future stalls attributable. - The PR-249 tail is checked by
zig build threads-reference-audit threads-reference-probes, so blockers and terminal premises cannot become accidental no-op passes.
Remaining: keep widening generated and hand-written stress toward exceptions, termination, cleanup, waiters, and cross-thread lifecycle.
2. GC Maturity
The GC is correct under parallel mutation. Current coverage includes
test262-parallel, parallel_gc tests, mid-script collector tests, and a
sustained parallel_gc soak that checks retained graphs survive and the live
set stays bounded across rounds.
Known performance/maturity work:
- GC cells now use
Context.GcCellBacking, a reusable size-class slab backing that recycles 16-byte-aligned cell allocations and delegates non-cell heap side storage unchanged. Fresh chunks hand out cells through lazy bump cursors with a per-bucket bump hint, so short-lived contexts do not pre-link every unused slot before teardown. Ownership classification is now bucket-local too, so collection and context teardown first reject pointers outside each bucket's address span and do not scan unrelated size-class chunks when freeing a cell. A per-bucket recent-chunk hint keeps repeated frees/remaps from the same slab on the fast path instead of restarting the bucket chunk walk each time. New chunk creation reserves the chunk list, bump-offset list, and sorted address-index metadata in fixed-size capacity chunks before allocating the slab itself, then inserts the address-index entry with a binary lower-bound search, so GC context lifecycle work pays fewer allocator calls and avoids a linear scan as buckets grow. DuringContext.destroy, the backing enters bulk-teardown mode and leaves parallel mode because every shared-realm thread has already joined or terminated. The heap then runs every cell finalizer and releases collector side buffers without issuing one backing-allocator free per cell; the backing releases those cells with its whole chunks immediately afterward. This removes an ownership lookup/free dispatch per surviving cell. Bucket-shaped delegated side allocations are still classified and freed through the wrapped allocator while finalizers run, and non-owned bucket-shaped resize/remap/free paths avoid retaking the backing lock after classification. Explicit quiescentcollectGarbage()now also trims fully unused tail slabs using per-slab live counters: empty spike chunks can be released beforeContext.destroy(), while non-empty chunks and empty inner chunks stay retained for reuse. When multiple tail slabs are released together, the backing compacts freelist and sorted-address-index metadata once for the whole tail range instead of rescanning it once per slab. This cuts the old one-general-allocator-call-per-cell profile without changing the collector API. The current 128-byte Object bucket uses 64 KiB chunks. Expanded 256/512-byte classes use 256/384 KiB, and the 1024/2048-byte classes always use 384 KiB. Explicit collection trims fully unused object-heavy spike chunks back toward the current retained live baseline instead of carrying the former 83-chunk post-collect footprint. LiveSharedArrayBufferretain teardown is also regression covered across the arena path, the no-GIL threaded path, and the.gil = trueserialized fallback. - Single-mutator GC contexts now allocate object side stores directly from the context allocator instead of going through the cell-slab classifier only to delegate. True-parallel JS contexts keep the synchronized backing wrapper for those side stores because the embedder-provided allocator may not be thread-safe.
- GC-enabled contexts now allocate the heap, root-tracing binding, and cell backing as one stable lifecycle state object instead of three separate GPA objects. Existing internal pointers still target the same subobjects, but create/destroy-heavy embedders pay fewer allocator calls per GC context.
- No-GIL context bootstrap now keeps the GC heap and cell backing in their
single-mutator allocation mode until all globals and the
ThreadAPI are installed, then enables parallel heap/allocation locking immediately before returning the context. The realm is still unobservable during bootstrap, so public semantics are unchanged; locally this moved thegc-profilethreaded no-gilcreate column from roughly 14.2 ms/context to roughly 12.5 ms/context while the returned context still reports parallel heap and backing mode. - Tight-loop per-scope allocation in the tree-walker is largely addressed: a
for/for-of/for-inloop reuses one per-iteration binding environment when no closure captures it (keyed offEnvironment.captured), a block or a switch CaseBlock allocates no environment when it declares nothing block-scoped, and a non-arrow call skips building theargumentsobject unless the body could name it. A 4M-iteration tree-walked loop with a block body dropped ~74s→15s. What remains here is the genuinely per-iteration-allocating case (a captured loop binding, or a block that does declare alet), which still wants a nursery/generational fast path rather than an environment-reuse trick. - Context create/destroy remains more expensive than the arena model because global setup and GC finalization still touch many cells. Long-lived contexts amortize this; create-per-task embedders still need additional lifecycle reductions or guidance.
- Supported pooling guidance today: prefer a bounded pool of long-lived
contexts per isolation domain (tenant, module-loader/global state, and host
capability set), run one embedder task at a time per pooled context unless the
application intentionally exposes shared-realm parallelism, and collect at
quiescent task boundaries. The
gc-profiletask row models that as one warmup evaluation followed by 40 tasks in the same context withcollectGarbage()every 10 tasks. On the 2026-07-10 local profile, recreate/evaluate/destroy was 6.67x slower than reuse+periodic-GC for explicit GC and 6.69x slower for threaded no-GIL GC. Treat those as host-specific point measurements: use the ratio column on the target deployment host before setting pool size or collection cadence. Destroy instead of reusing when global/module state must be reset, untrusted code may have polluted the realm, protected host handles cannot be released at the boundary, or live Worker/Thread activity has not quiesced. zig build gc-profileis the local baseline for those costs. It compares arena, explicit-GC, no-GIL threaded GC, and.gil = truecontexts across create/destroy, create-per-task versus long-lived-context reuse with periodic collection, workload destroy attribution with and without a prior quiescentcollectGarbage(), object-heavy allocation, block-scopedletallocation, and explicitcollectGarbage(). Use-Dgc-profile-case='<exact table name>'for focused local rows such asnursery,nursery drift,allocation, orgc churn reusebefore paying for the full profile matrix. The lifecycle row now breaks create and destroy apart, the workload destroy row separates finalizer/collection work from post-collection teardown, and the profile also prints GC cell-backing attribution for the intrinsic empty-context footprint and for an object-heavy allocation run: chunk count, total cell-slot capacity, live cells at context creation, live cells after allocation, free slots after collection, and live cells after collection, followed by per-size-class bucket tables for the empty context and the same workload. The bucket tables show slot size, chunks, capacity, issued cells, fresh allocations, reused allocations, freed cells, free cells, and live cells, using exact per-bucket free, capacity, issued, fresh, reused, and freed counters so profiling a collection no longer walks every freed cell or slab chunk. Finalizer attribution is likewise split between empty-context destroy and destroy after the object workload. Fresh-slot allocation skips slab chunks whose bump range is already exhausted, chunk metadata growth is reserved before each slab allocation, explicit collection trims fully unused tail slabs after object spikes, and multi-slab tail trimming compacts freelist and sorted-address-index metadata once for the whole trimmed range instead of rescanning it once per released slab. The per-class 64/256/384 KiB geometry is reported explicitly so the profile exposes chunk churn separately from remaining create/destroy wall-clock costs. A repeated allocate-plus-collect churn table now reports fresh versus reused cells, freed cells, final chunk/live counts, and reuse percentage. Quiescent nursery rows now compare multiple 512-allocation retention shapes (ephemeral, sparse, one-quarter retained, array/object one-quarter retained, and all retained) and report boundary pause time, young input cells/bytes, reclaimed cells/bytes, promoted cells/bytes, byte survival/reclamation percentages, the next nursery threshold, minor/full cycle deltas, and repeated-batch threshold drift instead of relying only on a one-shot object workload. The nursery threshold policy now keeps low-survival decay gradual but caps upward growth at the observed young batch size, avoiding the earlier high-survival pattern where one fixed-size batch could raise the threshold enough to skip the next minor and carry dead young cells to a later boundary. The no-GIL bootstrap row should also be read against the explicit parallel-lock deferral above: returned contexts are fully parallel, but private global/API installation no longer measures the atomic allocator lock on every cell allocation. Once parallel,threads-profileexposes LockedArenaaacq/acnt/aspnand Environment binding-lockeacq/ecnt/espncolumns, plus Object backing/property/element lock triplets (ob*/op*/oe*), in the shared-realm rows so allocation-heavy and global/environment-binding-heavy profile cases can separate arena lock traffic from binding-table, hidden-class, object-storage, and synchronization pressure. cell allocation is locked per size class, so unrelated cell sizes can use their slab/free-list fast paths concurrently. Only chunk growth, chunk metadata, and delegated non-cell side storage take the separate inner-allocator lock required for a potentially non-thread-safe embedder allocator. The landed nursery is non-moving and quiescent-only: survivors tenure after one minor cycle, explicit GC remains full-heap, and the no-GIL mid-script collector remains the existing abort-safe full collector. GC finalizer attribution includesskipfree, which must equal finalized cells for the slab-owned bulk teardown path.- Mid-script parallel GC remains abort-safe. Sync wait/lock/condition peers are
not treated as frozen parked stacks; their lock-free pump points now service
root publication, and the collector waits long enough for one bounded park
wake. This lets property
Atomics.wait,Condition.wait, and contendedLockacquisition converge under the mid-script collector while preserving quiescent collection as the fallback for heavier non-converging cycles. Thezig-gcbarrier hand-off also re-checksmarkingandconcurrentwhile holding the barrier lock, so a stale mutator that observed an active collection just before an abort cannot append intobarrier_bufafter the abort path has cleared it; the dependency has a deterministic regression test for that abort boundary and is covered by the localparallel_jsTSan slice. Broad "maybe managed" root/barrier inputs now use exact live-payload membership inzig-gc, so stale or wild non-GC pointers fail closed instead of being header-peeked; the dependency's live-payload index keeps that safety compatible with barrier-heavy no-GIL fuzz/profile workloads.zig-gcalso frees oversized empty collector scratch buffers after a one-off spike once current live-cell pressure no longer justifies retaining them, so pooled contexts keep the normal scratch reuse path without carrying arbitrarily large weak-slot/mark-stack buffers forever. The current internal policy treats propertyAtomics.wait,Condition.wait, and contendedLockacquisition as running peers while they pump tasks and GC safepoints; only the short native condition-wait region setsgc_parked, and the collector may directly trace that frozen peer only while pinninggc_root_lock. Each root-publication generation gives running peers up to 50,000 mark/poll iterations, with a 25 ms floor for opportunistic mid-script collection and a 100 ms floor for allocation-failure recovery that cannot otherwise proceed. Both use a 32-generation base convergence cap. Attempts that are still making born-cell progress and have no deferred work may use bounded extension rounds before a round-limit abort. Publication-timeout or round-limit failures abort without sweeping, briefly suppress immediate re-election churn at later safepoints, and leave reclamation to the next quiescent full collection. These budgets are measured implementation policy, not stable embedder API. zig build midgc-profilenow makes convergence measurable without exposing the internalparallel_midscript_gcknob as embedder API. It separates publication-timeout aborts from round-limit aborts, splits out attempts whose finish was blocked by deferred generator/iterator cells, and reports publication generations, total/worst-generation publication polls, total/worst-attempt self-checking finish retries, running versus parked peer observations, actual peer root publications, post-abort retry backoff skips, and collector-side total/maximum pause time. Focused tests assert the attempt/outcome and abort-reason accounting identities, the max-versus-total bounds, and prove both running-peer publication and direct parked-peer observation paths execute.- Native synchronization side records participate in the nursery remembered
set. Lock/Condition/ThreadLocal wrappers are the owner for queued async-hold
jobs, async condition waiters and lock edges, and ThreadLocal map values. A
deterministic minor-collection regression plus focused Debug/ReleaseFast
condition asyncWaitprofiles guard the GC-poisoned Promise failure that previously surfaced when the broad contention profile reached 4 threads.
3. Parallel Performance
zig build benchincludes a scaling benchmark where N JSThreads run independent compute loops in one GIL-free context.zig build threads-profileis the dedicated contention harness. It compares the no-GIL default with.gil = trueacross independent compute, shared object properties, global lexical binding churn, mixed Object/Function/Promise GC-cell allocation, shared array append, typed-array Atomics, contended propertyAtomics.wait/notify,Condition.wait/notifyAll, propertyAtomics.waitAsynctimeout settlement, single-lock and multi-lockCondition.asyncWait,Lock.hold,Lock.asyncHolddelivery, observedLock.asyncHoldcallback settlement, no-fnLock.asyncHoldrelease-function delivery, and thread lifecycle churn. Each row enables and includes internal contention counters:eventscount logical contention (Lock/Condition/property wait and queuedasyncHoldgrants),shape/newsh/syldreport hidden-class transition requests, newly-created child shapes, and transition-lock yields,aacq/acnt/aspn,eacq/ecnt/espn, andob*/op*/oe*report arena, Environment binding, and Object backing/property/element lock traffic,lcntandaqsplit direct contendedLock.holdattempts from queuedLock.asyncHoldgrants inside that total,parkscount timed wait/pump iterations includingThread.join,joinssplit theThread.joinsubset out of aggregate parks for lifecycle attribution,lock/cond/propsplit the remaining sync park pressure by contendedLock.hold,Condition.wait, and propertyAtomics.wait,waitus/jus/lus/cus/pussplit total native wait microseconds plus join/lock/condition/property wait microseconds,async/doneaggregateCondition.asyncWaitplus propertywaitAsyncregistration against completed async-condition reacquires plus settled propertywaitAsynctickets, whilecaw/cadandpaw/padsplit those same async sources into condition-async wait/done and property-waitAsync wait/done pairs, andempty/jobssplit the run-loop task pump into empty fast-path hits and delivered grant jobs whilehold/cjobsplit those delivered jobs into ordinaryLock.asyncHoldgrants andCondition.asyncWaitreacquire grants;cqgrow/cqcompcount condition waiter-queue backing growth versus consumed-head compaction so notify-heavy rows can separate allocation pressure from amortized FIFO churn.- The mixed GC-cell allocation row explicitly enables GC in both modes, so its
no-GIL versus
.gil = trueresult measures parallel allocator behavior rather than comparing the no-GIL slab allocator with the serialized arena engine. - Use
.gil = truedeliberately for coordination-heavy workloads whose useful work is mostly task delivery, async-condition reacquire, or other serialized handoff rather than parallel JS execution. On the 2026-07-10 local 11-core profile,condition asyncWaitwas faster in serialized mode at every contended width: 2 threads took 15.42 ms no-GIL versus 1.28 ms with.gil = true, and 8 threads took 128.60 ms versus 5.95 ms. That is a representative warning sign, not a portable promise; rerun the focused row on the deployment host and prefer.gil = truewhen thevs gilcolumn stays below 1.0x and thejobs/cjobcounters dominate the row. Conversely, independent compute is the no-GIL-friendly shape: the same profile showed no-GIL at 2.56x, 8.42x, and 16.63x faster than serialized mode for 2, 4, and 8 worker threads, respectively. Single-thread rows can still favor.gil = truebecause they pay parallel bookkeeping without parallel work. - Parked sync waiters still pump the realm run-loop so async-hold grants make progress, but empty pumps now use an atomic queue-count fast path and avoid taking the shared threading API lock.
- Async-hold delivery also dequeues both the per-lock pending grant list and the
realm task queue with FIFO head cursors instead of front-shifting lists,
keeping delivery cost proportional to delivered jobs rather than pending queue
length. Retry-front async-hold grants use an amortized O(1) front stash when
no consumed head slot is available, so failed grant delivery does not shift
the whole per-lock pending list. Task-queue writers publish the
tasks_queuedempty/pending hint from the locked queue length instead of doing writer-side atomic RMW, reducing one shared counter cost in async-grant registration and delivery. Task pumps now copy larger bounded FIFO bursts under the shared threading API lock and run every grant outside it, reducing delivery lock acquisitions from once per job to once per burst and needing fewer shared-lock acquisitions for already-queued grant storms; they also reserve realm task-queue capacity in fixed chunks before capacity-assumed appends, so async grant storms pay fewer allocator-growth trips while holding the shared API lock. Per-lock pending-grant and retry-front queues also reserve fixed-size capacity chunks before capacity-assumed appends, soLock.asyncHoldand async-condition reacquire storms grow those lock-held lists less often without changing FIFO or retry-front semantics. The pump snapshots the microtask enqueue generation around each delivered grant, so unobserved grants that enqueue no reactions skip an otherwise-empty no-GIL microtask drain while preserving checkpoint order for grants that do enqueue reactions. - Promise microtask drains now use a FIFO head cursor instead of
orderedRemove(0), so observed async-hold callback settlement and no-fn release-function reactions do not shift the remaining reaction queue on every delivered job while preserving checkpoint order. Microtask enqueues and abandoned-thread queue transfers reserve fixed-size capacity chunks before capacity-assumed appends, reducing allocator-growth trips under each targetMicrotaskQueue's trace-sensitive queue-local lock during promise/thread lifecycle bursts. Per-promise fulfill/reject reaction lists also reserve fixed-size capacity chunks before capacity-assumed appends underPromise.lock, while preserving GC-owned reaction-entry accounting and settlement/finalization cleanup. - Async-generator request queues now use the same FIFO head-cursor pattern for
queued
.next()/.return()/.throw()requests, avoiding a front shift on every request settlement or done-drain step. Request enqueue reserves fixed-size capacity chunks before capacity-assumed appends and compacts consumed head slots before growing, while GC traces only pending requests. - No-fn
Lock.asyncHoldgrants embed their once-only release state in the already arena-lived hold job, avoiding an extra small allocation per delivered release function while preserving the release-function object and existing lock/GC ordering. - The profile now has direct rows for property
Atomics.waitAsyncfinite timeout settlement plus busy-spin single-lock, parked single-lock, and multi-lockCondition.asyncWaitreacquire delivery. Use the parked row as the cleaner condition-delivery control, and the original single-lock row as the scheduler-pressure stress case. Together they let local performance work separate async waiter registration, property ticket settlement, async-condition reacquire completion, FIFO-bursted task enqueue pressure, and run-loop grant delivery instead of inferring them from elapsed time alone. The profiler prints every exact shared-realm scenario filter from the benchmark table at startup, so control rows such ascondition asyncWait multi-lockremain discoverable as the table evolves. - Condition notify/notifyAll use the same FIFO head-cursor pattern for the
mixed sync/async waiter queue, avoiding one front-shift per notified waiter.
The waiter queue reserves fixed-size capacity chunks before capacity-assumed
appends, so condition waiter bursts pay fewer allocator-growth trips while
holding
CondRecord.mutex. Timed-out or terminated sync condition waiters are marked canceled and skipped by the head cursor instead of being removed from the middle of the queue. Sync notifyAll handoff now waits on the waiter's condition ack signal rather than sleeping in fixed 1ms chunks, with the same timeout fallback for spurious or missed wakes. Async-only condition notifications now release the condition queue mutex before preparing no-fn async regrants, so release-function creation and realm task enqueueing no longer run inside that queue critical section; mixed sync/async wakeups keep the existing sync handoff ordering. Notify records woken sync/async entries in one FIFO wake list; the common small-wake path uses a fixed stack buffer, and only larger notifications allocate a pre-sized heap list. Contiguous async condition regrants for the same lock are prepared in fixed-size stack batches and applied under one lock acquisition per batch, sonotifyAll()no longer retakes that lock once per async waiter. Ready async-condition reacquire jobs are appended to the realm task queue in FIFO bursts, amortizing the shared API lock when a notification wakes multiple lock groups, and sync handoff completion uses a pending-waiter countdown instead of rescanning the wake list until every ticket acknowledges. - Property-mode
Atomics.notifystable-compacts matching waiter queues in one pass. Heap-owned sync wait tickets are unlinked before signal, so awakened peers no longer each rescan and front-shift the table on return; matchingwaitAsynctickets are collected for post-unlock settlement without repeated middle removals. Individual sync wait timeout/termination cleanup now stable-compacts the waiter table in one pass instead of front-shifting the remaining waiters. Timeout polling now also compacts all expired propertywaitAsynctickets in one pass and realm teardown frees abandoned propertywaitAsynctickets by linear scan. Sync waiter and waitAsync ticket tables reserve fixed-size capacity chunks before capacity-assumed appends, so property waiter storms grow table storage less often while holdingGil.prop_mutex. - Typed-array
Atomics.wait/waitAsyncticket-list appends reserve fixed-size capacity chunks before capacity-assumed writes underwaiters_mutex.Atomics.notifyunlinks sync stack tickets before signaling, so awakened waiters do not each rescan and shift the process-wide waiter list. Typed-arraywaitAsyncharvest and abandon paths stable-compact matching tickets in one pass while preserving FIFO order for other waiters. - Context-owned typed-array
waitAsyncpromise roots now takerealm_lockfor list-header mutation, settlement removal, clearing, and interpreter-root tracing, matching the lock already used by the parallel collector'sContextroot scan. The list also reserves fixed-size capacity chunks before capacity-assumed appends, so waitAsync bursts grow root storage less often. - Worker inbox/outbox channels now use FIFO head cursors for structured-clone
message queues, so Worker-heavy lifecycle and receive loops do not pay one
front shift per delivered message. Channel writers also reserve fixed-size
queue capacity chunks before capacity-assumed appends, so Worker message
bursts grow the inbox/outbox arrays less often while holding the channel
mutex.
$262.agentreports use the same FIFO head-cursor shape and reserve fixed-size queue capacity chunks before capacity-assumed appends, so report-heavy Atomics/test262 agent cases avoid one front shift pergetReport()and pay fewer report-queue growth trips under the agent group mutex. Empty internalWorker.receive(..., 0)polls now return from the channel while holding the queue lock instead of entering a timed condition wait, and skip drained-queue compaction on the empty fast path. - SharedArrayBuffer retain lists reserve fixed-size capacity chunks before capacity-assumed appends, so structured-clone and shared-buffer lifetime churn grows each realm's retain table less often while holding the retain-list spin lock. This keeps the JS-visible SAB storage contract unchanged: backing slabs stay refcounted, fixed-address, and released exactly once per retained realm wrapper.
- FinalizationRegistry cleanup jobs reserve fixed-size capacity chunks before
capacity-assumed appends after duplicate suppression, so cleanup storms grow
the realm cleanup-job queue less often while holding
realm_lock. Cleanup job ordering and one-job-per-ready-registry semantics are unchanged. - C-API protected-handle entries reserve fixed-size capacity chunks before
capacity-assumed appends after counted-handle deduplication, so embedder
protect storms grow the root table less often while holding
realm_lock.JSValueProtect/JSValueUnprotectcounting semantics and context affinity are unchanged. - Shared-realm
Threadrecords reserve fixed-size capacity chunks before capacity-assumed appends for the main record and spawned records, so thread-spawn/lifecycle storms grow the per-realm record table less often while holding the GIL/API lock. Thread id allocation, live-cap checks, join records, and teardown semantics are unchanged. Thread.asyncJoin()pending observer lists reserve fixed-size capacity chunks before capacity-assumed appends, so async-join observer storms grow each target thread's pending list less often while holdingjoin_mutex. Completion still swaps the list out before resolving/rejecting promises, and the completion path keeps snapshot promises rooted while settlement can run JS.- Active-interpreter root entries reserve fixed-size capacity chunks before capacity-assumed appends, so evaluate/drain and GC-root registration churn grows that root table less often while holding the active-interpreter lock. Push/pop semantics and GC root iteration are unchanged.
- GIL park records reserve fixed-size capacity chunks before capacity-assumed appends, so thread entry/exit and mid-script-GC root-publication registration grow the per-realm park table less often while holding the GIL. Duplicate suppression, parked-stack publication, collector iteration, and unregister semantics are unchanged.
- Active interpreter roots, protected C-API handles, and GIL park records are unordered root sets, so their removals now use swap removal instead of order-preserving list shifts on evaluate, handle-unprotect, and thread teardown paths.
- Internal module-graph queues used by top-level-await parent resumption,
import deferdependency startup, and dynamic-import namespace waiters reserve fixed-size capacity chunks before capacity-assumed appends. This keeps module graph semantics unchanged while reducing allocator-growth trips in module Worker/import-graph lifecycle bursts. Completed-parent resumption also drains with a FIFO head cursor and clears the retained queue after the drain, avoiding one front-shift per completed parent. - WeakMap/WeakSet entry delete and GC dead-key pruning are unordered by
observable JS semantics, so they now use tail removal instead of shifting
later entries. FinalizationRegistry
unregisterstill preserves survivor cleanup order, but it does so with one stable compaction pass rather than one middle removal per matching record. - The same
threads-profilerun now includes isolatedWorkersections for structured-clone inbox/outbox round-trips, empty receive polling, and teardown. The teardown table splits handler-driven self-close, owner-driven host-close drain of queued messages, and hardterminate()of spinning code, with separate script and module Worker rows so import-graph startup and teardown pressure are visible beside plain source Workers. Message rows include channelpush/popcounts and empty receivenullcounts; teardown rows include per-modeopstotals covering channel push, pop, empty-pop, and close operations. It is reported outside the no-GIL versus.gil = truetable because Workers already isolate eachContextonto its own OS thread. - Measured speedup shows real parallelism: roughly 1.8x at 2 threads and 2.5x at 4 threads in the recorded checkpoint.
Remaining: use the attribution columns to drive targeted reductions in contended user-level locks, Worker-heavy lifecycle and message traffic, shared-buffer lifetime churn, join/lifecycle waiting, object/element storage contention, context lifecycle pooling, nursery sizing/pause tuning, and deeper multi-age generational work.
4. Embedder API
- Zig:
Context.createWith(.{ .enable_threads = true })is parallel by default;.gil = trueopts into serialized execution. - C:
ZJSGlobalContextCreateThreaded(gil)exposes the same choice. - Non-threaded contexts remain single-threaded and avoid the parallel synchronization protocols.
- The public memory-model contract is documented in Memory Model: JS-defined program races remain program races, while engine-state races are bugs and remain TSan-gated.
Remaining: keep C-API context-affinity guidance and memory-model wording current as embedders exercise more threaded host patterns.
5. Robustness
- Re-entrant getter/shared-mutation tests prove per-object locks are not held across JS callbacks in a way that deadlocks.
- The
cve/PR-249 subset covers teardown, waiters, lifecycle, GC, and synchronization hazards. threadfuzzgenerates random shared object / array / closure / typed-array programs in GIL-free contexts and supports single-file reproduction. Its broad profile now adds caught exceptions/finally, nested thread lifecycle,asyncJoin, propertywait/waitAsync,Condition,Thread.restrict, andFinalizationRegistrycleanup coverage under GC-backed parallel contexts.- The mid-script GC fuzzer profile blocks peers in property
Atomics.wait,Condition.wait, and contendedLockacquisition while allocation pressure drivesparallel_midscript_gc; every seed now runs a normal completion wait-pump subprogram, a sync-wait cleanup subprogram, a promise-publication subprogram, a pending-microtask subprogram, a creator-owned buffer subprogram, script and module Worker creator-owned cleanup subprograms, a nested parent/childThread.asyncJoincleanup subprogram, a finalization/Thread.asyncJoinunregister-token cleanup subprogram, a typed-arraywaitAsync/finalization cleanup subprogram, aCondition.asyncWait/finalization cleanup subprogram, aLock.asyncHold(fn)throw/finalization cleanup subprogram, a ThreadLocal lifecycle subprogram, a ThreadLocal-finalization subprogram, a ThreadLocal-termination cleanup subprogram, a Thread.restrict lifecycle subprogram, a Thread.restrict-finalization subprogram, isolated script Worker/SAB and module Worker/SAB cleanup subprograms, script and module Worker/thread finalization cleanup subprograms that park isolated Workers on a retained SAB while shared-realm Threads publishFinalizationRegistrycleanup roots andasyncJoinobservers through a finishing sweep, script and module Worker handler-exception cleanup subprograms, script and module Worker close/terminate drain/drop subprograms, script and module Worker terminate/finalization cleanup subprograms, an async-hold release/waiter cleanup subprogram, script and module Worker/thread teardown cleanup subprograms, script and module Worker/Condition.asyncWait teardown cleanup subprograms, script and module Worker/waitAsync teardown cleanup subprograms, script and module Worker/ThreadLocal/asyncHold teardown cleanup subprograms, a sync-wait burst cleanup subprogram, a sync-timeout exit subprogram, anAtomics.Mutex.lockIfAvailableacquire/timeout cleanup subprogram, anAtomics.Condition.waitnotify/reacquire cleanup subprogram, a weak-collection cleanup subprogram, and an expected teardown-termination subprogram, and each must finish at least one parallel sweep. The wait-pump subprogram queues a FIFOLock.asyncHoldgrant chain including a root-bearing rejected grant plus an asyncCondition.waitreacquire with hidden captured JS roots and requires sync-wait pump points to deliver both during the same mid-script GC pressure window, keeps a typed-arraywaitAsyncpromise/reaction graph reachable only through the native waiter queue until notification, keeps pendingThread.asyncJoinfulfillment/rejection promise reactions reachable only through native completion records until the child threads are released, keeps child-returned fulfilled/rejected promises, user thenables, and thrown objects published through bothjoin()andasyncJoin()in the lifecycle profile, registers child-thread finalization targets with unregister tokens while fulfilled and rejectedasyncJoinobservers plus sync-wait peers remain live through the finishing sweep, keeps typed-arraywaitAsyncreaction roots pending while notifying child threads stay parked through the finishing sweep before exact asyncJoin and cleanup verification, keepsCondition.asyncWaitreacquire tickets and childasyncJoinobservers pending through the finishing sweep before exact reacquire, asyncJoin, and cleanup verification, keeps queuedLock.asyncHold(fn)fulfillment/throw callbacks plus no-fn release grants pending while the lock remains held through the finishing sweep before exact reaction and cleanup verification, keeps a registered object reachable only throughThreadLocal.valuewhile that owner is parked, keeps a completed-but-unjoinedThreadresult object and a completed-but-unjoined thrown exception object reachable only through the thread completion record, then delivers the expectedFinalizationRegistrycleanup count/sum after a quiescent collect. The focusedC-API: JSValueProtect roots survive mid-script parallel GCunit witness directly covers the protected-handle table as a parallel mid-script root: an otherwise unrooted C-API object stays alive through a finishing sweep driven by concurrently running shared-realmThreads and is reclaimed after the finalJSValueUnprotect. The ThreadLocal lifecycle subprogram parks owner threads with per-threadThreadLocal.valueobjects through a finishing sweep before verifying per-thread isolation, nested-thread isolation, thrown-object identity, andasyncJoinobservers. The ThreadLocal-finalization subprogram parks owner threads with registry targets reachable only throughThreadLocal.value, drives a finishing mid-script sweep, rejects any early cleanup delivery while those hidden roots are live, then clears the values and verifies exact cleanup count/sum after a quiescent collection. The ThreadLocal-termination cleanup subprogram keeps ThreadLocal-only cleanup targets live through a finishing sweep, then forces top-level-failure thread teardown, requires blocking joins to observe termination, and verifies exact cleanup after the owner-thread entries are released. The Thread.restrict lifecycle subprogram parks restricted owner-local objects through a finishing sweep before verifying owner isolation, nested foreign access rejection, thrown-object identity, andasyncJoinobservers. The Thread.restrict-finalization subprogram parks owner threads with restricted owner-local objects registered for finalization, verifies nested foreign reads still throwConcurrentAccessError, drives a finishing mid-script sweep, rejects early cleanup while those owner-thread roots are live, then releases the owners and verifies exact asyncJoin plus cleanup oracles after a quiescent collection. The promise-publication subprogram keeps a child-returned typed-arraywaitAsyncpromise pending through the sweep, keeps a child-returned rejected promise and a child-returned user thenable parked behind pre-completionasyncJoin()observers, and verifies post-sweepThread.asyncJoin()fulfillment/rejection/thenable assimilation plusThread.join()returning the original promise/thenable for post-completion observers; it also keeps a child-thrown object with a nested promise rooted through completion state until post-sweepasyncJoin()/join()publication. The sync-wait cleanup subprogram parks peers in propertyAtomics.wait,Condition.wait, and contendedLock.holdacquisition through a finishing sweep, then verifies each resumed peer's stack root plus exactFinalizationRegistrycleanup count/sum delivery; it also lets propertywaitAsynctimeout tickets expire while those sync peers are parked, keeps a live propertywaitAsyncticket rooted through the finishing sweep, keeps isolated script and module Workers parked on retainedSharedArrayBuffers through the same sweep, then notifies the live waiter and both Workers and verifies exact captured-root scoring plus both Worker replies after the sweep. The sync-wait burst subprogram parks multiple waiters on the same property, the sameCondition, and the same contendedLockthrough a finishing sweep, verifies cleanup is not delivered while those stack roots are live, then releases all three wait sets and verifies exact cleanup after quiescence. The sync-timeout exit subprogram parks propertyAtomics.waitpeers and staticAtomics.Condition.waitForpeers through a finishing sweep, verifies cleanup is not delivered while their stack roots are live, then requires timeout results,Atomics.Mutex.UnlockTokenreacquisition/unlock, and exact cleanup after quiescence. TheAtomics.Mutex.lockIfAvailablesubprogram keeps acquire-after-release waiters parked behind a holder through a finishing sweep, allows timeout waiters to expire independently while those acquire peers remain rooted, then requires reused-token acquire and timeout results plus exact cleanup after quiescence. The staticAtomics.Condition.waitsubprogram parks notify/reacquire token waiters through a finishing sweep, verifies cleanup is not delivered while their stack roots are live, then requires exact notify counts, token reacquisition,asyncJoinobservers, and cleanup after quiescence. The pending-microtask subprogram keeps Promise, typed-arraywaitAsync,Thread.asyncJoin, with-fnLock.asyncHold, no-fn release-function, andFinalizationRegistrycleanup roots queued through a finishing mid-script sweep, then drains the realm run loop and checks exact reaction/cleanup oracles. The weak-collection subprogram keeps live WeakMap values reachable only through live weak keys while dead WeakMap/WeakSet targets are reachable only through weak structures and WeakRefs, composes that with parked propertyAtomics.wait,Condition.wait, and contendedLock.holdpeers, and verifies live ephemeron values, cleared dead refs, exact cleanup count/sum, and exact FinalizationRegistry unregister-token suppression after a finishing sweep. The creator-owned buffer subprogram leaves child-createdSharedArrayBufferandArrayBufferstorage rooted through unjoinedThreadcompletion records and delayedasyncJoinobservers across a finishing sweep, then verifies blockingjoin(), post-sweepasyncJoin(), andArrayBuffer.transfer()observers see exact contents after the creating thread has exited. The script and module Worker creator-owned cleanup subprograms carry child-created SAB/ArrayBuffer storage through Worker structured-clone while siblingFinalizationRegistrycleanup roots and transfer observers survive the finishing sweep. The script Worker/SAB and module Worker/SAB cleanup subprograms run isolated Workers on the same retainedSharedArrayBufferwhile shared-realmThreads register cleanup targets and park stack roots through a finishing sweep, then verify exact Worker progress, joined thread roots, asyncJoin reactions, and cleanup count/sum; sibling script/module Worker handler-exception cleanup subprograms first recover from an expected thrownonmessagedelivery, then prove the same Worker progress and cleanup oracle through the finishing sweep. Script/module Worker close/terminate subprograms now preserve exact FIFO drain/drop, post-close drop, post-terminate receive silence, joined roots, asyncJoin reactions, and cleanup count/sum through the same finishing sweep. Script/module Worker terminate/finalization subprograms keep spinning Workers alive on one retainedSharedArrayBufferwhile shared-realmThreads publish cleanup roots, asyncJoin observers, joined roots, and exact cleanup count/sum through the same finishing sweep before Worker termination. The script and module Worker/thread teardown cleanup subprograms keep shared-realm Threads, pendingasyncJoinrejection reactions, and cleanup jobs live through a finishing sweep while isolated Workers spin, then force top-level failure teardown and verify exact rejection and cleanup oracles. The script and module Worker/Condition.asyncWait teardown cleanup subprograms keep a condition async reacquire ticket, parkedThread, isolated Worker progress, and cleanup jobs live through a finishing sweep before notification, top-level failure, rejectedasyncJoinobservation, and exact cleanup. The script and module Worker/waitAsync teardown cleanup subprograms keep child-owned typed-arraywaitAsynctickets pending through a finishing sweep while isolated Workers spin, then force top-level failure teardown and verify rejectedasyncJoinobservers plus zero leaked child waiter tickets. The script and module Worker/ThreadLocal/asyncHold teardown cleanup subprograms compose isolated Worker termination withThreadLocalhidden roots, no-fnLock.asyncHold()release-function delivery, parked property/condition waiters, post-sweep rejection release, top-level failure, rejectedasyncJoinobservers, and exact cleanup through a finishing sweep. The teardown subprogram parks children after installing child-owned typed-arraywaitAsynctickets, verifies pendingasyncJoinrejection reactions with captured roots after the parent throws, and proves post-termination notify wakes zero leaked child waitAsync tickets. - Host-side thread queues are now explicit GC roots: queued
Lock.asyncHoldtasks inGil.tasks, per-lock pending grants, async condition waiters, typed-arraywaitAsyncwaiter/reaction roots, pendingThread.asyncJoinpromise/reaction roots, ThreadLocal values, thread completion results, release-function lock records, and contendedLock.holdreceiver/callback pairs trace or temp-root their hidden JS values instead of relying on a JS property path or native stack scan. Join-side parked-root state now clears and releases the completion mutex on termination/error unwinds, and joiners only publishgc_parkedfor the actual native condition wait rather than while pumping tasks, so failed or activeThread.join()calls do not leave stale or moving frozen-peer state behind. Native-callback entry is covered by a mid-script witness where no-GIL workers repeatedly enter VM closures throughArray.prototype.mapwhile the collector publishes roots. Requested shell/host GC leaves an elected mid-script parallel collector untouched while threads are live, and a later quiescent collection aborts stale parallel mark state before starting a fresh precise mark. - The lifecycle fuzzer profile adds deterministic resizable
ArrayBuffer/DataViewconstructor races under no-GIL resize pressure, termination storms where main JS throws with parked/unjoinedThreads, exact-counter oracles for scriptWorkers plus simple-import, diamond-shaped, and fanout/rejoin moduleWorkers overlapping shared-realmThreads on one retainedSharedArrayBuffer, script/module Worker/thread/finalization scheduling on one retained SAB, script and module Worker termination interleaved with exact shared-realm finalization cleanup on a retained SAB, Worker termination while top-level failure tears down parked shared-realmThreads, pendingasyncJoinrejection reactions, and already-ready cleanup jobs on the same retained SAB, module Worker termination with the same shared-realm teardown/reaction/cleanup oracle, exact FIFO drain/drop ordering for mixed script and module Workerclose/terminate/postMessagelifecycles, plus worker handler-exception recovery, Worker handler-exception recovery composed with shared-realm Thread finalization cleanup on one retained SAB, module Worker handler-exception recovery composed with the same retained-SAB cleanup oracle,Thread.restrictlifecycle isolation plusThread.restrict-ownedFinalizationRegistrycleanup after owner-thread exit,ThreadLocalroots kept live while no-fnLock.asyncHold()release functions deliver with property and condition waiters parked, followed by exact cleanup, Thread exception identity throughjoin()/asyncJoin()while property and condition waiters are parked, thread-returned typed-arraywaitAsyncpromise assimilation throughjoin()/asyncJoin()while waiters are parked, typed-arraywaitAsyncsettlement interleaved withasyncJoinreactions and exactFinalizationRegistrycleanup delivery,Condition.asyncWaitreacquire delivery interleaved withjoin()/asyncJoin()reactions and exactFinalizationRegistrycleanup delivery, proposal-styleAtomics.Mutex/Atomics.Condition.waitFortoken waiters that take both notify and timeout paths whileasyncJoinobservers and exact cleanup share the same lifecycle window,Atomics.Mutex.lockIfAvailabletoken waiters that take both acquire-after-release and timeout paths with reused tokens in that same cleanup window, teardown termination with pendingasyncJoinrejection reactions and child-owned typed-arraywaitAsynctickets that must be abandoned before the child exits, cross-threadFinalizationRegistrycleanup count/sum oracles, teardown termination while propertywaitAsynctimeout compaction, async condition reacquire, a pendingasyncJoinrejection reaction, and already-readyFinalizationRegistrycleanup jobs share the same realm turn, Worker termination composed with condition async reacquire, pendingasyncJoinrejection cleanup, and exactFinalizationRegistrycleanup, Worker termination composed with child-owned typed-arraywaitAsyncticket abandonment, pendingasyncJoinrejection cleanup, and exactFinalizationRegistrycleanup, module Worker termination composed with the same child-owned typed-arraywaitAsyncticket abandonment, pendingasyncJoinrejection cleanup, and exactFinalizationRegistrycleanup, Worker termination composed withThreadLocalhidden roots, no-fnLock.asyncHold()release-function delivery, parked property/condition waiters, top-level teardown, rejectedasyncJoinobservers, and exactFinalizationRegistrycleanup, cleanup delivery interleaved withjoin()/asyncJoin()and unregister-token suppression, cleanup delivery after parked property/condition waiters resume, deterministicLock.asyncHold()barging where a sync hold legally overtakes a queued no-fn async ticket beforeawaitdelivers its release function, no-fnLock.asyncHold()release-function delivery while property and condition waiters stay parked before exact cleanup after they resume, Promise reaction queue churn from with-fnLock.asyncHold, no-fn release functions, typed-arraywaitAsync,Thread.asyncJoin, and exactFinalizationRegistrycleanup,Lock.asyncHold(fn)throw/release ordering with queued no-fn release grants and exactFinalizationRegistrycleanup, propertyAtomics.waitAsynclate-settlement races where a peer removes timeout tickets from the global table while the owning Thread closes its stack-local microtask queue, with bothjoin()andasyncJoin()observers, creator-ownedSharedArrayBufferandArrayBufferstorage that survives the creating Thread's exit, sibling-thread reads, GC pressure, and post-creator resize/ArrayBuffer.transfer()(also covered by the focused unit witness), child-created SAB/ArrayBuffer storage crossing isolated Worker structured-clone after creator Thread exit plus a sibling script Worker clone/finalization cleanup/transfer observer variant plus a module Worker clone/finalization cleanup/transfer observer variant, andThreadLocalisolation across normal, throwing, nested, and async-joined thread lifecycles, plusThreadLocalvalues registered withFinalizationRegistryacross park/resume/clear/join cleanup lifecycles with exact cleanup count/sum delivery after quiescent collection, plusThreadLocal-only cleanup targets released when top-level failure forcibly terminates their owner threads, plus parent-created childThreads whoseasyncJoin()promises outlive the parent Thread's local microtask queue before child release, nestedThreadLocalroot checks, rerouted async settlement, and exact finalization cleanup after both thread layers exit, plus post-completionThread.asyncJoin()fulfillment and rejection observers settling after blocking joins while property waiters stay parked before exact cleanup, plus child-created SAB/ArrayBuffer storage crossing isolated Worker structured-clone after the creator Thread exits. - CI runs the fuzzer in several modes: default seeded, TSan, high-contention amplified, broad semantic, mid-script GC wait-pump/microtask/property-waitAsync-late-settlement/late-asyncJoin-fulfillment-rejection-cleanup/creator-buffer/nested-asyncJoin/sync-wait-cleanup/sync-wait-burst/asyncHold-release-cleanup/promise/teardown/Worker-SAB/script-module-Worker-thread-finalization/Worker-exception/Worker-close/script-module-Worker-Condition-asyncWait-teardown/script-module-Worker-TLS-asyncHold-teardown/weak-collection, lifecycle, ReleaseSafe, and deterministic-result verification.
Remaining: keep extending the lifecycle profile toward more cross-realm scheduling, richer cleanup/finalization interleavings, more async-grant/ mid-script-GC variants, and additional teardown race variants.
6. CI Gating
Every pull request and push to main runs:
- unit tests,
- GIL-mode PR-249 corpus,
- focused no-GIL thread witness,
- TSan unit gates,
- TSan
parallel_jsunit slice, threadfuzz,- TSan
threadfuzz, - TSan mid-script-GC
threadfuzzsmoke, - TSan lifecycle
threadfuzzsmoke, - amplified
threadfuzz, - broad semantic
threadfuzz, - mid-script GC wait-pump/microtask/property-waitAsync-late-settlement/late-asyncJoin-fulfillment-rejection-cleanup/creator-buffer/nested-asyncJoin/sync-wait-cleanup/sync-wait-burst/asyncHold-release-cleanup/promise/teardown/Worker-SAB/script-module-Worker-thread-finalization/Worker-exception/Worker-close/script-module-Worker-Condition-asyncWait-teardown/script-module-Worker-TLS-asyncHold-teardown/weak-collection
threadfuzz, - lifecycle
threadfuzz, - ReleaseSafe
threadfuzz, - deterministic-result
threadfuzz-verify, - sharded no-GIL PR-249 corpus TSan sweep,
- test262-parallel representative slice.
The no-GIL corpus TSan gate and specialized mid-GC/lifecycle TSan fuzzer smokes hard-block on every reported race. The corpus runs without suppressions; any future program-byte suppression must include a deterministic load-bearing witness and must not cover engine state. Nightly/manual CI additionally runs higher-iteration TSan fuzzer sweeps for the default, mid-script-GC, and lifecycle profiles so sanitizer depth keeps growing without making every PR pay the full runtime.