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:
+30
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user