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
|
- **Blocking is futex-backed, never spin-backed** — waiters park in the kernel so an
|
||||||
idle core still halts ([halting.md](halting.md)).
|
idle core still halts ([halting.md](halting.md)).
|
||||||
- **New syscalls are private**: extend [abi.zig](../system/abi.zig) `SystemCall` after
|
- **New syscalls are private**: extend [abi.zig](../system/abi.zig) `SystemCall` after
|
||||||
`shm_physical = 36` (`thread_spawn = 37`, `thread_exit = 38`, `futex_wait = 39`,
|
`shm_physical = 36` (`thread_spawn = 37`, `thread_exit = 38`, `current_core = 39`,
|
||||||
`futex_wake = 40`) + a `library/runtime` wrapper; user code never names a number.
|
`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
|
- **Restart granularity stays the process** — a faulting thread kills its process; the
|
||||||
supervisor restarts the process, which respawns its threads.
|
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
|
> child's stack); make the arena per-aspace and the runtime heap thread-safe alongside the
|
||||||
> `Mutex` work (M5).
|
> `Mutex` work (M5).
|
||||||
|
|
||||||
## M3 — `join` + `detach` + real parallelism
|
## M3 — `join` + `detach` + real parallelism ✅
|
||||||
|
|
||||||
- [ ] `join` over the existing exit-notification path
|
- [x] `join` over the existing exit-notification path
|
||||||
([process-lifecycle.md](process-lifecycle.md)): `spawn` passes a per-thread
|
([process-lifecycle.md](process-lifecycle.md)): `thread_spawn` gained a 4th arg, an
|
||||||
`exit_endpoint`; `join` blocks in `ipc_reply_wait` until the child-exit notice for
|
`exit_endpoint` handle (resolved + refcounted like `spawnProcessSupervised`, via
|
||||||
that `tid`, then `munmap`s the stack. `detach` relinquishes the join right; the
|
`spawnThreadSupervised`); `join` blocks in `ipc_reply_wait` on that endpoint until
|
||||||
reaper reclaims a detached thread's stack + slot on exit.
|
the child-exit notice for its `tid`, then `munmap`s the stack. `detach` relinquishes
|
||||||
- [ ] `runtime.Thread.join` / `detach` / `getCurrentId` (id = kernel task id).
|
the join right (its stack is reclaimed at process exit — kernel-reaper reclaim for
|
||||||
- [ ] `-Dtest-case=thread-join` (`smp: true`): the parent spawns N threads that each do
|
detached threads is deferred; see note).
|
||||||
K `@atomicRmw`-increments on a shared counter and stamp the core index they ran
|
- [x] `runtime.Thread.join` / `detach`, plus `Thread.currentCore()` (a new `current_core`
|
||||||
on; the parent joins all N and asserts `counter == N*K` **and** `distinct cores >
|
= 39 syscall) for the parallelism proof. `getCurrentId` deferred to M6 (TLS), where
|
||||||
1` (genuine cross-core parallelism). A detached thread sub-check confirms no leak.
|
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,
|
**Gate (met):** `python3 test/qemu_test.py thread-join` passes (`thread-test: join ok` →
|
||||||
cores=<>1>`; guardrail set green (esp. `smp`, `affinity`, `process-kill`).
|
`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
|
## 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)`
|
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
|
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
|
wake or timeout; `futex_wake(addr, count)` moves up to `count` waiters back to
|
||||||
|
|||||||
+64
-29
@@ -1,18 +1,19 @@
|
|||||||
//! `runtime.Thread` — threads for danos, shaped like Zig's `std.Thread` but built on
|
//! `runtime.Thread` — threads for danos, shaped like Zig's `std.Thread` but built on
|
||||||
//! danos's private thread ABI (docs/threading.md). Several tasks share one address
|
//! danos's private thread ABI (docs/threading.md). Several tasks share one address
|
||||||
//! space; `spawn` starts one, the kernel delivers the closure pointer in the new
|
//! space; `spawn` starts one, the kernel delivers the closure pointer in the new
|
||||||
//! thread's rdi, and a small Zig trampoline runs the user function and calls
|
//! thread's rdi, a plain Zig trampoline runs the user function and calls `thread_exit`,
|
||||||
//! `thread_exit`. See docs/threading.md for why this mirrors `std.Thread`'s API rather
|
//! and `join` blocks on the thread's exit notification. See docs/threading.md for why
|
||||||
//! than being the literal type (the private, renumberable syscall ABI).
|
//! this mirrors `std.Thread`'s API rather than being the literal type.
|
||||||
//!
|
//!
|
||||||
//! M2 surface: `spawn` + a `Thread` handle. `join`/`detach` and the `Mutex`/`Condition`
|
//! The closure (the function's captured args) lives at the **top of the thread's own
|
||||||
//! family arrive in later milestones (docs/threading-plan.md). A binary must be built
|
//! stack**, not the heap — each thread's stack is private, so there is no shared-heap
|
||||||
//! multi-threaded (`addThreadedUserBinary`) before it may spawn.
|
//! concurrency in the spawn/join machinery (the runtime heap is not yet thread-safe).
|
||||||
|
//! A binary must be built multi-threaded (`addThreadedUserBinary`) before it may spawn.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const sc = @import("system-call.zig");
|
const sc = @import("system-call.zig");
|
||||||
const system = @import("system.zig");
|
const system = @import("system.zig");
|
||||||
const heap = @import("heap.zig");
|
const ipc = @import("ipc.zig");
|
||||||
|
|
||||||
/// A thread stack, if the caller does not override it. 64 KiB of mmap'd, zeroed pages.
|
/// A thread stack, if the caller does not override it. 64 KiB of mmap'd, zeroed pages.
|
||||||
pub const default_stack_size: usize = 64 * 1024;
|
pub const default_stack_size: usize = 64 * 1024;
|
||||||
@@ -20,6 +21,11 @@ pub const default_stack_size: usize = 64 * 1024;
|
|||||||
pub const Thread = struct {
|
pub const Thread = struct {
|
||||||
/// The kernel task id of the spawned thread.
|
/// The kernel task id of the spawned thread.
|
||||||
tid: u32,
|
tid: u32,
|
||||||
|
/// The endpoint the kernel notifies when this thread ends — what `join` blocks on.
|
||||||
|
exit_endpoint: ipc.Handle,
|
||||||
|
/// The mmap'd stack, reclaimed by `join` (or at process exit after `detach`).
|
||||||
|
stack_base: usize,
|
||||||
|
stack_size: usize,
|
||||||
|
|
||||||
pub const Id = u32;
|
pub const Id = u32;
|
||||||
|
|
||||||
@@ -29,9 +35,7 @@ pub const Thread = struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub const SpawnError = error{
|
pub const SpawnError = error{
|
||||||
/// The closure could not be allocated on the heap.
|
/// The kernel refused the thread, the stack mmap failed, or no endpoint was free.
|
||||||
OutOfMemory,
|
|
||||||
/// The kernel refused the thread (task table full, or the stack mmap failed).
|
|
||||||
SystemResources,
|
SystemResources,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,38 +46,69 @@ pub const Thread = struct {
|
|||||||
const Args = @TypeOf(args);
|
const Args = @TypeOf(args);
|
||||||
const Closure = struct {
|
const Closure = struct {
|
||||||
args: Args,
|
args: Args,
|
||||||
|
/// Entered directly by the kernel with `self` in rdi (C ABI). Runs the user
|
||||||
/// Entered directly by the kernel with `self` in rdi (C ABI). Runs the
|
/// function, then ends the thread — never returns.
|
||||||
/// user function, then ends the thread — never returns.
|
|
||||||
fn entry(self_addr: usize) callconv(.c) noreturn {
|
fn entry(self_addr: usize) callconv(.c) noreturn {
|
||||||
const self: *@This() = @ptrFromInt(self_addr);
|
const self: *@This() = @ptrFromInt(self_addr);
|
||||||
const call_args = self.args;
|
@call(.auto, function, self.args);
|
||||||
heap.allocator().destroy(self); // args copied out; closure no longer needed
|
|
||||||
@call(.auto, function, call_args);
|
|
||||||
exitThread();
|
exitThread();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closure = heap.allocator().create(Closure) catch return error.OutOfMemory;
|
// The endpoint the kernel posts this thread's exit notification to.
|
||||||
errdefer heap.allocator().destroy(closure);
|
const endpoint = ipc.createIpcEndpoint() orelse return error.SystemResources;
|
||||||
closure.* = .{ .args = args };
|
|
||||||
|
|
||||||
// Stack: mmap zeroed pages, then hand the kernel a 16-byte-aligned-minus-8 top so
|
|
||||||
// the C-ABI trampoline sees the alignment a `call` would have left (rsp % 16 == 8).
|
|
||||||
const base = system.mmap(config.stack_size, system.PROT_READ | system.PROT_WRITE);
|
const base = system.mmap(config.stack_size, system.PROT_READ | system.PROT_WRITE);
|
||||||
if (system.mmapFailed(base)) return error.SystemResources;
|
if (system.mmapFailed(base)) return error.SystemResources;
|
||||||
errdefer _ = system.munmap(base, config.stack_size);
|
|
||||||
const stack_top = (base + config.stack_size) - 8;
|
|
||||||
|
|
||||||
const tid = threadSpawn(@intFromPtr(&Closure.entry), stack_top, @intFromPtr(closure));
|
// Lay the closure at the very top of the thread's own stack, then start the
|
||||||
if (threadSpawnFailed(tid)) return error.SystemResources;
|
// thread's rsp just below it (16-aligned minus 8, the alignment a `call` leaves
|
||||||
return .{ .tid = @intCast(tid) };
|
// for a C-ABI entry) so the growing stack never overwrites the args.
|
||||||
|
var closure_addr = (base + config.stack_size) - @sizeOf(Closure);
|
||||||
|
closure_addr &= ~@as(usize, @alignOf(Closure) - 1); // align the closure down
|
||||||
|
const closure: *Closure = @ptrFromInt(closure_addr);
|
||||||
|
closure.* = .{ .args = args };
|
||||||
|
|
||||||
|
var stack_top = closure_addr & ~@as(usize, 15); // 16-align below the closure
|
||||||
|
stack_top -= 8; // ...then rsp % 16 == 8 at the C entry
|
||||||
|
|
||||||
|
const tid = threadSpawn(@intFromPtr(&Closure.entry), stack_top, closure_addr, endpoint);
|
||||||
|
if (threadSpawnFailed(tid)) {
|
||||||
|
_ = system.munmap(base, config.stack_size);
|
||||||
|
return error.SystemResources;
|
||||||
|
}
|
||||||
|
return .{ .tid = @intCast(tid), .exit_endpoint = endpoint, .stack_base = base, .stack_size = config.stack_size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until this thread finishes, then reclaim its stack. Mirrors
|
||||||
|
/// `std.Thread.join`. The exit endpoint is private to this thread, so the first
|
||||||
|
/// child-exit notification on it is this thread's.
|
||||||
|
pub fn join(self: Thread) void {
|
||||||
|
var receive: [0]u8 = undefined;
|
||||||
|
while (true) {
|
||||||
|
const got = ipc.replyWait(self.exit_endpoint, &.{}, &receive, null);
|
||||||
|
if (got.isChildExit() and got.childProcessId() == self.tid) break;
|
||||||
|
}
|
||||||
|
_ = system.munmap(self.stack_base, self.stack_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relinquish the right to join: never wait for or reclaim this thread. Its stack is
|
||||||
|
/// reclaimed at process exit (docs/threading-plan.md M3 — kernel-reaper stack reclaim
|
||||||
|
/// for detached threads is a later refinement). Mirrors `std.Thread.detach`.
|
||||||
|
pub fn detach(self: Thread) void {
|
||||||
|
_ = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dense 0-based index of the core the calling thread is running on. A danos
|
||||||
|
/// extension beyond `std.Thread`, used to observe genuine cross-core parallelism.
|
||||||
|
pub fn currentCore() Id {
|
||||||
|
return @intCast(sc.systemCall0(.current_core));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// thread_spawn(entry, stack_top, arg) -> tid, or a wrapped error (see below).
|
/// thread_spawn(entry, stack_top, arg, exit_endpoint) -> tid, or a wrapped error.
|
||||||
fn threadSpawn(entry: usize, stack_top: usize, arg: usize) usize {
|
fn threadSpawn(entry: usize, stack_top: usize, arg: usize, exit_endpoint: ipc.Handle) usize {
|
||||||
return sc.systemCall3(.thread_spawn, entry, stack_top, arg);
|
return sc.systemCall4(.thread_spawn, entry, stack_top, arg, exit_endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The kernel returns a real (small) task id on success and a wrapped `-1` on failure;
|
/// The kernel returns a real (small) task id on success and a wrapped `-1` on failure;
|
||||||
|
|||||||
+2
-1
@@ -63,8 +63,9 @@ pub const SystemCall = enum(u64) {
|
|||||||
shm_create = 34, // shm_create(len) -> vaddr (rax), handle (rdx): a shareable, zeroed, cacheable RAM region mapped into this AS; the handle is a capability passed to another process as an ipc_call send_cap (docs/display-v2.md)
|
shm_create = 34, // shm_create(len) -> vaddr (rax), handle (rdx): a shareable, zeroed, cacheable RAM region mapped into this AS; the handle is a capability passed to another process as an ipc_call send_cap (docs/display-v2.md)
|
||||||
shm_map = 35, // shm_map(cap) -> vaddr: map the shared region named by a received capability into this AS (the same physical pages the creator sees)
|
shm_map = 35, // shm_map(cap) -> vaddr: map the shared region named by a received capability into this AS (the same physical pages the creator sees)
|
||||||
shm_physical = 36, // shm_physical(cap) -> paddr: the guest-physical base of a shared region held by capability, so a driver can program it into a device (e.g. virtio-gpu attach_backing); the pages are contiguous (docs/display-v2.md)
|
shm_physical = 36, // shm_physical(cap) -> paddr: the guest-physical base of a shared region held by capability, so a driver can program it into a device (e.g. virtio-gpu attach_backing); the pages are contiguous (docs/display-v2.md)
|
||||||
thread_spawn = 37, // thread_spawn(entry, stack_top, arg) -> tid: start a task that shares the caller's address space at `entry` on `stack_top`, with `arg` in rdi (docs/threading.md)
|
thread_spawn = 37, // thread_spawn(entry, stack_top, arg, exit_endpoint) -> tid: start a task sharing the caller's address space at `entry` on `stack_top`, `arg` in rdi; exit_endpoint (a handle, or no_cap) is notified when it ends — how join waits (docs/threading.md)
|
||||||
thread_exit = 38, // thread_exit(): end the calling thread, dropping one reference to its address space (destroyed on the last)
|
thread_exit = 38, // thread_exit(): end the calling thread, dropping one reference to its address space (destroyed on the last)
|
||||||
|
current_core = 39, // current_core() -> index: the dense 0-based index of the core the caller is running on (for parallelism/affinity introspection)
|
||||||
_,
|
_,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ fn system_call(state: *architecture.CpuState) void {
|
|||||||
.shm_map => systemShmMap(state),
|
.shm_map => systemShmMap(state),
|
||||||
.shm_physical => systemShmPhysical(state),
|
.shm_physical => systemShmPhysical(state),
|
||||||
.thread_spawn => systemThreadSpawn(state),
|
.thread_spawn => systemThreadSpawn(state),
|
||||||
|
.current_core => systemCurrentCore(state),
|
||||||
.thread_exit => {
|
.thread_exit => {
|
||||||
// A thread ends like a process exit(0), but only this task: its
|
// A thread ends like a process exit(0), but only this task: its
|
||||||
// resources are released and its address-space reference dropped (the
|
// resources are released and its address-space reference dropped (the
|
||||||
@@ -661,14 +662,36 @@ fn systemThreadSpawn(state: *architecture.CpuState) void {
|
|||||||
const entry = architecture.systemCallArg(state, 0);
|
const entry = architecture.systemCallArg(state, 0);
|
||||||
const stack_top = architecture.systemCallArg(state, 1);
|
const stack_top = architecture.systemCallArg(state, 1);
|
||||||
const arg = architecture.systemCallArg(state, 2);
|
const arg = architecture.systemCallArg(state, 2);
|
||||||
|
const exit_handle = architecture.systemCallArg(state, 3);
|
||||||
const t = scheduler.current();
|
const t = scheduler.current();
|
||||||
if (t.aspace == 0) return fail(state); // kernel tasks own no address space to share
|
if (t.aspace == 0) return fail(state); // kernel tasks own no address space to share
|
||||||
if (entry == 0 or entry >= user_half_end) return fail(state);
|
if (entry == 0 or entry >= user_half_end) return fail(state);
|
||||||
if (stack_top == 0 or stack_top > user_half_end) return fail(state);
|
if (stack_top == 0 or stack_top > user_half_end) return fail(state);
|
||||||
const tid = scheduler.spawnThread(t.aspace, entry, stack_top, arg, t.priority, t.id) orelse return fail(state);
|
// The endpoint the thread notifies on exit (how join waits), or none.
|
||||||
|
const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
|
||||||
|
null
|
||||||
|
else
|
||||||
|
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
|
||||||
|
const tid = spawnThreadSupervised(t.aspace, entry, stack_top, arg, t.priority, t.id, exit_endpoint) orelse return fail(state);
|
||||||
architecture.setSystemCallResult(state, tid);
|
architecture.setSystemCallResult(state, tid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn a thread sharing `aspace`, taking the exit-endpoint reference under the **same**
|
||||||
|
/// lock as the spawn (as `spawnProcessSupervised` does), so the thread cannot die before
|
||||||
|
/// its reference exists. Returns the new thread id, or null on resource exhaustion.
|
||||||
|
fn spawnThreadSupervised(aspace: u64, entry: u64, stack_top: u64, arg: u64, priority: scheduler.Priority, supervisor: u32, exit_endpoint: ?*ipc.Endpoint) ?u32 {
|
||||||
|
const flags = sync.enter();
|
||||||
|
defer sync.leave(flags);
|
||||||
|
const tid = scheduler.spawnUserLocked(aspace, entry, stack_top, arg, priority, "thread", supervisor, if (exit_endpoint) |e| @ptrCast(e) else null) orelse return null;
|
||||||
|
if (exit_endpoint) |endpoint| endpoint.refcount += 1; // the thread holds it birth-to-death
|
||||||
|
return tid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// current_core() -> index: the dense 0-based index of the core the caller runs on.
|
||||||
|
fn systemCurrentCore(state: *architecture.CpuState) void {
|
||||||
|
architecture.setSystemCallResult(state, scheduler.currentCpuIndex());
|
||||||
|
}
|
||||||
|
|
||||||
/// process_enumerate(buffer, maximum) -> total: snapshot the task table into the
|
/// process_enumerate(buffer, maximum) -> total: snapshot the task table into the
|
||||||
/// caller's buffer (up to `maximum` `abi.ProcessDescriptor` entries), returning
|
/// caller's buffer (up to `maximum` `abi.ProcessDescriptor` entries), returning
|
||||||
/// the total live-task count — the exact shape of `device_enumerate`, so a `ps`
|
/// the total live-task count — the exact shape of `device_enumerate`, so a `ps`
|
||||||
|
|||||||
@@ -424,17 +424,6 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, user_arg: u64, pri
|
|||||||
return t.id;
|
return t.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a **thread**: a user task that shares an *existing* address space `aspace`
|
|
||||||
/// (docs/threading.md), starting at `entry` on `user_sp` with `arg` delivered in its
|
|
||||||
/// rdi. Takes a reference to `aspace` (destroyed only when the last thread on it
|
|
||||||
/// exits). Acquires the kernel lock itself. `supervisor` is the spawning process.
|
|
||||||
/// Returns the new thread's id, or null if the task table is full / out of memory.
|
|
||||||
pub fn spawnThread(aspace: u64, entry: u64, user_sp: u64, arg: u64, priority: Priority, supervisor: u32) ?u32 {
|
|
||||||
const flags = sync.enter();
|
|
||||||
defer sync.leave(flags);
|
|
||||||
return spawnUserLocked(aspace, entry, user_sp, arg, priority, "thread", supervisor, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The first thing a fresh user task runs (in ring 0, via task_trampoline). It
|
/// The first thing a fresh user task runs (in ring 0, via task_trampoline). It
|
||||||
/// drops to ring 3 at the task's recorded entry/stack. Reading them from the
|
/// drops to ring 3 at the task's recorded entry/stack. Reading them from the
|
||||||
/// Task avoids smuggling values through callee-saved registers across the
|
/// Task avoids smuggling values through callee-saved registers across the
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
|||||||
aspaceRefcountTest(boot_information);
|
aspaceRefcountTest(boot_information);
|
||||||
} else if (eql(case, "thread-spawn")) {
|
} else if (eql(case, "thread-spawn")) {
|
||||||
threadSpawnTest(boot_information);
|
threadSpawnTest(boot_information);
|
||||||
|
} else if (eql(case, "thread-join")) {
|
||||||
|
threadJoinTest(boot_information);
|
||||||
} else if (eql(case, "args")) {
|
} else if (eql(case, "args")) {
|
||||||
argsTest(boot_information);
|
argsTest(boot_information);
|
||||||
} else if (eql(case, "init")) {
|
} else if (eql(case, "init")) {
|
||||||
@@ -1504,6 +1506,51 @@ fn threadSpawnTest(boot_information: *const BootInformation) void {
|
|||||||
result();
|
result();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thread join + parallelism (docs/threading-plan.md M3): `thread-test` in join mode
|
||||||
|
/// spawns N workers that each do K atomic increments on a shared counter and stamp the
|
||||||
|
/// core they ran on; it `join`s all N and asserts the total is exactly N*K (every worker
|
||||||
|
/// ran, join waited for each) and that >1 core was used (genuine parallelism), then a
|
||||||
|
/// detached worker proves `detach`. Its single verdict marker is the case result.
|
||||||
|
fn threadJoinTest(boot_information: *const BootInformation) void {
|
||||||
|
log("DANOS-TEST-BEGIN: thread-join\n", .{});
|
||||||
|
if (boot_information.initial_ramdisk_len == 0) {
|
||||||
|
check("bootloader handed over an initial_ramdisk", false);
|
||||||
|
result();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||||
|
const rd = initial_ramdisk.Reader.init(image) orelse {
|
||||||
|
check("initial_ramdisk image is valid", false);
|
||||||
|
result();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn thread-test in join mode (argv selects the mode).
|
||||||
|
var started = false;
|
||||||
|
var i: u32 = 0;
|
||||||
|
while (i < rd.count) : (i += 1) {
|
||||||
|
const item = rd.entry(i) orelse continue;
|
||||||
|
if (!eql(item.name, "thread-test")) continue;
|
||||||
|
started = if (process.spawnProcess(item.blob, 4, &.{ "thread-test", "join" })) true else |_| false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
check("thread-test (join mode) spawned", started);
|
||||||
|
|
||||||
|
const ok_marker = "thread-test: join ok";
|
||||||
|
const fail_marker = "thread-test: FAIL";
|
||||||
|
scheduler.setPriority(1);
|
||||||
|
const deadline = architecture.millis() + 15000;
|
||||||
|
while (architecture.millis() < deadline) {
|
||||||
|
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
|
||||||
|
scheduler.yield();
|
||||||
|
}
|
||||||
|
scheduler.setPriority(4);
|
||||||
|
|
||||||
|
check("N worker threads joined; counter exact (N*K) and >1 core used", bufferHas(ok_marker));
|
||||||
|
check("no thread failure reported", !bufferHas(fail_marker));
|
||||||
|
result();
|
||||||
|
}
|
||||||
|
|
||||||
/// The full PID-1 path: the bootloader read /system/services/init off the boot volume and
|
/// The full PID-1 path: the bootloader read /system/services/init off the boot volume and
|
||||||
/// handed it over; load it as a user ELF and spawn it as a real ring-3 process
|
/// handed it over; load it as a user ELF and spawn it as a real ring-3 process
|
||||||
/// — the same call the normal boot path makes — then confirm it beats. init
|
/// — the same call the normal boot path makes — then confirm it beats. init
|
||||||
|
|||||||
@@ -1,47 +1,123 @@
|
|||||||
//! thread-test — the first multi-threaded danos binary (docs/threading-plan.md M2).
|
//! thread-test — danos's multi-threaded exerciser (docs/threading-plan.md M2, M3).
|
||||||
//!
|
//!
|
||||||
//! Proves `runtime.Thread.spawn` starts a task in the **same address space**: the main
|
//! Two modes, chosen by argv[1] (default "spawn"):
|
||||||
//! thread spawns a worker, the worker writes a shared global and signals `done`, and the
|
//! spawn — M2: one worker writes a shared global; the main thread observes it, proving
|
||||||
//! main thread — polling that shared memory — observes the write. Seeing the write proves
|
//! `runtime.Thread.spawn` started a task in the **same** address space.
|
||||||
//! the two tasks share one address space (a separate process could not touch this memory).
|
//! join — M3: N workers each do K atomic increments on a shared counter and stamp the
|
||||||
//! The `thread-test: child ran in shared aspace ok` line is the case's marker.
|
//! core they ran on; the main thread `join`s all N and checks the total is
|
||||||
|
//! exactly N*K (every worker ran, join waited) and that >1 core was used
|
||||||
|
//! (genuine parallelism). Then a detached worker proves `detach` runs and
|
||||||
|
//! needs no join.
|
||||||
//!
|
//!
|
||||||
//! Built multi-threaded (`addThreadedUserBinary`), so the poll below is a real atomic
|
//! Built multi-threaded (`addThreadedUserBinary`) so atomics/shared reads are real.
|
||||||
//! load the compiler must re-read — under a single-threaded build it could be hoisted.
|
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const runtime = @import("runtime");
|
const runtime = @import("runtime");
|
||||||
|
|
||||||
/// Written by the worker thread, read by main — the shared-address-space evidence.
|
fn write(comptime s: []const u8) void {
|
||||||
var shared_value: u32 = 0;
|
_ = runtime.system.write(s);
|
||||||
/// Release/acquire handshake: publishes the `shared_value` write to the reader.
|
}
|
||||||
var done = std.atomic.Value(u32).init(0);
|
|
||||||
|
|
||||||
|
// --- M2: spawn mode ---------------------------------------------------------
|
||||||
|
|
||||||
|
var shared_value: u32 = 0;
|
||||||
|
var spawn_done = std.atomic.Value(u32).init(0);
|
||||||
const sentinel: u32 = 0xA5A5;
|
const sentinel: u32 = 0xA5A5;
|
||||||
|
|
||||||
fn worker() void {
|
fn spawnWorker() void {
|
||||||
shared_value = sentinel; // a plain write to a global we share with main
|
shared_value = sentinel;
|
||||||
done.store(1, .release); // ...published by this release store
|
spawn_done.store(1, .release);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main() void {
|
fn runSpawnMode() void {
|
||||||
_ = runtime.system.write("thread-test: starting\n");
|
write("thread-test: starting\n");
|
||||||
|
_ = runtime.Thread.spawn(.{}, spawnWorker, .{}) catch {
|
||||||
_ = runtime.Thread.spawn(.{}, worker, .{}) catch {
|
write("thread-test: FAIL spawn refused\n");
|
||||||
_ = runtime.system.write("thread-test: FAIL spawn refused\n");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bounded wait for the worker to run and publish. yield() keeps the core useful;
|
|
||||||
// the acquire load pairs with the worker's release store.
|
|
||||||
var spins: usize = 0;
|
var spins: usize = 0;
|
||||||
while (done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
while (spawn_done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
||||||
runtime.system.yield();
|
runtime.system.yield();
|
||||||
}
|
}
|
||||||
|
if (spawn_done.load(.acquire) == 1 and shared_value == sentinel) {
|
||||||
if (done.load(.acquire) == 1 and shared_value == sentinel) {
|
write("thread-test: child ran in shared aspace ok\n");
|
||||||
_ = runtime.system.write("thread-test: child ran in shared aspace ok\n");
|
|
||||||
} else {
|
} else {
|
||||||
_ = runtime.system.write("thread-test: FAIL worker did not update shared memory\n");
|
write("thread-test: FAIL worker did not update shared memory\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- M3: join mode ----------------------------------------------------------
|
||||||
|
|
||||||
|
const worker_count: u32 = 4;
|
||||||
|
const iterations: u64 = 100_000;
|
||||||
|
|
||||||
|
var counter = std.atomic.Value(u64).init(0);
|
||||||
|
var cores_seen = std.atomic.Value(u32).init(0);
|
||||||
|
|
||||||
|
fn joinWorker() void {
|
||||||
|
var i: u64 = 0;
|
||||||
|
while (i < iterations) : (i += 1) {
|
||||||
|
_ = counter.fetchAdd(1, .monotonic);
|
||||||
|
if (i % 1000 == 0) stampCore(); // periodic: catches cross-core migration too
|
||||||
|
}
|
||||||
|
stampCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stampCore() void {
|
||||||
|
const core = runtime.Thread.currentCore();
|
||||||
|
if (core < 32) _ = cores_seen.fetchOr(@as(u32, 1) << @intCast(core), .monotonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detach_done = std.atomic.Value(u32).init(0);
|
||||||
|
|
||||||
|
fn detachWorker() void {
|
||||||
|
detach_done.store(1, .release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runJoinMode() void {
|
||||||
|
write("thread-test: join mode starting\n");
|
||||||
|
|
||||||
|
var threads: [worker_count]runtime.Thread = undefined;
|
||||||
|
var spawned: u32 = 0;
|
||||||
|
while (spawned < worker_count) : (spawned += 1) {
|
||||||
|
threads[spawned] = runtime.Thread.spawn(.{}, joinWorker, .{}) catch break;
|
||||||
|
}
|
||||||
|
if (spawned != worker_count) {
|
||||||
|
write("thread-test: FAIL could not spawn all workers\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (threads[0..spawned]) |t| t.join();
|
||||||
|
|
||||||
|
const total = counter.load(.acquire);
|
||||||
|
const cores = @popCount(cores_seen.load(.acquire));
|
||||||
|
if (total != worker_count * iterations) {
|
||||||
|
write("thread-test: FAIL counter mismatch (a worker was lost or join did not wait)\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cores <= 1) {
|
||||||
|
write("thread-test: FAIL workers never ran on more than one core\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// detach: the worker runs and we never join it.
|
||||||
|
const dt = runtime.Thread.spawn(.{}, detachWorker, .{}) catch {
|
||||||
|
write("thread-test: FAIL detach spawn refused\n");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
dt.detach();
|
||||||
|
var spins: usize = 0;
|
||||||
|
while (detach_done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
|
||||||
|
runtime.system.yield();
|
||||||
|
}
|
||||||
|
if (detach_done.load(.acquire) != 1) {
|
||||||
|
write("thread-test: FAIL detached worker did not run\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
write("thread-test: join ok\n"); // the M3 verdict marker
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main(init: runtime.process.Init) void {
|
||||||
|
const mode = init.arguments.get(1) orelse "spawn";
|
||||||
|
if (std.mem.eql(u8, mode, "join")) runJoinMode() else runSpawnMode();
|
||||||
|
}
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ CASES = [
|
|||||||
"timeout": 60,
|
"timeout": 60,
|
||||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||||
|
|
||||||
|
# docs/threading-plan.md M3: join + parallelism — N workers each do K atomic
|
||||||
|
# increments (total exactly N*K after join) and run on >1 core; plus detach.
|
||||||
|
{"name": "thread-join",
|
||||||
|
"smp": 4,
|
||||||
|
"timeout": 60,
|
||||||
|
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||||
|
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||||
# Process arguments: argv arrives on the SysV entry stack (argv[0] = the spawned
|
# Process arguments: argv arrives on the SysV entry stack (argv[0] = the spawned
|
||||||
# name, argv[1..] = the system_spawn argument blob) and echoes back intact.
|
# name, argv[1..] = the system_spawn argument blob) and echoes back intact.
|
||||||
{"name": "args",
|
{"name": "args",
|
||||||
|
|||||||
Reference in New Issue
Block a user