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:
parent
11e363896f
commit
73df864fd2
37
build.zig
37
build.zig
|
|
@ -69,6 +69,36 @@ fn addUserBinary(
|
||||||
acpi_ids_module: *std.Build.Module,
|
acpi_ids_module: *std.Build.Module,
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
root: []const u8,
|
root: []const u8,
|
||||||
|
) *std.Build.Step.Compile {
|
||||||
|
return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As `addUserBinary`, but built multi-threaded (`single_threaded = false`) so real
|
||||||
|
/// atomics/TLS work — required before a binary may call `runtime.Thread.spawn`
|
||||||
|
/// (docs/threading.md). Threads are a deliberate per-binary opt-in.
|
||||||
|
fn addThreadedUserBinary(
|
||||||
|
b: *std.Build,
|
||||||
|
target: std.Build.ResolvedTarget,
|
||||||
|
runtime_module: *std.Build.Module,
|
||||||
|
mmio_module: *std.Build.Module,
|
||||||
|
xkeyboard_config_module: *std.Build.Module,
|
||||||
|
acpi_ids_module: *std.Build.Module,
|
||||||
|
name: []const u8,
|
||||||
|
root: []const u8,
|
||||||
|
) *std.Build.Step.Compile {
|
||||||
|
return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addUserBinaryImpl(
|
||||||
|
b: *std.Build,
|
||||||
|
target: std.Build.ResolvedTarget,
|
||||||
|
runtime_module: *std.Build.Module,
|
||||||
|
mmio_module: *std.Build.Module,
|
||||||
|
xkeyboard_config_module: *std.Build.Module,
|
||||||
|
acpi_ids_module: *std.Build.Module,
|
||||||
|
name: []const u8,
|
||||||
|
root: []const u8,
|
||||||
|
threaded: bool,
|
||||||
) *std.Build.Step.Compile {
|
) *std.Build.Step.Compile {
|
||||||
// Settings (target, optimize, code model, ...) live on the root module only;
|
// Settings (target, optimize, code model, ...) live on the root module only;
|
||||||
// the program and runtime modules leave theirs null and inherit them.
|
// the program and runtime modules leave theirs null and inherit them.
|
||||||
|
|
@ -93,7 +123,7 @@ fn addUserBinary(
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = .ReleaseSmall,
|
.optimize = .ReleaseSmall,
|
||||||
.code_model = .large,
|
.code_model = .large,
|
||||||
.single_threaded = true,
|
.single_threaded = !threaded, // a threaded binary needs real atomics/TLS
|
||||||
.sanitize_c = .off,
|
.sanitize_c = .off,
|
||||||
.stack_check = false,
|
.stack_check = false,
|
||||||
.stack_protector = false,
|
.stack_protector = false,
|
||||||
|
|
@ -548,6 +578,9 @@ pub fn build(b: *std.Build) void {
|
||||||
const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "args-echo", "system/services/args-echo/args-echo.zig");
|
const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "args-echo", "system/services/args-echo/args-echo.zig");
|
||||||
const process_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "process-test", "system/services/process-test/process-test.zig");
|
const process_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "process-test", "system/services/process-test/process-test.zig");
|
||||||
const log_flush_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "log-flush", "system/services/log-flush/log-flush.zig");
|
const log_flush_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "log-flush", "system/services/log-flush/log-flush.zig");
|
||||||
|
// The first multi-threaded binary: exercises runtime.Thread over the thread ABI
|
||||||
|
// (docs/threading.md). Built threaded so its shared-memory poll is real.
|
||||||
|
const thread_test_exe = addThreadedUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "thread-test", "system/services/thread-test/thread-test.zig");
|
||||||
|
|
||||||
// Pack the user binaries into the initial_ramdisk image with the host-side Python tool
|
// Pack the user binaries into the initial_ramdisk image with the host-side Python tool
|
||||||
// (the container format is trivial, and Python sidesteps std API churn). Args:
|
// (the container format is trivial, and Python sidesteps std API churn). Args:
|
||||||
|
|
@ -591,6 +624,8 @@ pub fn build(b: *std.Build) void {
|
||||||
mk_run.addFileArg(pci_bus_exe.getEmittedBin());
|
mk_run.addFileArg(pci_bus_exe.getEmittedBin());
|
||||||
mk_run.addArg("crash-test");
|
mk_run.addArg("crash-test");
|
||||||
mk_run.addFileArg(crash_test_exe.getEmittedBin());
|
mk_run.addFileArg(crash_test_exe.getEmittedBin());
|
||||||
|
mk_run.addArg("thread-test");
|
||||||
|
mk_run.addFileArg(thread_test_exe.getEmittedBin());
|
||||||
mk_run.addArg("device-list");
|
mk_run.addArg("device-list");
|
||||||
mk_run.addFileArg(device_list_exe.getEmittedBin());
|
mk_run.addFileArg(device_list_exe.getEmittedBin());
|
||||||
mk_run.addArg("discovery");
|
mk_run.addArg("discovery");
|
||||||
|
|
|
||||||
|
|
@ -120,29 +120,39 @@ full guardrail set passes unchanged — 13/13 (`smoke`, `sched`, `priority`, `sm
|
||||||
`vfs-client-death`, `ipc`, `ipc-cap`, `display-service`); default `zig build` clean,
|
`vfs-client-death`, `ipc`, `ipc-cap`, `display-service`); default `zig build` clean,
|
||||||
`zig build test` green. The reframing is invisible until an aspace is actually shared.
|
`zig build test` green. The reframing is invisible until an aspace is actually shared.
|
||||||
|
|
||||||
## M2 — `thread_spawn` + `thread_exit`: a thread runs in the shared address space
|
## M2 — `thread_spawn` + `thread_exit`: a thread runs in the shared address space ✅
|
||||||
|
|
||||||
Spawn only — no join yet. Prove a second task executes in the **caller's** address
|
Spawn only — no join yet. Prove a second task executes in the **caller's** address
|
||||||
space and exits cleanly.
|
space and exits cleanly.
|
||||||
|
|
||||||
- [ ] [abi.zig](../system/abi.zig): `thread_spawn = 37`, `thread_exit = 38`. Handlers
|
- [x] [abi.zig](../system/abi.zig): `thread_spawn = 37`, `thread_exit = 38`. Handlers in
|
||||||
in process.zig; `thread_spawn` calls the `spawnUserLocked` path with the caller's
|
process.zig; `thread_spawn` calls `scheduler.spawnThread` (shares the caller's
|
||||||
aspace (`retainAspace`), `entry`, `user_sp`; `thread_exit` marks the task dead,
|
aspace, `retainAspace`); `thread_exit` ends the task like a process `exit(0)`
|
||||||
hands back its user-stack range, and `releaseAspace`s.
|
(`terminateCurrent` → `releaseAspace`). The closure pointer is delivered in the new
|
||||||
- [ ] `library/runtime/thread.zig` (barrel-exported as `runtime.Thread`): `spawn` maps
|
thread's **rdi** via a new `jump_to_user_arg` asm path (`t.user_arg`, 0 for a
|
||||||
a stack (`mmap`), heap-allocates the closure `{fn, args, done}`, writes the
|
process) — no naked runtime asm.
|
||||||
closure pointer to the stack top, calls `thread_spawn(&threadTrampoline, top,
|
- [x] `library/runtime/thread.zig` (barrel-exported as `runtime.Thread`): `spawn` maps a
|
||||||
closure)`. `threadTrampoline` reads the closure, calls the function, calls
|
stack (`mmap`), heap-allocates the `{args}` closure, and calls
|
||||||
`thread_exit`.
|
`thread_spawn(&Closure.entry, stack_top, closure)`; `Closure.entry` (a plain C-ABI
|
||||||
- [ ] `addUserBinary` gains `threaded: bool = false` → `single_threaded = false`; a
|
Zig fn, closure in rdi) runs the function and calls `thread_exit`. Stack top is
|
||||||
`thread-test` service (threaded) is the first opt-in binary.
|
16-aligned-minus-8 for the C entry.
|
||||||
- [ ] `-Dtest-case=thread-spawn`: the parent spawns one thread that writes a sentinel
|
- [x] A `threaded` flag on the user-binary recipe (`addThreadedUserBinary` →
|
||||||
to a **shared** global and sets `done`; the parent bounded-polls `done`, then
|
`single_threaded = false`); `thread-test` is the first opt-in binary.
|
||||||
asserts the shared global holds the sentinel (same address space) and frames
|
- [x] `-Dtest-case=thread-spawn`: `thread-test` spawns a worker that writes a sentinel to
|
||||||
return to baseline (thread exit released nothing extra — refcount held).
|
a **shared** global and release-stores `done`; the main thread acquire-polls `done`
|
||||||
|
and asserts the shared global holds the sentinel — proof the worker ran in the same
|
||||||
|
address space.
|
||||||
|
|
||||||
**Gate:** `python3 test/qemu_test.py thread-spawn` logs `thread: child ran in shared
|
**Gate (met):** `python3 test/qemu_test.py thread-spawn` passes
|
||||||
aspace ok`; guardrail set green.
|
(`thread-test: child ran in shared aspace ok` → `DANOS-TEST-RESULT: PASS`); guardrail set
|
||||||
|
16/16 green (incl. `args`/`init`/`process`, which exercise the new `jump_to_user_arg`
|
||||||
|
process path with arg 0) plus `aspace-refcount`; `zig build` clean, `zig build test`
|
||||||
|
green.
|
||||||
|
|
||||||
|
> **Note (deferred to M3+):** the mmap arena is per-*task* (`heap_next`), so two threads
|
||||||
|
> in one aspace that both `mmap` would collide. Fine for M2 (only the parent maps, for the
|
||||||
|
> 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ pub const panic = start.panic;
|
||||||
/// Process entry types: the `Init` handed to `main`, and its `Arguments`.
|
/// Process entry types: the `Init` handed to `main`, and its `Arguments`.
|
||||||
pub const process = @import("process.zig");
|
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
|
/// The service harness: one replyWait loop folding requests, signals, and
|
||||||
/// notifications into callbacks (docs/process-lifecycle.md).
|
/// notifications into callbacks (docs/process-lifecycle.md).
|
||||||
pub const service = @import("service.zig");
|
pub const service = @import("service.zig");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,8 @@ 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_exit = 38, // thread_exit(): end the calling thread, dropping one reference to its address space (destroyed on the last)
|
||||||
_,
|
_,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -687,6 +687,15 @@ pub fn jumpToUser(entry: u64, stack_top: u64) noreturn {
|
||||||
jump_to_user(entry, stack_top);
|
jump_to_user(entry, stack_top);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// As `jumpToUser`, but delivers `arg0` in the user's `rdi` — how a fresh thread
|
||||||
|
/// receives its closure pointer (docs/threading.md). A normal process is dropped
|
||||||
|
/// with `arg0 = 0`, which its `_start` ignores (it reads argv off the stack).
|
||||||
|
extern fn jump_to_user_arg(rip: u64, rsp: u64, arg0: u64) callconv(.c) noreturn;
|
||||||
|
|
||||||
|
pub fn jumpToUserArg(entry: u64, stack_top: u64, arg0: u64) noreturn {
|
||||||
|
jump_to_user_arg(entry, stack_top, arg0);
|
||||||
|
}
|
||||||
|
|
||||||
/// Route CPU exceptions to `handler`, which receives the trap frame and does not
|
/// Route CPU exceptions to `handler`, which receives the trap frame and does not
|
||||||
/// return. Until set, faults just halt the core.
|
/// return. Until set, faults just halt the core.
|
||||||
pub fn setFaultHandler(handler: *const fn (*const CpuState) noreturn) void {
|
pub fn setFaultHandler(handler: *const fn (*const CpuState) noreturn) void {
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,22 @@ jump_to_user:
|
||||||
swapgs # user GS base (isr_common/syscall swap back on entry)
|
swapgs # user GS base (isr_common/syscall swap back on entry)
|
||||||
iretq
|
iretq
|
||||||
|
|
||||||
|
# jump_to_user_arg(rdi = user rip, rsi = user rsp, rdx = user rdi/arg0): as
|
||||||
|
# jump_to_user, but delivers arg0 in the user's rdi — how a fresh **thread**
|
||||||
|
# receives its closure pointer (docs/threading.md). rdi carries the rip only until
|
||||||
|
# it is pushed into the iretq frame, after which we overwrite it with the arg.
|
||||||
|
.global jump_to_user_arg
|
||||||
|
jump_to_user_arg:
|
||||||
|
cli
|
||||||
|
push $0x1B # user SS (0x18 | RPL 3)
|
||||||
|
push %rsi # user RSP
|
||||||
|
push $0x202 # RFLAGS: IF | reserved-1
|
||||||
|
push $0x23 # user CS (0x20 | RPL 3)
|
||||||
|
push %rdi # user RIP (consumes rdi)
|
||||||
|
mov %rdx, %rdi # user rdi = arg0 (the thread's closure pointer)
|
||||||
|
swapgs # user GS base (isr_common/syscall swap back on entry)
|
||||||
|
iretq
|
||||||
|
|
||||||
# --- ring 3 entry/exit ------------------------------------------------------
|
# --- ring 3 entry/exit ------------------------------------------------------
|
||||||
|
|
||||||
# enter_user(rdi = user rip, rsi = user rsp, rdx = &TSS.rsp0)
|
# enter_user(rdi = user rip, rsi = user rsp, rdx = &TSS.rsp0)
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,16 @@ fn system_call(state: *architecture.CpuState) void {
|
||||||
.shm_create => systemShmCreate(state),
|
.shm_create => systemShmCreate(state),
|
||||||
.shm_map => systemShmMap(state),
|
.shm_map => systemShmMap(state),
|
||||||
.shm_physical => systemShmPhysical(state),
|
.shm_physical => systemShmPhysical(state),
|
||||||
|
.thread_spawn => systemThreadSpawn(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
|
||||||
|
// space survives while sibling threads hold it). docs/threading.md.
|
||||||
|
if (scheduler.currentIsUserProcess()) {
|
||||||
|
scheduler.current().exit_reason = .exited;
|
||||||
|
terminateCurrent();
|
||||||
|
} else architecture.userExit();
|
||||||
|
},
|
||||||
_ => fail(state),
|
_ => fail(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -642,6 +652,23 @@ fn systemSpawn(state: *architecture.CpuState) void {
|
||||||
fail(state); // no bundled binary by that name
|
fail(state); // no bundled binary by that name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// thread_spawn(entry, stack_top, arg) -> tid: start a task that shares the **caller's**
|
||||||
|
/// address space (docs/threading.md). The runtime supplies `entry` (its thread
|
||||||
|
/// trampoline), a stack it mmap'd, and the closure pointer, which the kernel delivers in
|
||||||
|
/// the new thread's rdi. The entry and stack must lie in the user half; the new thread is
|
||||||
|
/// supervised by the caller and inherits its priority. Only a user process may spawn.
|
||||||
|
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 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);
|
||||||
|
architecture.setSystemCallResult(state, tid);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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`
|
||||||
|
|
@ -1433,7 +1460,7 @@ pub fn spawnProcessSupervised(image: []const u8, priority: u3, argv: []const []c
|
||||||
architecture.mapUserPageInto(aspace, page_virtual, stack_frame, true, false); // RW + NX
|
architecture.mapUserPageInto(aspace, page_virtual, stack_frame, true, false); // RW + NX
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = scheduler.spawnUserLocked(aspace, parsed.entry, user_sp, priority, argv[0], supervisor, if (exit_endpoint) |endpoint| @ptrCast(endpoint) else null) orelse
|
const child = scheduler.spawnUserLocked(aspace, parsed.entry, user_sp, 0, priority, argv[0], supervisor, if (exit_endpoint) |endpoint| @ptrCast(endpoint) else null) orelse
|
||||||
return error.OutOfMemory;
|
return error.OutOfMemory;
|
||||||
// The child holds a reference to its exit endpoint from birth to death. Taken
|
// The child holds a reference to its exit endpoint from birth to death. Taken
|
||||||
// only now, after nothing can fail; the lock is still held, so the child
|
// only now, after nothing can fail; the lock is still held, so the child
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ pub const Task = struct {
|
||||||
aspace: u64 = 0,
|
aspace: u64 = 0,
|
||||||
user_ip: u64 = 0, // user-mode entry point (user task only)
|
user_ip: u64 = 0, // user-mode entry point (user task only)
|
||||||
user_sp: u64 = 0, // user-mode stack pointer (user task only)
|
user_sp: u64 = 0, // user-mode stack pointer (user task only)
|
||||||
|
user_arg: u64 = 0, // value delivered in the user's rdi at first entry: 0 for a
|
||||||
|
// process (its _start ignores it), the closure pointer for a thread (docs/threading.md)
|
||||||
// Next free virtual address in this task's mmap grant arena (0 = uninitialised;
|
// Next free virtual address in this task's mmap grant arena (0 = uninitialised;
|
||||||
// process.zig lazily seeds it to the arena base on the first mmap). Bumped up
|
// process.zig lazily seeds it to the arena base on the first mmap). Bumped up
|
||||||
// as the user heap grows; user task only.
|
// as the user heap grows; user task only.
|
||||||
|
|
@ -388,7 +390,7 @@ pub fn spawnOn(entry: *const fn () void, priority: Priority, cpu: u32) bool {
|
||||||
/// out of memory.
|
/// out of memory.
|
||||||
/// **Caller must hold the kernel lock** (the loader that builds `aspace` holds it
|
/// **Caller must hold the kernel lock** (the loader that builds `aspace` holds it
|
||||||
/// across the whole spawn, so the address space and the task appear atomically).
|
/// across the whole spawn, so the address space and the task appear atomically).
|
||||||
pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority, task_name: []const u8, supervisor: u32, exit_endpoint: ?*anyopaque) ?u32 {
|
pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, user_arg: u64, priority: Priority, task_name: []const u8, supervisor: u32, exit_endpoint: ?*anyopaque) ?u32 {
|
||||||
const t = freeSlot() orelse return null;
|
const t = freeSlot() orelse return null;
|
||||||
const stack = heap.allocator().alloc(u8, stack_size) catch return null;
|
const stack = heap.allocator().alloc(u8, stack_size) catch return null;
|
||||||
// Take this task's reference to the address space before we commit the slot, so a
|
// Take this task's reference to the address space before we commit the slot, so a
|
||||||
|
|
@ -405,6 +407,7 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority
|
||||||
.aspace = aspace,
|
.aspace = aspace,
|
||||||
.user_ip = entry,
|
.user_ip = entry,
|
||||||
.user_sp = user_sp,
|
.user_sp = user_sp,
|
||||||
|
.user_arg = user_arg,
|
||||||
.supervisor = supervisor,
|
.supervisor = supervisor,
|
||||||
.exit_endpoint = exit_endpoint,
|
.exit_endpoint = exit_endpoint,
|
||||||
};
|
};
|
||||||
|
|
@ -421,6 +424,17 @@ pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority
|
||||||
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
|
||||||
|
|
@ -430,7 +444,7 @@ fn startUserTask() void {
|
||||||
// No serial chatter here: this runs on every spawn, unserialized against
|
// No serial chatter here: this runs on every spawn, unserialized against
|
||||||
// user-space writes, and its output used to shear concurrent log lines in
|
// user-space writes, and its output used to shear concurrent log lines in
|
||||||
// half — the largest source of corrupted markers in the QEMU scenarios.
|
// half — the largest source of corrupted markers in the QEMU scenarios.
|
||||||
architecture.jumpToUser(t.user_ip, t.user_sp); // noreturn
|
architecture.jumpToUserArg(t.user_ip, t.user_sp, t.user_arg); // noreturn (arg0 = 0 for a process)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unlocked task-creation primitive. Caller must hold the kernel lock (or be the
|
/// The unlocked task-creation primitive. Caller must hold the kernel lock (or be the
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
||||||
faultRecoveryTest(boot_information);
|
faultRecoveryTest(boot_information);
|
||||||
} else if (eql(case, "aspace-refcount")) {
|
} else if (eql(case, "aspace-refcount")) {
|
||||||
aspaceRefcountTest(boot_information);
|
aspaceRefcountTest(boot_information);
|
||||||
|
} else if (eql(case, "thread-spawn")) {
|
||||||
|
threadSpawnTest(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")) {
|
||||||
|
|
@ -1376,7 +1378,7 @@ fn spawnFaultingProcess() ?u32 {
|
||||||
architecture.mapUserPageInto(aspace, process.stack_base_virtual, stack_frame, true, false); // RW + NX
|
architecture.mapUserPageInto(aspace, process.stack_base_virtual, stack_frame, true, false); // RW + NX
|
||||||
|
|
||||||
// Supervised by the calling test task, so exitReasonOf can read the verdict.
|
// Supervised by the calling test task, so exitReasonOf can read the verdict.
|
||||||
const id = scheduler.spawnUserLocked(aspace, process.code_virtual, process.stack_base_virtual + abi.page_size, 4, "fault-probe", scheduler.currentId(), null) orelse {
|
const id = scheduler.spawnUserLocked(aspace, process.code_virtual, process.stack_base_virtual + abi.page_size, 0, 4, "fault-probe", scheduler.currentId(), null) orelse {
|
||||||
architecture.destroyAddressSpace(aspace);
|
architecture.destroyAddressSpace(aspace);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
@ -1466,6 +1468,42 @@ fn aspaceRefcountTest(boot_information: *const BootInformation) void {
|
||||||
result();
|
result();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thread spawn (docs/threading-plan.md M2): the `thread-test` service spawns a worker
|
||||||
|
/// thread that writes a shared global; the main thread, polling that memory, observes the
|
||||||
|
/// write — proving `runtime.Thread.spawn` started a task in the **same** address space
|
||||||
|
/// (a separate process could not touch it). The service's own marker is the verdict.
|
||||||
|
fn threadSpawnTest(boot_information: *const BootInformation) void {
|
||||||
|
log("DANOS-TEST-BEGIN: thread-spawn\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;
|
||||||
|
};
|
||||||
|
|
||||||
|
check("thread-test spawned", spawnNamed(rd, "thread-test"));
|
||||||
|
|
||||||
|
// Wait for the service's verdict marker (it polls shared memory the worker wrote).
|
||||||
|
const ok_marker = "thread-test: child ran in shared aspace ok";
|
||||||
|
const fail_marker = "thread-test: FAIL";
|
||||||
|
scheduler.setPriority(1);
|
||||||
|
const deadline = architecture.millis() + 12000;
|
||||||
|
while (architecture.millis() < deadline) {
|
||||||
|
if (bufferHas(ok_marker) or bufferHas(fail_marker)) break;
|
||||||
|
scheduler.yield();
|
||||||
|
}
|
||||||
|
scheduler.setPriority(4);
|
||||||
|
|
||||||
|
check("a worker thread ran in the shared address space (shared write observed)", bufferHas(ok_marker));
|
||||||
|
check("the thread path reported no failure", !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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -300,6 +300,13 @@ 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 M2: runtime.Thread.spawn — a worker thread runs in the
|
||||||
|
# caller's address space (a shared-memory write, observed by the main thread).
|
||||||
|
{"name": "thread-spawn",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue