threads(M3): join, detach, and cross-core parallelism

thread_spawn takes a 4th arg, an exit-endpoint handle: spawnThreadSupervised
resolves and refcounts it under the spawn lock (like spawnProcessSupervised), so
a thread's death posts a child-exit notification carrying its tid. runtime
Thread.join blocks in replyWait on that (private) endpoint for its tid, then
munmaps the stack; detach relinquishes the join (stack reclaimed at process
exit, for now). New current_core=39 syscall + Thread.currentCore() lets a worker
observe which core it ran on.

The closure now lives at the top of the thread's own (private) stack instead of
the heap, so spawn/join never touch the not-yet-thread-safe runtime heap.

thread-test gains a join mode: 4 workers x 100k atomic increments, joined, with
counter == N*K and >1 core stamped (real parallelism), plus a detached worker.
Gate thread-join PASS (4x, non-flaky); 17 guardrail/M1/M2 cases green; build +
host tests clean.
This commit is contained in:
2026-07-20 21:19:17 +01:00
parent 73df864fd2
commit 0730e77530
8 changed files with 279 additions and 86 deletions
+30 -16
View File
@@ -14,8 +14,8 @@ lands on its own and ends in a **verifiable gate** — shaped for a `/loop` run,
- **Blocking is futex-backed, never spin-backed** — waiters park in the kernel so an
idle core still halts ([halting.md](halting.md)).
- **New syscalls are private**: extend [abi.zig](../system/abi.zig) `SystemCall` after
`shm_physical = 36` (`thread_spawn = 37`, `thread_exit = 38`, `futex_wait = 39`,
`futex_wake = 40`) + a `library/runtime` wrapper; user code never names a number.
`shm_physical = 36` (`thread_spawn = 37`, `thread_exit = 38`, `current_core = 39`,
`futex_wait = 40`, `futex_wake = 41`) + a `library/runtime` wrapper; user code never names a number.
- **Restart granularity stays the process** — a faulting thread kills its process; the
supervisor restarts the process, which respawns its threads.
@@ -154,25 +154,39 @@ green.
> child's stack); make the arena per-aspace and the runtime heap thread-safe alongside the
> `Mutex` work (M5).
## M3 — `join` + `detach` + real parallelism
## M3 — `join` + `detach` + real parallelism ✅
- [ ] `join` over the existing exit-notification path
([process-lifecycle.md](process-lifecycle.md)): `spawn` passes a per-thread
`exit_endpoint`; `join` blocks in `ipc_reply_wait` until the child-exit notice for
that `tid`, then `munmap`s the stack. `detach` relinquishes the join right; the
reaper reclaims a detached thread's stack + slot on exit.
- [ ] `runtime.Thread.join` / `detach` / `getCurrentId` (id = kernel task id).
- [ ] `-Dtest-case=thread-join` (`smp: true`): the parent spawns N threads that each do
K `@atomicRmw`-increments on a shared counter and stamp the core index they ran
on; the parent joins all N and asserts `counter == N*K` **and** `distinct cores >
1` (genuine cross-core parallelism). A detached thread sub-check confirms no leak.
- [x] `join` over the existing exit-notification path
([process-lifecycle.md](process-lifecycle.md)): `thread_spawn` gained a 4th arg, an
`exit_endpoint` handle (resolved + refcounted like `spawnProcessSupervised`, via
`spawnThreadSupervised`); `join` blocks in `ipc_reply_wait` on that endpoint until
the child-exit notice for its `tid`, then `munmap`s the stack. `detach` relinquishes
the join right (its stack is reclaimed at process exit — kernel-reaper reclaim for
detached threads is deferred; see note).
- [x] `runtime.Thread.join` / `detach`, plus `Thread.currentCore()` (a new `current_core`
= 39 syscall) for the parallelism proof. `getCurrentId` deferred to M6 (TLS), where
a lighter self-id fits. The closure now rides the **thread's own stack** (not the
heap) — private per thread, so spawn/join touch no shared heap.
- [x] `-Dtest-case=thread-join` (`smp: 4`): `thread-test` join mode spawns N=4 workers
that each do K=100k `@atomicRmw`-increments on a shared counter and stamp the core
they ran on; the main thread joins all N and asserts `counter == N*K` **and**
`@popCount(cores_seen) > 1` (genuine cross-core parallelism), then a detached worker
proves `detach` runs without a join.
**Gate:** `python3 test/qemu_test.py thread-join` logs `thread: N joined, counter=N*K,
cores=<>1>`; guardrail set green (esp. `smp`, `affinity`, `process-kill`).
**Gate (met):** `python3 test/qemu_test.py thread-join` passes (`thread-test: join ok` →
`DANOS-TEST-RESULT: PASS`), robust across 4 runs; guardrail 17/17 green (incl. `smp`,
`affinity`, `process-kill`, and `args`/`init`/`process` on the exit-endpoint spawn path)
plus `aspace-refcount`/`thread-spawn`; `zig build` clean, `zig build test` green.
> **Note (deferred):** a detached thread's stack is freed only at process exit (not by the
> reaper on thread exit) — kernel user-stack tracking + reclaim is a later refinement. And
> the runtime heap is still not thread-safe: threads that both allocate concurrently would
> race (the thread *machinery* avoids the heap, but worker code sharing an allocator does
> not). Both fold into the M5 `Mutex`/allocator work.
## M4 — Futex: the one blocking primitive
- [ ] [abi.zig](../system/abi.zig): `futex_wait = 39`, `futex_wake = 40`. Kernel
- [ ] [abi.zig](../system/abi.zig): `futex_wait = 40`, `futex_wake = 41`. Kernel
wait-queue keyed by `(aspace_root, vaddr)`; `futex_wait(addr, expected, timeout)`
parks the task iff `*addr == expected` (re-checked under the lock) and returns on
wake or timeout; `futex_wake(addr, count)` moves up to `count` waiters back to