Skip to content

Latest commit

 

History

History
722 lines (683 loc) · 54.8 KB

File metadata and controls

722 lines (683 loc) · 54.8 KB

Phase 7: GIL Removal — Historical Prerequisites Audit

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.

Historical Serialized Baseline

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 gating prerequisite: a tracing GC

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.

Historical blocker map

# 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.

Design inputs to lock in now (so earlier phases don't foreclose them)

  • Keep Value pointer-width. Nothing should bypass the Value API or assume a multi-word payload; the NaN-boxed representation is now the live engine layout (#7). The C-API still hides Value behind an opaque Boxed pointer (c_api.zig), so the ABI is insulated.
  • Keep ordinary string equality byte-based. No code should assume pointer-identity of equal runtime strings; Value strings now use StringCell, but the sharded intern table remains an opt-in shared-string mechanism (#8). JSStringRef lifecycle is thread-safe: src/jsstring.zig uses 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 mutate transitions directly.
  • 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 touching shape or slots. Named data/accessor delete and shape+slot rebuild also live behind the same lock. Do not add new direct mutations of shape, slots, accessors, attrs, or key_order.
  • Keep Promise state funnel-shaped. Promise settlement and reaction-list mutation now synchronize on Promise.lock (promise.zig), and awaitValue observes settled promises through promise.snapshot. Do not add direct reads of Promise.state or Promise.value outside promise.zig/locked GC tracing.
  • Keep element storage funnel-shaped. Object.elements_lock is 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 direct elements.items access 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.

Bring-up ladder (completed, retained as history)

Mirror PR-249's phase-2 ladder, already proven for the serialized baseline:

  1. Per-shape + per-object locks (coarse), GIL still present, prove correctness.
  2. Drop the GIL; run the vendored corpus + test262 SAB/Atomics under real parallelism with TSan; drive unsuppressed races to zero.
  3. Serial-perf gate: single-thread throughput must not regress materially.
  4. Stress amplifiers (transition storms, property-add races, shared-TA atomics storms) flake-free in CI.

Action

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.

Execution-path GIL removal — completed campaign and maturity notes

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.GcCellBacking recycles 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-GIL parallel_gc; zig-gc Heap.setParallel still serializes all-list prepend + counters + born hand-off under alloc_lock. Arena (shapes, strings, AST, binding tables): Context.LockedArena, installed before the create-time arena is captured, so root_shape/env and Shape.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_gc bring-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.

  1. evaluate / evaluateModule realm state. Good news from the audit: the executing state is already per-thread — each Thread runs its own Interpreter (ctx.interpreter() in threadMain) with its own Interpreter.exception (the Context.exception slot 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-top collectGarbage (stop-the-world; parallel evaluates collide — gate behind the safepoint protocol or a collection lock), gc_execs registration, and the realm microtask/finalization drains. Parallel mid-script collection. The quiescent model is validated and shipping: parallel threads build a rooted shared graph (contended elements_lock/property_lock + insertion barrier), join, then a collectGarbage reclaims the garbage while keeping the live graph intact — TSan-clean (parallel_gc … quiescent collection after a parallel build in context.zig). The h.parallel guard 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 (a sample showed exactly this: property_lock held-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/concurrent flags, 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 clear barrier_buf while 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 into barrier_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's h.parallel branch 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 real Threads 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. beginConcurrentMarkParallel whitens + arms the barrier under alloc_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_interpreters are skipped — they self-publish. The realm-root parallel-safety audit is done: traceRoots reads the realm microtask queue under its queue-local lock, js_threads under the Gil api_lock, and async_waiters/c_api_handles/ finalization_cleanup_jobs under a new Context.realm_lock (all taken by their mutators only under parallel_js); ctx.exception (redundant with each interpreter's own) is skipped. The conservative native-stack scan was made race-free too: markConservativeWord claims via the atomic claimMark, and buildAddrIndex snapshots the all-list under alloc_lock.
    • Publication. Running peers publish precise roots (publishInterpreterRoots) at their safepoint; a peer blocked in the native wait inside join is flagged gc_parked and 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's beginPark/endPark and is covered precisely by gc_parked during the actual park). Peers blocked on property Atomics.wait, Condition.wait, or contended Lock acquisition under parallel_js are deliberately not flagged gc_parked: those loops wake every ~5 ms to pumpTasks and 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-array waitAsync waiter/reaction roots, pending Thread.asyncJoin promise/reaction roots, ThreadLocal maps, thread completion results, and release-function lock records are traced or insertion-barriered when populated. Contended Lock.hold receiver/callback pairs are temp-rooted while native acquisition parks. The mid-script GC fuzzer now leaves completed but unjoined child Thread result and thrown exception objects in native completion records across the allocation-pressure window and verifies that join() receives both intact. It also keeps a typed-array waitAsync promise/reaction graph reachable only through the native waiter queue until notification, pending Thread.asyncJoin fulfillment/rejection reactions reachable only through native completion records until the child threads are released, a sibling promise-publication case where a child-returned typed-array waitAsync promise, a child-returned rejected promise, a child-returned user thenable, and a child-thrown object remain rooted through completion/native waiter state until post-sweep join()/asyncJoin() fulfillment, rejection, thenable assimilation, and thrown-object publication, a sibling sync-wait cleanup case where property Atomics.wait, Condition.wait, and contended Lock.hold peers stay parked through a finishing sweep before their stack roots and exact FinalizationRegistry cleanup count/sum are verified, a sibling sync-wait burst case where multiple same-property, same-Condition, and same-Lock waiters stay parked through a finishing sweep before burst release and exact finalization cleanup are verified, a sibling Atomics.Mutex.lockIfAvailable case 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 static Atomics.Condition.wait case where notify/reacquire token waiters stay parked through a finishing sweep before exact notify counts, token reacquisition, asyncJoin observers, and finalization cleanup are verified, and a sibling teardown case where parked children hold child-owned typed-array waitAsync tickets through a finishing mid-script sweep before parent failure terminates them. The Thread.join() park path now clears its gc_parked publication and rebalances the completion mutex on termination/error unwinds, and it only publishes gc_parked for 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 by parallel_js (M3): sync wait peers publish roots for mid-script parallel GC, which exercises property waits, Condition.wait, and contended Lock acquisition while requiring a finishing mid-script sweep. The mid-GC sync-wait fuzzer also compacts expired property waitAsync tickets while those peers stay parked, keeps one live property waitAsync ticket 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. an Environment.parent — which is why a naive finish swept live parents), drains to closure, and sweeps only if no peer allocated during the finish (born_concurrent still empty under alloc_lock); otherwise it returns false and the driver aborts, freeing nothing. An aborted mark can never use-after-free; the next quiescent collectGarbage reclaims 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-profile exercises 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 and parallel_midscript_gc remain 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.

  2. 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 under Gil.api_lock (lockApi/unlockApi), independent of the GIL — two concurrent Thread constructions can't both pass the cap or claim the same id. Property waiters done: Gil.prop_mutex now guards prop_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.tasks enqueue/dequeue uses Gil.api_lock, and grant delivery runs outside that lock. Condition done: CondRecord.mutex guards the FIFO sync/async waiter queue, and sync waits park on CondRecord.cond without using the context GIL as the queue mutex.

  3. 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.getOwn already read under property_lock; Environment.get/ isConst/isFnName/isAlias now read each scope's binding tables under binding_lock (the writers put/assign/putAlias/putConst/putFnName lock 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 covered getOwn/binding reads but missed two map-content iterations that walked the live Object.accessors StringHashMap unlocked while seal/freeze ran: lockKeys and the isFrozen/isSealed integrity check (builtins.zig). Under parallel_js two 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 a property_lock-held snapshot of the accessor keys (Object.accessorKeysSnapshot) instead of the live map; behavior-identical (same keys, same per-key getAttr/setAttr), GIL path unchanged. Validated: unit suite unchanged vs. baseline, frozen-seal-race.js/proto-cycle-race.js/private-fields-shared.js PASS under parallel_js, freeze/seal/isFrozen-with-accessors conformance spot-check green. Lesson for the remaining audit: every unlocked o.accessors/o.attrs content read (.?.get / |m| iterate) is a grow-race site, distinct from the benign unlocked pointer-== null fast-path guards; the pointer guards that gate into a locked accessor are fine, but a guard that then reads the map inline is not.

  4. Other shared globalssymbol registry done: the cross-realm GlobalSymbolRegistry get-or-create (Symbol.for + lazy registry creation) is now atomic under Gil.symbol_registry_lock (the key ToString is computed before the lock, so a user toString can'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 hot get_prop/set_prop path, 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 by bytecode.ic_seqlock_enabled (set with binding_locks_enabled for 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 sharded strcell.InternTable) — to be surfaced empirically by the GIL-free bring-up.

  5. 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.

  6. 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.

Coordination-primitive rewrite — the critical path (design)

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:

  • LockRecord sync and async-grant state uses LockRecord.mutex.
  • Condition sync/async waiter FIFO state uses CondRecord.mutex; waiters register under the condition mutex before releasing the associated Lock, so notify cannot miss the release+park transition.
  • property-mode Atomics.wait / notify use Gil.prop_waiters / Gil.prop_async guarded by Gil.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_lock for the whole property step, so no-GIL RMW counters no longer lose updates.
  • typed-array Atomics.wait only releases/reacquires the context GIL in the shipped GIL mode; parallel_js parks 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: add mutex: std.Io.Mutex. acquireLock locks rec.mutex, tests locked/generation, and rec.cond.wait(&rec.mutex) on contention; releaseLock locks rec.mutex, hands off / signals rec.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 a rec.mutex-guarded transition plus the existing api_lock-guarded run-loop queue. The queue now carries an atomic count so ordinary sync waiters skip api_lock when no hold job is pending.
  • Condition: the condition record has its own queue mutex. A waiter registers in the FIFO while holding CondRecord.mutex, releases the associated Lock under that mutex, then parks on CondRecord.cond; notify pops, marks, and broadcasts under the same mutex, and async waiters are handed to the already migrated Lock.asyncHold grant path.
  • Property Atomics.wait/notify: the per-realm Gil.prop_mutex now guards prop_waiters/prop_async; propNotify collects 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, worker terminate, D9 trap-on-parked): each park loop keeps polling stop_flag between 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).