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
+4
View File
@@ -70,6 +70,10 @@ pub const panic = start.panic;
/// Process entry types: the `Init` handed to `main`, and its `Arguments`.
pub const process = @import("process.zig");
/// Threads: `runtime.Thread`, std.Thread-shaped, over the private thread ABI
/// (docs/threading.md). A binary must be built multi-threaded to spawn.
pub const Thread = @import("thread.zig").Thread;
/// The service harness: one replyWait loop folding requests, signals, and
/// notifications into callbacks (docs/process-lifecycle.md).
pub const service = @import("service.zig");
+89
View File
@@ -0,0 +1,89 @@
//! `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
//! 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_exit`. See docs/threading.md for why this mirrors `std.Thread`'s API rather
//! than being the literal type (the private, renumberable syscall ABI).
//!
//! M2 surface: `spawn` + a `Thread` handle. `join`/`detach` and the `Mutex`/`Condition`
//! family arrive in later milestones (docs/threading-plan.md). A binary must be built
//! multi-threaded (`addThreadedUserBinary`) before it may spawn.
const std = @import("std");
const sc = @import("system-call.zig");
const system = @import("system.zig");
const heap = @import("heap.zig");
/// 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 Thread = struct {
/// The kernel task id of the spawned thread.
tid: u32,
pub const Id = u32;
pub const SpawnConfig = struct {
/// Bytes of stack, rounded up to whole pages by the kernel's mmap.
stack_size: usize = default_stack_size,
};
pub const SpawnError = error{
/// The closure could not be allocated on the heap.
OutOfMemory,
/// The kernel refused the thread (task table full, or the stack mmap failed).
SystemResources,
};
/// Start `function(args...)` on a new thread sharing this address space. Mirrors
/// `std.Thread.spawn`. The thread's return value is discarded (as in `std.Thread`);
/// return data through shared state.
pub fn spawn(config: SpawnConfig, comptime function: anytype, args: anytype) SpawnError!Thread {
const Args = @TypeOf(args);
const Closure = struct {
args: Args,
/// Entered directly by the kernel with `self` in rdi (C ABI). Runs the
/// user function, then ends the thread — never returns.
fn entry(self_addr: usize) callconv(.c) noreturn {
const self: *@This() = @ptrFromInt(self_addr);
const call_args = self.args;
heap.allocator().destroy(self); // args copied out; closure no longer needed
@call(.auto, function, call_args);
exitThread();
}
};
const closure = heap.allocator().create(Closure) catch return error.OutOfMemory;
errdefer heap.allocator().destroy(closure);
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);
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));
if (threadSpawnFailed(tid)) return error.SystemResources;
return .{ .tid = @intCast(tid) };
}
};
/// thread_spawn(entry, stack_top, arg) -> tid, or a wrapped error (see below).
fn threadSpawn(entry: usize, stack_top: usize, arg: usize) usize {
return sc.systemCall3(.thread_spawn, entry, stack_top, arg);
}
/// The kernel returns a real (small) task id on success and a wrapped `-1` on failure;
/// no valid task id ever exceeds a u32.
inline fn threadSpawnFailed(ret: usize) bool {
return ret > std.math.maxInt(u32);
}
/// End the calling thread. Never returns.
fn exitThread() noreturn {
_ = sc.systemCall0(.thread_exit);
unreachable;
}