Status: historical design record. The shared-realm Thread API is now
true-parallel by default under Context.createWith(.{ .enable_threads = true }),
with .gil = true as the serialized fallback. Current status lives in
index.md, production-readiness.md,
limits.md, and the canonical
issue #1.
This file preserves the prerequisite audit that guided the GC/no-GIL work. Some milestone counts and "blocked" wording below refer to the checkpoint where that section was written, not the current shipping state.
Reference design for the end state: Pizlo, "Concurrent JavaScript: It Can Work!" (https://webkit.org/blog/7846/) — but it presupposes a GC, which is the gating prerequisite below.
Phases 1-6 originally shipped a GIL'd shared heap (src/gil.zig): exactly
one thread ran JS at a time, so arena allocation, remaining direct element side
doors, and every existing invariant were safe even before their final
per-structure synchronization existed. Threads interleaved only at the step
checkpoints ((steps & 1023) == 0 in src/interpreter.zig eval and
src/vm.zig execLoop, both calling Gil.yieldIfContended) and release the
lock at every blocking point. Removing the GIL means every one of the
structures the GIL protected needed its own correctness story. The
shape transition map and ordinary named-property helper paths now have that
first story: Shape.transition locks the per-shape transition table, and
Object.property_lock serializes helper-routed shape, slot, accessor,
attribute, and key-order state.
The arena model cannot express cross-thread object lifetimes. Context owns one
arena_state: *std.heap.ArenaAllocator (src/context.zig:22); everything
(values, objects, strings, AST, shapes, environments) lives there until
Context.destroy() frees it en masse (arena_state.deinit()). There is no
per-object reclamation. A shared parallel heap needs objects whose lifetime
spans agents and is reclaimed by tracing — SharedBufferStorage
(src/shared_buffer.zig) carries bytes, not object identity, which is why it
was sufficient for Layer A but cannot back shared objects. No ungil work
should begin before a tracing GC with safepoints replaces the arena. The step
checkpoints above are the natural safepoint sites — they already exist and are
polled in both engines.
Progress: the GC (M1, opt-in) now collects mid-script at those checkpoints,
including while peer threads are parked — conservative native-stack +
register scanning (src/stack_scan.zig) roots the tree-walker's live Value
locals, active VM Exec operand stacks are registered as precise roots, and a
parking thread publishes a conservative scan range that a GIL-holding collector
walks for every parked peer (the multi-thread safepoint protocol; a
safety net aborts collection unless every peer is parked-and-published). See
P7-gc-design.md. The GC can now reclaim at safepoints under
the full threading model with the GIL still held; lifting the GIL itself is M3
(NaN-boxed Value, write barrier, concurrent mark).
Current status: the NaN-boxed 8-byte Value (#7) has landed, shared-realm
Thread runs true-parallel by default, and .gil = true is the explicit
serialized fallback. The historical blocker list below is now mostly a closure
record: object/property/element storage, weak collections, FinalizationRegistry
records, async waiter arrays, the microtask queue, GC roots/barriers, and
thread lifecycle queues have synchronization/tracing paths and are covered by
the issue #1 TSan/fuzzer/corpus gates. Remaining work in this document is
performance and GC maturity: tuning the landed three-age nursery, parallel
mid-script minor coordination, context create/destroy cost, contention
reductions, and broader stress coverage.
| # | Structure | Site | Tear without the GIL | Fix direction |
|---|---|---|---|---|
| 1 | Per-context arena | context.zig:22, alloc via arena() |
ArenaAllocator + backing GPA are not thread-safe; concurrent alloc corrupts free lists |
GC-managed heap, or per-thread nurseries with a shared old space. Partially addressed for the one-mutator + concurrent-marker model: GC scratch (mark_stack/barrier_buf) is on a separate thread-safe aux allocator while cell slabs stay on the (GIL-serialized, mutator-only) backing, so marker and mutator never race on an allocator. Multiple parallel mutators still need thread-safe cell allocation. |
| 2 | Shape transition map | shape.zig transitions: StringHashMapUnmanaged(*Shape) plus per-shape transition_lock, mutated only in Shape.transition() |
Closed for the map itself: Shape.transition locks the table around lookup/allocation/publish, so two mutators adding the same property to one parent shape converge on one child instead of corrupting/diverging. Arena allocation remains covered by #1 until the shared heap/nursery story is complete. |
Keep all transition writes behind Shape.transition; later Layer-C work may swap the lock for a lock-free table only with equivalent convergence tests. |
| 3 | Object shape pointer | value.zig Object.property_lock, shape, setOwnUnlocked, deleteNamedDataOwn; VM property ICs in vm.zig |
Closed for ordinary named properties: Object.setOwn / getOwn / deleteNamedDataOwn and VM plain-property IC reads/writes hold property_lock while reading, publishing, or rebuilding the shape pointer. |
Keep all ordinary named-property shape publication behind property_lock; later Layer-C work may replace this with an atomic shape slot only if it preserves publish ordering and delete/rebuild convergence. |
| 4 | Object slot storage | value.zig Object.property_lock, slots: ArrayListUnmanaged(Value), setOwnUnlocked, deleteNamedDataOwn; VM property ICs in vm.zig |
Closed for ordinary named properties: slot append, same-slot updates, and delete/rebuild compaction through Object helpers and VM plain-property ICs are serialized by property_lock. Dense element storage and direct array/collection element mutations remain separate blockers. |
Keep slot-vector mutation behind property_lock; move any future direct slot side door into Object before removing the GIL. |
| 5 | Object element storage | value.zig Object.elements_lock, elements: ArrayListUnmanaged(Value); dense-array, Map/Set helper, and cursor paths in interpreter.zig |
Closed for shared-realm public paths audited so far: Object now has an element-store lock. Central dense-array get/set/delete/length, array-literal ordinary/hole appends, JSON array serialization length snapshots, JSON parse and Object enumerable/name/symbol result-array construction, VM/interpreter fresh result/argument/template-array appends, RegExp match/indices result arrays, iterator toArray and entry pair arrays, iterator-helper private-record appends, Reflect.ownKeys arrays, destructuring rest arrays, groupBy bucket arrays, Intl supported-values/Locale/format result arrays, packed reverse/sort/splice fast paths, descriptor reflection/definition, own-key / for-in dense enumeration, array destructuring/index-iteration length checks, indexed hasProperty, array object spread, Array-method result appends, Promise combinator result arrays, packed array/call spread, CreateListFromArrayLike, VM spread-call and synthesized default-super argument snapshots, Object.groupBy / Map.groupBy packed-array collection, Intl canonical locale-list reads/appends, DisposableStack resource tuple/list access, non-callback Map/Set helpers, Map/Set forEach per-slot snapshots, native Set helper scans, Map/Set cursors, C-API array construction/indexed reads, structuredClone transfer-list validation, structured-clone serialization/deserialization snapshots, and lock-scoped Map/Set storage mutation use locked helpers/snapshots before reading or mutating element-backed storage. Remaining direct raw storage sites are classified in bindings.md as helper internals, GC stop-the-world/locked paths, lock-scoped collection internals, or iterator-helper private setup/cleanup rather than unguarded shared-realm public fast paths. |
Keep new element storage access behind Object.elements_lock or a documented narrower equivalent, and do not hold it across JS callbacks. |
| 6 | Accessor / attribute maps | value.zig Object.property_lock, setAccessor/setAttr StringHashMapUnmanaged puts, deleteAccessorOwn removes |
Closed for ordinary named properties: accessor and attribute map lookup/mutation/removal through Object helpers is serialized by property_lock. |
Keep accessor/attribute state behind property_lock; add tests for any future descriptor side door before ungil. |
| 7 | Value width | value.zig Value = struct { bits: u64 } — pointer-width NaN-boxed word |
Closed for representation width: a Value slot is one machine word (@sizeOf(Value) == 8), so ordinary slot copies no longer tear across multiple words. Shared mutable containers still need their own synchronization/barriers. |
Keep all Value access through the Value API and the nanbox.zig/valuebox.zig proof tests; do not reintroduce slice/tag payloads into the hot representation. |
| 8 | Strings | value.zig string values point at strcell.StringCell; jsstring.zig atomic retain/release refcount |
Closed for width, synchronization, and GC ownership: runtime string values are one pointer to an immutable StringCell. GC-enabled contexts allocate managed cells, trace string-valued roots/edges, and finalize canonical bytes; explicit arena/static/interned cells carry non-managed classification. Equal runtime strings remain byte-equal rather than requiring pointer identity; the sharded InternTable remains available for deliberate shared-string paths. |
Keep runtime construction routed through strcell's active managed/arena funnel and use the intern table only where canonical identity is explicitly wanted. |
| 9 | Promise settlement and reactions | promise.zig Promise.lock, state, value, on_fulfill, on_reject, MicrotaskQueue.lock; gc.zig tracePromise; interpreter.zig awaitValue |
Closed for per-promise state: resolve/reject/then registration, snapshot reads, async thenable job guards, and GC tracing lock the Promise before reading or moving settlement/reaction state. Promise combinator aggregation records (Promise.all / allSettled / any / keyed variants) now guard remaining, settled, and per-element AlreadyCalled state with Combine.lock, while result arrays use locked element helpers. Microtask-queue content mutation is serialized by the target queue's own lock under parallel_js, so independent spawned-thread queues do not contend on the realm queue while cross-thread asyncJoin/waitAsync settlement still protects enqueue/dequeue. Async waiter arrays remain separate GIL-protected host state. |
Keep all Promise state reads through promise.snapshot/isPending or under Promise.lock; route microtask enqueue/dequeue through enqueue/drainMicrotasks so the queue-local parallel_js lock covers them. Do not hold Combine.lock across capability resolve/reject calls. |
- Keep
Valuepointer-width. Nothing should bypass theValueAPI or assume a multi-word payload; the NaN-boxed representation is now the live engine layout (#7). The C-API still hidesValuebehind an opaqueBoxedpointer (c_api.zig), so the ABI is insulated. - Keep ordinary string equality byte-based. No code should assume
pointer-identity of equal runtime strings;
Valuestrings now useStringCell, but the sharded intern table remains an opt-in shared-string mechanism (#8).JSStringReflifecycle is thread-safe:src/jsstring.ziguses an atomic refcount, so C-API strings can be retained and released from any thread while remaining immutable. - Keep shape transitions funnel-shaped. All transitions go through
Shape.transition(shape.zig), whose per-shape lock is now the synchronization point for the transition table (#2). Do not add side doors that mutatetransitionsdirectly. - Keep object named properties funnel-shaped. Ordinary named property
helper paths now synchronize on
Object.property_lock(value.zig), and the VM's plain-property inline caches take that lock before touchingshapeorslots. Named data/accessor delete and shape+slot rebuild also live behind the same lock. Do not add new direct mutations ofshape,slots,accessors,attrs, orkey_order. - Keep Promise state funnel-shaped. Promise settlement and reaction-list
mutation now synchronize on
Promise.lock(promise.zig), andawaitValueobserves settled promises throughpromise.snapshot. Do not add direct reads ofPromise.stateorPromise.valueoutsidepromise.zig/locked GC tracing. - Keep element storage funnel-shaped.
Object.elements_lockis the synchronization point for indexed storage. Dense-array helper paths, Map/Set helper paths, Set operations, structured-clone element serialization, C-API indexed helpers, and cursor paths already use it, and callback paths must snapshot one slot or one key list before invoking JS. Do not add new directelements.itemsaccess on shared data paths; finish moving internal engine tuples behind helpers. - Keep the safepoint checkpoints as the only interleave points. Both
engines already poll at
(steps & 1023) == 0; a GC needs exactly these as safepoints. Do not add heap mutation paths that can run for an unbounded number of steps without hitting a checkpoint.
Mirror PR-249's phase-2 ladder, already proven for the serialized baseline:
- Per-shape + per-object locks (coarse), GIL still present, prove correctness.
- Drop the GIL; run the vendored corpus + test262 SAB/Atomics under real parallelism with TSan; drive unsuppressed races to zero.
- Serial-perf gate: single-thread throughput must not regress materially.
- Stress amplifiers (transition storms, property-add races, shared-TA atomics storms) flake-free in CI.
This note began as the prerequisite record. The tracing GC (M1/M2),
NaN-boxed Value (#7), and concurrent marking (M3 GC half) have since landed,
and the heap is now parallel-safe and proven (see below). The execution-path
GIL drop is complete for shared-realm Thread: enable_threads is no-GIL by
default, and .gil = true remains the supported serialized fallback.
The GC half of M3 is complete and the heap-mutation foundations for GIL removal are done and validated:
- Thread-safe allocation. GC cell slabs:
Context.GcCellBackingrecycles 16-byte-aligned cell slabs from size-class chunks, keeps chunk ownership bucket-local with an address-span reject and a per-bucket recent-chunk hint for cheaper frees/remaps during collection/teardown, lazily bumps fresh chunk slots with a per-bucket bump hint instead of pre-linking unused cells, skips freelist rebuilds during context bulk teardown, and locks internally under no-GILparallel_gc;zig-gcHeap.setParallelstill serializes all-list prepend + counters + born hand-off underalloc_lock. Arena (shapes, strings, AST, binding tables):Context.LockedArena, installed before the create-time arena is captured, soroot_shape/envandShape.transition(which reuses the root shape's captured allocator) are thread-safe. Small arena allocations refill per-thread bump chunks under the lock, then allocate lock-free from the local chunk; large, highly aligned, and resize/remap operations keep using the serialized fallback. Backing-store accounting counters are atomic. - Per-structure locks (the object model + collections):
Object.property_lock/elements_lock,Shape.transition_lock,Environment.binding_lock,Promise.lock; weak collections use isMarked-based clearing plus unordered tail removal for dead entries, and FinalizationRegistry unregister uses stable one-pass compaction. - Proven: the
parallel_gcbring-up test runs 4 threads creating + shape-transitioning + writing 4,000 disjoint objects with no GIL — intact and TSan-clean.
The Thread API now runs JS in parallel by default. The touchpoints below are
the original execution-path audit plus current maturity notes; treat any
"needs" language in older subsections as historical unless a current issue #1
roadmap item repeats it.
-
evaluate/evaluateModulerealm state. Good news from the audit: the executing state is already per-thread — eachThreadruns its ownInterpreter(ctx.interpreter()inthreadMain) with its ownInterpreter.exception(theContext.exceptionslot is only the host/join hand-off, written at quiescent points), so threads don't clobber each other's throw state. The shared touchpoints that remain:active_interpreters(push/pop + GC-trace iteration on a shared list — needs a lock), the evaluate-topcollectGarbage(stop-the-world; parallel evaluates collide — gate behind the safepoint protocol or a collection lock),gc_execsregistration, and the realm microtask/finalization drains. Parallel mid-script collection. The quiescent model is validated and shipping: parallel threads build a rooted shared graph (contendedelements_lock/property_lock+ insertion barrier), join, then acollectGarbagereclaims the garbage while keeping the live graph intact — TSan-clean (parallel_gc … quiescent collection after a parallel buildincontext.zig). Theh.parallelguard skips the mid-script safepoint collector in this mode, so parallel execution never races a marker.Mid-script parallel collection — the STW barrier was a dead end; the fix is a ragged handshake. A stop-the-world safepoint barrier (peers park at their GC safepoints, collector waits for
par_active == 1) was prototyped and deadlocked: a mutator spinning to acquire a per-structure lock (e.g.Object.property_lock) can't reach its safepoint, while the thread holding that lock had already blocked at the barrier — so the lock is never released and the barrier never completes (asampleshowed exactly this:property_lockheld-but-unowned while the collector spun). The defect is fundamental to any protocol that blocks a mutator where it may transitively hold a lock. The replacement is now built: a ragged (non-blocking) root-publication handshake (src/root_handshake.zig) — the collector requests roots, and each mutator publishes its own roots at its safepoint (between bytecodes, where it provably holds no per-structure lock) and keeps running, so no mutator ever blocks and can always progress to release a lock. Only the collector waits, on a monotonic ack counter. The primitive is standalone + tested (a test reproduces the exact lock-contention hazard the STW barrier deadlocked on), TSan-clean. Wiring it into a concurrent parallel marker (collector marks while mutators run behind the insertion barrier; a second handshake re-scans roots at finish; sweep with born-black allocation) is the next step — the parked-thread rooting machinery (active_interpreters+gc_execs+ park records) is already in place.GC-side primitives now built (the M3 parallel marker's bricks). The zig-gc collector runs concurrently with parallel mutators: atomic
marking/concurrentflags, atomic born-grey mark-bit init, whiten-under-alloc_lock(beginConcurrentMarkParallel), and sweep-under-alloc_lock, all TSan-validated by stress tests (collector marks+sweeps while 4 mutators allocate, no GIL, plus a stale-barrier regression that proves abort cannot clearbarrier_bufwhile a delayed mutator appends after the fact). On the engine side,gc.publishInterpreterRoots(machine)is the per-mutator side of the handshake: it routes a running interpreter's precise roots (traceInterpreterRoots) through the insertion barrier intobarrier_buf(the only mutator→marker channel that is safe off the collector thread), so a peer publishes its own roots at a safepoint without the collector ever reading its live VM stack.Abort-safe driver — IMPLEMENTED (
parallel_midscript_gc), validated TSan-clean.collectMidScript'sh.parallelbranch is now a best-effort collector that is sound by construction. The end-to-end test ("parallel_js (M3): mid-script parallel collector reclaims garbage while threads run") runs 3 realThreads building + retaining graphs with no GIL while a collector among them marks+sweeps: every retained graph survives every collection, the run completes (no deadlock), and a parallel collection finishes — 10/10 non-TSan and ThreadSanitizer-clean (zero data races). How it works:- Election. A thread at its safepoint CAS-claims a single collector slot;
losers
publishInterpreterRoots(self)and return. No mutator ever blocks for the collector, so no mutator can be stuck holding a per-structure lock (the original STW deadlock). - Begin.
beginConcurrentMarkParallelwhitens + arms the barrier underalloc_lock, then traces only the collector-safe roots: realm-level state, the collector's own interpreter, and parked peers' frozen stacks (park records). Running peers'active_interpretersare skipped — they self-publish. The realm-root parallel-safety audit is done:traceRootsreads the realm microtask queue under its queue-local lock,js_threadsunder the Gilapi_lock, andasync_waiters/c_api_handles/finalization_cleanup_jobsunder a newContext.realm_lock(all taken by their mutators only underparallel_js);ctx.exception(redundant with each interpreter's own) is skipped. The conservative native-stack scan was made race-free too:markConservativeWordclaims via the atomicclaimMark, andbuildAddrIndexsnapshots the all-list underalloc_lock. - Publication. Running peers publish precise roots
(
publishInterpreterRoots) at their safepoint; a peer blocked in the native wait insidejoinis flaggedgc_parkedand traced directly (frozen). The flag is cleared while the joiner pumps tasks between waits, because that interpreter is moving again and must publish at ordinary safepoints. The redundant park-record conservative scan is skipped under a parallel collection (it raced the joiner'sbeginPark/endParkand is covered precisely bygc_parkedduring the actual park). Peers blocked on propertyAtomics.wait,Condition.wait, or contendedLockacquisition underparallel_jsare deliberately not flaggedgc_parked: those loops wake every ~5 ms topumpTasksand can run JS/microtasks before re-parking. Instead, their lock-free pump points service the same root-publication hook as ordinary bytecode safepoints, before re-entering the bounded native park. The collector's per-generation wait now has a short time floor so those peers get at least one wake/publish opportunity under load. The root set also includes host-side thread queues that can hide JS values from ordinary object tracing:Gil.tasks,LockRecord.pending, async condition waiters, typed-arraywaitAsyncwaiter/reaction roots, pendingThread.asyncJoinpromise/reaction roots, ThreadLocal maps, thread completion results, and release-function lock records are traced or insertion-barriered when populated. ContendedLock.holdreceiver/callback pairs are temp-rooted while native acquisition parks. The mid-script GC fuzzer now leaves completed but unjoined childThreadresult and thrown exception objects in native completion records across the allocation-pressure window and verifies thatjoin()receives both intact. It also keeps a typed-arraywaitAsyncpromise/reaction graph reachable only through the native waiter queue until notification, pendingThread.asyncJoinfulfillment/rejection reactions reachable only through native completion records until the child threads are released, a sibling promise-publication case where a child-returned typed-arraywaitAsyncpromise, a child-returned rejected promise, a child-returned user thenable, and a child-thrown object remain rooted through completion/native waiter state until post-sweepjoin()/asyncJoin()fulfillment, rejection, thenable assimilation, and thrown-object publication, a sibling sync-wait cleanup case where propertyAtomics.wait,Condition.wait, and contendedLock.holdpeers stay parked through a finishing sweep before their stack roots and exactFinalizationRegistrycleanup count/sum are verified, a sibling sync-wait burst case where multiple same-property, same-Condition, and same-Lockwaiters stay parked through a finishing sweep before burst release and exact finalization cleanup are verified, a siblingAtomics.Mutex.lockIfAvailablecase where acquire-after-release waiters stay parked behind a holder through a finishing sweep, timeout waiters may expire independently while those acquire peers remain rooted, and reused-token acquire/timeout results plus exact finalization cleanup are verified, a sibling staticAtomics.Condition.waitcase where notify/reacquire token waiters stay parked through a finishing sweep before exact notify counts, token reacquisition,asyncJoinobservers, and finalization cleanup are verified, and a sibling teardown case where parked children hold child-owned typed-arraywaitAsynctickets through a finishing mid-script sweep before parent failure terminates them. TheThread.join()park path now clears itsgc_parkedpublication and rebalances the completion mutex on termination/error unwinds, and it only publishesgc_parkedfor the actual native condition wait rather than join-time task pumping, so a failed or active join cannot leave stale or moving frozen-peer state. Requested shell/host GC leaves an elected mid-script parallel collector alone while threads are live; a later quiescent collection aborts stale parallel mark state before starting a fresh precise mark. The regression case from the earlier naive approach (marking sync waits as frozen and sweeping the host's captured-env vars) stays guarded byparallel_js (M3): sync wait peers publish roots for mid-script parallel GC, which exercises property waits,Condition.wait, and contendedLockacquisition while requiring a finishing mid-script sweep. The mid-GC sync-wait fuzzer also compacts expired propertywaitAsynctickets while those peers stay parked, keeps one live propertywaitAsyncticket rooted through the sweep until notification, and now parks multiple same-primitive property/condition/lock waiters through a burst release cleanup oracle. Non-converging cycles still abort safely and fall back to quiescent collection. - Terminate or abort. The collector drives root-publication generations,
marking between, until a born-cell-stable, nothing-deferred quiescent
window, then attempts
finishConcurrentMarkParallel. That fold-traces the born cells (catching their un-barriered creation-time references — e.g. anEnvironment.parent— which is why a naive finish swept live parents), drains to closure, and sweeps only if no peer allocated during the finish (born_concurrentstill empty underalloc_lock); otherwise it returns false and the driver aborts, freeing nothing. An aborted mark can never use-after-free; the next quiescentcollectGarbagereclaims the garbage. After an abort-safe fallback, the next safepoints observe a tiny internal retry cooldown rather than immediately electing another expensive doomed attempt under the same sustained allocation burst. So the driver collects when it catches a cheap quiescent window and falls back to quiescent collection otherwise, never trading correctness for pause-time. - Convergence telemetry.
zig build midgc-profileexercises the internal policy and attributes attempts, sweeps, publication-timeout and round-limit aborts, deferred-cell blocked aborts, generations, total and worst-generation failed publication polls, total and worst-attempt finish retries, born-cell-growth rounds, born-growth extension rounds, deferred-work rounds, running and parked peer observations, actual peer publications, post-abort retry backoff skips, and collector-side total/maximum pause. The pause is local to the collector mutator; peers are not stopped. Focused tests enforce the counter accounting identities and execute both publication and direct parked-root paths. These counters andparallel_midscript_gcremain testing/profile implementation details, not stable embedder API.
Quiescent collection under parallel mutation is already correct and shipping, so this driver is purely a pause-time optimization; it is not on the GIL-drop correctness path.
- Election. A thread at its safepoint CAS-claims a single collector slot;
losers
-
Thread-API shared state in
Gil(tasks,prop_waiters,prop_async,next_thread_id,park_records) — the old "mutated/read only under the GIL" model is being split into dedicated locks. Spawn done: the spawn critical section (live-cap check + id allocation +js_threads.append+ OS spawn) is now one atomic unit underGil.api_lock(lockApi/unlockApi), independent of the GIL — two concurrentThreadconstructions can't both pass the cap or claim the same id. Property waiters done:Gil.prop_mutexnow guardsprop_waiters/prop_async; sync wait parks on that mutex, and notify/timeout collect async tickets under the mutex but settle promises after releasing it. Run-loop tasks done:Gil.tasksenqueue/dequeue usesGil.api_lock, and grant delivery runs outside that lock. Condition done:CondRecord.mutexguards the FIFO sync/async waiter queue, and sync waits park onCondRecord.condwithout using the context GIL as the queue mutex. -
Lock-free READ paths vs concurrent writes — the central hot-path decision. Bring-up tests (
parallel_gc) prove writer-vs-writer is safe: 4 threads concurrently appending to a shared array (elements_lock) and adding distinct properties to a shared object (property_lock+Shape.transition) lose nothing and are TSan-clean — the per-structure write locks serialize mutators correctly. Reader-vs-writer is now also handled (RESOLVED).Object.getOwnalready read underproperty_lock;Environment.get/isConst/isFnName/isAliasnow read each scope's binding tables underbinding_lock(the writersput/assign/putAlias/putConst/putFnNamelock the matching tables), so a reader can't tear against or read a freed table from a concurrent rehash. Binding locks now start enabled process-wide: concurrent context creation proved that a false→true process flag can be observed differently by a lock helper and its paired unlock helper during create-time global setup. The remaining parallel/concurrent safety protocols are still enabled as a unit for contexts that need them. Validated by a concurrent reader+writer test on a shared global env, TSan-clean. (A seqlock/RCU or per-call acquisition-token read path remains a possible future optimization if the lock proves a parallel-throughput bottleneck, but it is no longer a correctness blocker.) Residual instance found + fixed (the "StringHashMap-grow panic" in the semantics batch). The blocker-#3 audit had coveredgetOwn/binding reads but missed two map-content iterations that walked the liveObject.accessorsStringHashMapunlocked whileseal/freezeran:lockKeysand theisFrozen/isSealedintegrity check (builtins.zig). Underparallel_jstwo threads sealing/freezing the same shared object (frozen-seal-race.js) could grow (reallocate) that map on one thread while the other iterated it — the rare grow-corruption panic. Fixed by iterating aproperty_lock-held snapshot of the accessor keys (Object.accessorKeysSnapshot) instead of the live map; behavior-identical (same keys, same per-keygetAttr/setAttr), GIL path unchanged. Validated: unit suite unchanged vs. baseline,frozen-seal-race.js/proto-cycle-race.js/private-fields-shared.jsPASS underparallel_js, freeze/seal/isFrozen-with-accessors conformance spot-check green. Lesson for the remaining audit: every unlockedo.accessors/o.attrscontent read (.?.get/|m| iterate) is a grow-race site, distinct from the benign unlocked pointer-== nullfast-path guards; the pointer guards that gate into a locked accessor are fine, but a guard that then reads the map inline is not. -
Other shared globals — symbol registry done: the cross-realm GlobalSymbolRegistry get-or-create (
Symbol.for+ lazy registry creation) is now atomic underGil.symbol_registry_lock(the keyToStringis computed before the lock, so a usertoStringcan't reenter it); a test drives the racing critical section, TSan-clean. VM inline caches done: the plain-property inline caches (chunk.ics) — shared mutable(shape, slot)written from the hotget_prop/set_proppath, where two threads racing the same instruction over different objects could tear the pair into a stable inconsistency — are now a seqlock (InlineCache.lookupSlot/record: a version-bracketed read + a try-claim write, best-effort so a writer that can't claim just skips caching). Gated bybytecode.ic_seqlock_enabled(set withbinding_locks_enabledfor the parallel/concurrent contexts); the default GIL-serialized path keeps plain field access (one extra relaxed flag load), behavior-identical. Validated by an isolation test (two writers, distinct shapes, one cache) and an integrated test (4 threads run one shared chunk over one shared object, no GIL — never-written reads never tear), both TSan-clean. Remaining: realm-level caches (Date/regex) and the string story (arena slices today; a shared intern table would need the shardedstrcell.InternTable) — to be surfaced empirically by the GIL-free bring-up. -
Corpus semantics. The threads corpus partly pins GIL-serialized behavior (deterministic interleavings, run-loop grant ordering). Dropping the GIL changes the model, so the campaign must re-derive which corpus expectations are synchronization-correctness (must still hold under true parallelism — Lock/ Condition/Atomics) vs. GIL-specific ordering (may legitimately change), and drive real races to zero under TSan with the whole corpus running in parallel.
-
Gates: whole-corpus TSan campaign to zero unsuppressed races; serial-perf gate (single-thread throughput must not regress); stress amplifiers.
Validated so far (parallel_gc / parallel_js bring-up tests): parallel heap
mutation; parallel parse+compile+VM execution of disjoint scripts; contended
parallel append to a shared array; contended parallel property-add + shape
growth on a shared object; quiescent collection after a parallel build (live
graph kept, garbage reclaimed); atomic GlobalSymbolRegistry get-or-create under
contention; seqlock inline caches (isolation: two writers, distinct shapes,
one cache); and one shared compiled chunk run over one shared object on 4
threads with no GIL (shared ICs + property_lock + binding_lock, never-written
reads never tear). The first production-Thread vertical slice is also in place:
test-only Context.TestingOptions.parallel_js drops the execution-path GIL while
real shared-realm Thread workers contend the shipped Atomics.Mutex /
LockRecord sync path and the Lock.asyncHold grant-delivery path; the focused
parallel_js tests are TSan-clean. Plus the standalone ragged root-publication
handshake primitive.
These cover allocation, the object/shape model, the writer-vs-writer and
reader-vs-writer locks, the inline caches, quiescent GC, the named shared-global
prerequisites (symbol registry + spawn bookkeeping), one real production sync
primitive under the Thread entrypoint, and async lock-grant delivery. What
remains: wire the root handshake into a concurrent parallel marker (mid-script
collection #1); move the property waiter tables and condition-variable waiter
state onto their own locks (#2); broaden parallel_js beyond the mutex/async
lock-grant slice; then run the full corpus campaign (#5).
This is the final, largest step — major surgery on the core execution path plus a semantics campaign, to be done as a focused effort (not a mechanical flip), now that every heap prerequisite is in place.
The heap and bytecode-execution prerequisites are validated under real parallel
GIL-free bytecode (objects/shapes/elements/env/arena/GC-alloc/promise/
inline-caches/symbol-registry/lazy-prototypes; strings are uninterned so there is
no shared intern-table race). The remaining blocker for true parallel JS via the
Thread API is finishing the coordination primitives. The first slice has
landed: LockRecord (Atomics.Mutex / Lock) now has a per-record
std.Io.Mutex, and sync acquireLock/releaseLock/unlock-token paths guard
locked, holder, sync_waiting, and sync_generation with that mutex. A
test-only parallel_js context drops the execution-path GIL and proves real
Thread workers can contend one production Atomics.Mutex without races.
The next slice also landed: async Lock.asyncHold grant state (pending,
grant_pending, active_release, async_runner) uses that same per-record
mutex, and the realm task queue is checked/enqueued/dequeued under Gil.api_lock
so task delivery is TSan-clean with no context GIL.
src/jsthread.zig coordination state migrated in the focused parallel_js
campaign:
LockRecordsync and async-grant state usesLockRecord.mutex.Conditionsync/async waiter FIFO state usesCondRecord.mutex; waiters register under the condition mutex before releasing the associatedLock, sonotifycannot miss the release+park transition.- property-mode
Atomics.wait/notifyuseGil.prop_waiters/Gil.prop_asyncguarded byGil.prop_mutex; wait and waitAsync revalidate the property value under that mutex immediately before enqueueing, so a racing store+notify cannot strand a waiter after the value already differs. - named property-mode Atomics load/store/exchange/compare-exchange/RMW hold
Object.property_lockfor the whole property step, so no-GIL RMW counters no longer lose updates. - typed-array
Atomics.waitonly releases/reacquires the context GIL in the shipped GIL mode;parallel_jsparks directly on the agent waiter table.
The remaining work is no longer a single coordination queue; it is broadening the GIL-free execution campaign across the full PR-249 corpus and continuing to close object/heap/shape/promise mutation paths called out in the blocker map.
Target design (per-waitable mutex+condvar). Give each waitable its own real
std.Io.Mutex:
LockRecord: addmutex: std.Io.Mutex.acquireLocklocksrec.mutex, testslocked/generation, andrec.cond.wait(&rec.mutex)on contention;releaseLocklocksrec.mutex, hands off / signalsrec.cond. The state fields move from "GIL-protected" to "rec.mutex-protected". The async hold-job machinery (HoldJob,pending, grant delivery,enqueueHoldJob/pumpTasks) re-expresses "deliver a grant" as arec.mutex-guarded transition plus the existingapi_lock-guarded run-loop queue. The queue now carries an atomic count so ordinary sync waiters skipapi_lockwhen no hold job is pending.Condition: the condition record has its own queue mutex. A waiter registers in the FIFO while holdingCondRecord.mutex, releases the associatedLockunder that mutex, then parks onCondRecord.cond;notifypops, marks, and broadcasts under the same mutex, and async waiters are handed to the already migratedLock.asyncHoldgrant path.- Property
Atomics.wait/notify: the per-realmGil.prop_mutexnow guardsprop_waiters/prop_async;propNotifycollects matching async tickets under the table mutex and settles them after releasing it (settling runs JS → per-structure locks, lock order: table-mutex must not be held across JS). - Termination/abandon (
teardown_stop, workerterminate, D9 trap-on-parked): each park loop keeps pollingstop_flagbetween waits, as today.
The GIL becomes the "bytecode runs without it" lock. It stays as the home of
the threading bookkeeping (api_lock, symbol_registry_lock, lazy_init_lock,
park_records) and is acquired only by the specific shared-bookkeeping operations
— never held across bytecode. threadMain/evaluate stop wrapping execution in
g.acquire()/g.release(); the per-structure locks (already validated) carry
correctness during execution.
Corpus-semantics caveat. The threads corpus partly pins GIL-serialized
interleavings and run-loop grant ordering (docs/threads/api.md, the cve/mc-*
and lock//condition/ cases). Dropping the GIL changes the model, so this is a
re-derivation: classify each expectation as synchronization-correctness (must hold
under true parallelism) vs. GIL-specific ordering (may legitimately change), adjust
the corpus/notes, and drive races to zero under a whole-corpus TSan run. This is
why it is a campaign, not a flip.
Sequencing. (1) Per-record mutex on LockRecord + sync
acquireLock/releaseLock/unlock-token paths off the GIL, behind gated
parallel_js: landed and TSan-clean for the focused real-Thread
Atomics.Mutex test. (2) Hold-jobs / Lock.asyncHold off the GIL: landed and
TSan-clean for the focused real-Thread async-grant test. (3) Property
Atomics.wait/notify waiter-table mutex: landed and TSan-clean for the
focused real-Thread property-waiter test. (4) Condition waiter queue
mutex: landed and TSan-clean for the focused real-Thread condition-waiter
test. (5) Broaden the execution-path GIL drop in threadMain/evaluate under
parallel_js; the corpus runner now has -Dthreads-parallel-js=true so PR-249
files can be probed under the same GIL-free mode instead of only via unit
witnesses. The first full-allowlist probe is intentionally not a gate yet:
smoke.js, api/condition-async-wait.js, and the lifecycle join/exception
cluster now pass under parallel_js; so do the promoted ordinary-array
mutation probes arrays/push-resize-multithread.js and
arrays/shared-element-read-write.js. The broad promoted-allowlist
parallel_js probe now carries one explicit budget skip:
cve/mc-df-segmented-length.js, which is green in the normal GIL mode but is
still too slow under no-GIL dense-array shrink/regrow contention. Targeted
-Dthreads-case=cve/mc-df-segmented-length.js remains the repro for that
frontier. The CVE tail now gets past resizable ArrayBuffer resize churn and SAB
retain-list churn under parallel_js; cve/mc-lock-cow-materialize-race.js
and cve/mc-val-llint-cache-storm.js are also focused-green after test-harness
repairs that preserve their real oracles while removing no-GIL shutdown/budget
artifacts. gc-stress/havebadtime-vs-indexed-fastpath.js is also focused-green
under parallel_js after two changes: prototype-chain indexed-store guards now
consult a conservative per-object "indexed own ever seen" marker instead of a
per-key own-property lookup on every hot indexed store, and the no-GIL run uses a
smaller stress budget while keeping the same bad-time flip and trap-index
oracles as the full GIL-mode file. The promoted JIT-audit subset is now
focused-green under parallel_js through constructor/fire benchmarks, tailcall
argument preservation, OSR/catch-loop locals, golden-disasm workload execution,
int-gate smoke files, shared-ArrayStorage stress, spawned-thread butterfly
stress, tag-discipline, and TID-tag witnesses. The normal GIL-mode JIT files
keep their original stress sizes; parallel_js trims the tailcall,
OSR/catch-loop, golden-disasm, stop-budget, shared-ArrayStorage,
spawned-thread butterfly, tag-discipline, and TID-tag loop counts so those
files remain correctness witnesses rather than serial-performance gates.
races/counter-atomics.js is also
focused-green under parallel_js; the GIL-mode file keeps its original
100,000-add/1,000-CAS amplifier, while no-GIL keeps the same 8-worker
lost-update/CAS oracle at a smaller interpreter budget. races/counter-lock.js
uses the same split for contended Lock.hold, and the promoted race block now
passes under parallel_js through races/wait-notify-storm.js. The promoted
heap block is also focused-green under parallel_js, from
heap-access-blocking.js through heap-stop-interleavings.js. The promoted
invariants block is focused-green 7/7, and the promoted objectmodel block is
focused-green in verified slices through
objectmodel/i08-named-vs-indexed-first-install.js. The heavy objectmodel
stress files (i03-stale-spine-reader-vs-grow.js,
i03-stress-force-segmented.js, i03-stress-force-sw.js,
i03-t1-vs-sw-flip.js, i03-t5-racing-growers.js,
i03-visit-range-outofline.js, i08-named-vs-indexed-first-install.js) now
carry no-GIL budgets: GIL mode keeps the full round/element/churn amplifiers
while parallel_js runs the same I21/I25/I27/I33/AB18-S3 race oracles at
smaller budgets, so the broad probe is no longer throttled by the objectmodel
block's serial-performance amplifiers. The full promoted
semantics block is focused-green 15/15 under parallel_js; the IC transition
files keep their original GIL-mode pass counts while no-GIL uses smaller
watchdog-safe pass budgets, and the remaining heavy semantics files
(atom-rope-torture.js, date-cache-churn.js, frozen-seal-race.js,
private-fields-shared.js, proto-cycle-race.js, regexp-lastindex-shared.js,
symbol-registry-cross-thread.js) now carry no-GIL budgets as well — GIL mode
keeps the full amplifiers while parallel_js runs the same oracles at smaller
budgets. The full promoted scaling block is also
focused-green 6/6 under parallel_js; scaling/raytrace-like.js and
scaling/richards-like.js keep their normal corpus/gate workloads while using
smaller no-GIL standalone budgets. The VM-state block is focused-green 10/10
under parallel_js; the four ROUNDS-amplified churn files
(exception-state-per-thread.js, regexp-churn-threads.js,
structure-churn-dictionary.js, structure-churn-threads.js) now carry no-GIL
round budgets while keeping the full GIL-mode amplifiers and the same per-round
oracles (the fixed-digest runVMStateWorkload files are left intact). The
promoted CVE tail after
cve/mc-int-resizable-tail-quarantine.js is focused-green in slices through
cve/mc-wait-property-wait-lost-wakeup.js, including the async-generator
resume-head claim case cve/mc-prim-async-generator-resume-claim.js; that
case is also TSan-clean as a focused threads-test probe. The sync generator
resume-claim case cve/mc-prim-generator-resume-claim.js is focused-green and
TSan-clean under parallel_js after the sync generator resume path gained a
per-generator claim mutex. The thread teardown/settlement tail is focused-green
through cve/mc-tdwn-vm-teardown-unjoined.js; pending asyncJoin queue
rebasing now takes the thread registry API lock and each target record's join
mutex before rewriting queue pointers, so it cannot race nested Thread
creation or completion publication. The GC-stress block is focused-green 4/4,
the promoted Atomics block is focused-green 15/15, and the promoted JIT-audit
subset is focused-green in verified slices through jit/tid-tag-3-threads.js.
The bench checksum files also keep their normal serial-performance protocol
counts while parallel_js uses capped harness and inner-loop counts so the
broad probe remains a correctness witness. Broader promoted-allowlist
parallel_js is still exploratory, but the cumulative budget probe is now
cleared: a single zig build threads-test -Dthreads-parallel-js=true runs
the whole 209-file allowlist to completion within a 32-minute cap (208 PASS /
1 FAIL) — the objectmodel/semantics/vmstate no-GIL budgets removed the last
cumulative-budget walls, so the run reaches the final
vmstate/vmlite-single-thread-identity.js PASS line instead of timing out. The
single failure was a non-deterministic api/blocking-gate.js: async completions not reached (~1/27, no-GIL only, TSan-clean), now fixed — and it was a
GIL-specific corpus assumption, not an engine bug. The test asserts
shouldThrow(() => inner.join()) / shouldThrow(() => t.join()), expecting the
joinee to still be Running so the can-block-is-false gate makes join()
throw. Under the GIL that is guaranteed (the joining thread holds the lock, so
the target cannot run); under parallel_js the target runs concurrently and
~1/27 finishes first, so join() takes the allowed finished-thread fast path,
returns, doesn't throw, and the shouldThrow failure cascades into a rejected
asyncJoin chain → completion #3 never fires. Pinned with non-perturbing
engine-side per-path settlement counters (the heisenbug suppressed every JS-side
probe): on failure reg=1 not 2, and the throwing thread's captured message was
"expected an exception but none was thrown" — the inner.join() assertion.
Fix (test(threads): re-derive blocking-gate's GIL-specific join-gating assertion): assertJoinGated() keeps the strict throw under the GIL and accepts
either outcome under no-GIL (gated TypeError if Running, or the allowed fast
path if finished). No wait primitive (every blocking wait is itself gated under
can-block-is-false) and no busy-spin (which starves the GIL-free runner).
Verified GIL PASS + parallel_js 0/250.
Two genuine no-GIL correctness fixes were made along the way (both correct, both
406/406 and regression-free, though neither was this flake's cause): the
cross-thread microtask-queue data race is now serialized by
queue-local microtask locks (TSan-validated), and asyncJoin settlement reactions
whose joiner is a spawned thread are routed to the realm queue rather than the
joiner's abandoned local queue (PendingJoin records the joiner; exiting threads
flush their residual queue). With the budget-cleared corpus and these fixes, the
promoted parallel_js allowlist now runs clean. (6)
The contention profiler keeps both the original condition asyncWait stress row
whose notifier busy-spins on the ready counter and a condition asyncWait parked
control row whose notifier parks with property Atomics.wait; compare both
before attributing an async-condition change to task delivery versus scheduler
interference. The condition asyncWait multi-lock row splits the same condition
delivery through several lock groups, and the profiler now prints exact
shared-realm scenario filters from the scenario table so this control row stays
discoverable. The profiler also has focused promise microtasks,
promise reactions, and promise thenables cases for issue #15: they keep the
default table narrow, but record Promise microtask enqueue/pop/run totals and
split reaction jobs from thenable-assimilation jobs under no-GIL versus
.gil = true. Their timing columns come from an
uninstrumented warmed pass; the counter columns come from a separate counted
pass in the same warmed context, so profiler atomics do not masquerade as
runtime contention. The focused columns split microtask-queue lock traffic
(qlock/qyld), per-Promise state-lock traffic (plock/pyld), thread-safe
arena traffic (aacq/acnt/aspn, acquisitions / contended acquisitions /
failed spin attempts; after chunking, acquisitions are chunk refills or
fallback operations rather than every tiny arena allocation), and transient Promise allocation sources (rpair
resolving-function pairs, cap NewPromiseCapability executors, pnew
Promise creations, pcell Promise state cells, pobj Promise wrapper objects,
rfn resolving-function objects, rgr reaction-list growth, qgr
microtask-queue growth, and bgr drain-batch growth). A third gil+gc timing
column keeps the serialized path on GC-managed cells, which separates
GC/cell-management overhead from queue-lock and parallel scheduling overhead.
That profile led to one contention reduction: no-GIL interpreters now lock the
current MicrotaskQueue rather than a realm-wide lock, so independent
spawned-thread Promise drains no longer serialize on the host queue.
The drain path also moves the currently pending burst into an interpreter-local
batch under one queue lock before running jobs unlocked; GC traces that active
batch separately from the queue.
Intrinsic .then reactions now store the result promise directly in the
reaction record instead of allocating native resolve/reject capability closures
per dependent promise; custom species capabilities still use the function-based
path. The focused Promise profile tracks this through lower LockedArena
traffic and unchanged reaction/thenable job counts.
Intrinsic Promise.resolve for primitive values and intrinsic Promise.reject
now allocate already-settled native promises directly, preserving subclass and
thenable paths while avoiding the general settlement machinery for cases with no
user-observable resolver or thenable work.
What now gates the GIL drop is the
whole-corpus TSan campaign +
serial-perf gate. The two named remaining rare no-GIL races are now both
closed: the blocking-gate drain flake (re-derived its GIL-specific assertion),
and the StringHashMap-grow panic in the semantics batch — root-caused to
unlocked map-content reads of Object.accessors in seal/freeze
(lockKeys / the integrity check) and in the index-keyed property-mode
Atomics accessor check (isAccessor), all now reading via a
property_lock-held snapshot / the locked getAccessor. What remains is to run
the whole corpus under TSan to surface any un-named residual races and clear
the serial-perf gate. -Dtsan is now wired through to the corpus
binary (zig build threads-test -Dtsan=true builds the corpus and the engine it
links under ThreadSanitizer, via a dedicated TSan-instrumented js module;
default-off so other targets are byte-identical). With the pinned toolchain
0.17.0-dev.956+2dca73595 this builds on macOS too, so TSan is back in the local
dev loop; CI runs it on Linux as the shared gate. (The older dev.131 pin failed
the TSan runtime sub-compilation on darwin — bundled libcxx undeclared identifier 'INFINITY' — making it Linux-CI-only.) Mid-script concurrent-parallel GC (the ragged
root_handshake → concurrent marker) is independent of this and is a GC
pause-time optimization, not on this critical path (quiescent collection is
already correct under parallel mutation).