Status: implemented (src/gil.zig, src/jsthread.zig, src/context.zig,
conformance/threads_test.zig). Scope: Phase 6 of
#1 — the shared-memory Thread API
from oven-sh/WebKit#249 (vendored at reference/webkit-249/), implemented
under one VM lock. Concurrency, not parallelism: their phase 1 proved this
mode is independently shippable and testable; for this engine it is the
shipping state until a GC exists (Layer C).
- Real OS threads, the engine-global
std.Io.Threaded, the waiter table, and group teardown discipline (src/agent.zig, Phase 2). - Per-realm microtask queues + async-waiter settle loops (Phases 2-3).
- The stop-word checkpoint in both engines (interpreter.zig
eval, vm.zig dispatch — Phase 5): the same checkpoint is where the GIL yield goes. - The corpus:
reference/webkit-249/threads-tests/{api,atomics,arrays,sync}/(~47 files) is the spec; port behind a small shim that mapsThreadetc. plus theirresources/assert.js.
new Thread(fn, ...args): fn runs in the same realm — same globalThis, same heap, same module graph.t.join()blocks (releases the GIL), returns fn's value or rethrows its exception object;t.asyncJoin()settles on the requester's queue;Thread.current;t.id(main 0).join()settles only after the thread's own microtask queue drains.Locknon-recursive (hold(fn)= tryLock fast path + finally-equivalent release;asyncHold);Condition.wait(lock)= atomic release+park+ reacquire, spurious wakeups allowed;notify/notifyAll;ThreadLocal(.valueper-thread, any JS value);Thread.restrict(obj)→ConcurrentAccessErroron enforced foreign access.Atomics.*extended to ordinary own data properties (SameValueZero CAS so NaN loops work; wait/notify on(object, key)— a second waiter table keyed by object pointer + property key). The property waiter queues live on the per-contextGil, not in process-global state, so independent threaded contexts stay isolated while each op is trivially one atomic step under the realm lock; implement the semantics per theiratomics/property-*.jstests.- Promise rules: reactions run on the settling thread's queue; termination
drops undrained microtasks but keeps published settlements; a terminated
thread's
joinrethrows a plainError.
- Opt-in:
Context.enable_threads: bool(creation option). Off = noThreadglobal, zero new code on any path, the P0 affinity assert stays. On = the affinity assert relaxes to "holds the GIL" (the assert checksgil.holder == currentinstead of creator id). - The GIL: one
Io.Mutex+ holder id on Context. Spawned threads runfnvia a per-threadInterpreterover the SHARED Context state (arena allocation is single-threaded because of the lock). Every blocking point releases it:join,Lockcontention,Condition.wait,Atomics.wait, the agent parks. - Yield point: the existing step checkpoint (every 1024 steps) gains
if (gil_contended) { unlock; lock; }— without it one thread starves all others. Contention flag = atomic counter of waiters on the GIL. - Per-thread state (the bindings audit, GIL edition):
Interpreterinstance (already per-call-site), its OWN microtask queue + async-waiter list (lift from "per-Context" to "per-Thread record"; the main thread keeps the Context-owned ones), call depth/steps (already per-Interpreter),re_legacy(already per-Interpreter), exception slot (per-Interpreter already;Context.exceptionstays main-only for the C boundary). - Thread records:
src/jsthread.zig— JS-visible Thread objects carry a*ThreadRecord(private_data): std.Thread handle, result Value slot (heap value? lives in the shared arena — fine, one heap), state, its microtask queue, join cond. Ids from a counter; main = 0. - Closures cross threads safely because there is one heap and one lock: the function object, its captured environments, everything is just arena memory accessed under the GIL.
- Thread.restrict:
restricted_to: ?std.Thread.Idon Object (one optional field; checked in getProperty/setMember slow paths only when set — measure the cost, expect ~zero). - Corpus port:
test/threads/+ a runner target (zig build threads-test) that evaluates each file in an enable_threads Context with the shim prelude. Start withapi/thread-basic.js,api/lock-basic.js,api/condition-basic.js,atomics/property-load-store.jsand grow. Keep growing to the exact inventory; current blockers and terminal premises are verifier-owned rather than directory-wide skips.
Context.enable_threads+ GIL struct + relaxed affinity assert + yield checkpoint. No JS surface yet; existing suites must be byte-identical with the option off (the invariant gate).Threadglobal (ctor/join/current/id) + per-thread microtask queues + result/exception propagation. Portapi/thread-basic.js,thread-ctor-errors.js,thread-exc.js.Lock+Condition+ThreadLocal(reuse Io primitives; GIL released while parked). Portapi/lock-*.js,condition-*.js,threadlocal-basic.js,sync/.- Atomics-on-properties + property waiter table. Port
atomics/property-*. Thread.restrict+arrays/subset + stress loops; TSan pass (zig build test -Dtsan=truemust stay clean).
- The GIL inverts the P0 affinity story for enable_threads contexts — the relaxation must be surgical (assert on GIL ownership, not thread id) or debug builds will panic on legitimate cross-thread use.
evaluate()'s drain tail and the delivery loops assume one queue; the per-thread lift (4) is the fiddly part. Main-thread behavior must not change with the option off — gate everything.- Shape transition maps have a per-shape lock, and ordinary named-property
helper paths, including named-property delete/rebuild, have
Object.property_lock. Dense-array and Map/Set helper paths now route throughObject.elements_lock, but remaining direct element side doors, non-atomicValueslots, and arena allocation still stay under the GIL. Dropping the GIL remains Layer C work, not a Phase 6 shortcut.