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
+24 -1
View File
@@ -228,6 +228,7 @@ fn system_call(state: *architecture.CpuState) void {
.shm_map => systemShmMap(state),
.shm_physical => systemShmPhysical(state),
.thread_spawn => systemThreadSpawn(state),
.current_core => systemCurrentCore(state),
.thread_exit => {
// A thread ends like a process exit(0), but only this task: its
// 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 stack_top = architecture.systemCallArg(state, 1);
const arg = architecture.systemCallArg(state, 2);
const exit_handle = architecture.systemCallArg(state, 3);
const t = scheduler.current();
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 (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);
}
/// 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
/// caller's buffer (up to `maximum` `abi.ProcessDescriptor` entries), returning
/// the total live-task count — the exact shape of `device_enumerate`, so a `ps`
-11
View File
@@ -424,17 +424,6 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, user_arg: u64, pri
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
/// 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
+47
View File
@@ -143,6 +143,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
aspaceRefcountTest(boot_information);
} else if (eql(case, "thread-spawn")) {
threadSpawnTest(boot_information);
} else if (eql(case, "thread-join")) {
threadJoinTest(boot_information);
} else if (eql(case, "args")) {
argsTest(boot_information);
} else if (eql(case, "init")) {
@@ -1504,6 +1506,51 @@ fn threadSpawnTest(boot_information: *const BootInformation) void {
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
/// 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