threads(M2): thread_spawn/thread_exit + runtime.Thread.spawn

A thread is a task sharing the caller's address space. New private syscalls
thread_spawn(entry, stack_top, arg)=37 and thread_exit=38: thread_spawn goes
through scheduler.spawnThread (retains the shared aspace), thread_exit ends the
task like a process exit(0) (terminateCurrent -> releaseAspace, so the space
survives while siblings hold it). The closure pointer reaches the new thread in
rdi via a new jump_to_user_arg asm path and a per-task user_arg (0 for a normal
process, whose _start ignores it) - so the runtime trampoline is a plain C-ABI
Zig function, no naked asm.

runtime.Thread (library/runtime/thread.zig) mirrors std.Thread.spawn: mmap a
stack, heap-allocate the args closure, hand the kernel the trampoline + closure.
addThreadedUserBinary opts a binary into single_threaded=false; thread-test is
the first, and proves a worker runs in the shared address space via a shared
global the main thread polls.

Gate thread-spawn PASS; 16 guardrail cases green (incl. args/init/process on the
new jump_to_user_arg path) + aspace-refcount; build + host tests clean.
This commit is contained in:
2026-07-20 21:04:23 +01:00
parent 11e363896f
commit 73df864fd2
12 changed files with 321 additions and 23 deletions
@@ -0,0 +1,47 @@
//! thread-test — the first multi-threaded danos binary (docs/threading-plan.md M2).
//!
//! Proves `runtime.Thread.spawn` starts a task in the **same address space**: the main
//! thread spawns a worker, the worker writes a shared global and signals `done`, and the
//! main thread — polling that shared memory — observes the write. Seeing the write proves
//! the two tasks share one address space (a separate process could not touch this memory).
//! The `thread-test: child ran in shared aspace ok` line is the case's marker.
//!
//! Built multi-threaded (`addThreadedUserBinary`), so the poll below is a real atomic
//! load the compiler must re-read — under a single-threaded build it could be hoisted.
const std = @import("std");
const runtime = @import("runtime");
/// Written by the worker thread, read by main — the shared-address-space evidence.
var shared_value: u32 = 0;
/// Release/acquire handshake: publishes the `shared_value` write to the reader.
var done = std.atomic.Value(u32).init(0);
const sentinel: u32 = 0xA5A5;
fn worker() void {
shared_value = sentinel; // a plain write to a global we share with main
done.store(1, .release); // ...published by this release store
}
pub fn main() void {
_ = runtime.system.write("thread-test: starting\n");
_ = runtime.Thread.spawn(.{}, worker, .{}) catch {
_ = runtime.system.write("thread-test: FAIL spawn refused\n");
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;
while (done.load(.acquire) == 0 and spins < 50_000_000) : (spins += 1) {
runtime.system.yield();
}
if (done.load(.acquire) == 1 and shared_value == sentinel) {
_ = runtime.system.write("thread-test: child ran in shared aspace ok\n");
} else {
_ = runtime.system.write("thread-test: FAIL worker did not update shared memory\n");
}
}