threads(M4): futex_wait/futex_wake, the blocking primitive

New private syscalls futex_wait(addr, expected, timeout_ns)=40 and
futex_wake(addr, count)=41. A waiter is a .blocked task tagged with
Task.futex_addr (no queue linkage); futex_wait reads the user word under the big
lock and parks only if it still equals expected, so a concurrent wake can't slip
between the check and the block. futex_wake scans the task table and readies up
to count waiters in the same address space. A timed wait also sets wake_at so the
existing wakeExpired times it out; futex_addr staying non-zero (only futex_wake
clears it) distinguishes timeout from a real wake. Waiters park in-kernel, so an
idle core still halts (no busy-wait).

runtime.Thread.Futex mirrors std.Thread.Futex (wait/timedWait/wake). thread-test
gains a futex mode: a waiter parks, the main thread wakes it (serial order
waiting/waking/woke, asserted by the case regex), and timedWait reports a
timeout.

Gate thread-futex PASS (3x); 18 guardrail cases green incl. sleep/event/ipc
blocking paths; build + host tests clean.
This commit is contained in:
2026-07-20 21:30:57 +01:00
parent 0730e77530
commit b3a8147bd7
8 changed files with 272 additions and 14 deletions
+49
View File
@@ -229,6 +229,8 @@ fn system_call(state: *architecture.CpuState) void {
.shm_physical => systemShmPhysical(state),
.thread_spawn => systemThreadSpawn(state),
.current_core => systemCurrentCore(state),
.futex_wait => systemFutexWait(state),
.futex_wake => systemFutexWake(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
@@ -692,6 +694,53 @@ fn systemCurrentCore(state: *architecture.CpuState) void {
architecture.setSystemCallResult(state, scheduler.currentCpuIndex());
}
/// futex_wait(addr, expected, timeout_ns) -> status (docs/threading.md): if the 4-byte
/// user word at `addr` still equals `expected`, block until a futex_wake on `addr` or
/// (if timeout_ns > 0) the deadline. The compare and the block are one critical section,
/// so a concurrent futex_wake cannot slip between them. Returns futex_woken / mismatch /
/// timed_out.
fn systemFutexWait(state: *architecture.CpuState) void {
const addr = architecture.systemCallArg(state, 0);
const expected: u32 = @truncate(architecture.systemCallArg(state, 1));
const timeout_ns = architecture.systemCallArg(state, 2);
const t = scheduler.current();
if (t.aspace == 0) return fail(state);
if (addr == 0 or (addr & 3) != 0 or addr + 4 > user_half_end) return fail(state);
const flags = sync.enter();
var word_bytes: [4]u8 = undefined;
if (!ipc.copyFromUser(t.aspace, addr, &word_bytes)) {
sync.leave(flags);
return fail(state);
}
if (std.mem.readInt(u32, &word_bytes, .little) != expected) {
sync.leave(flags);
architecture.setSystemCallResult(state, abi.futex_mismatch);
return;
}
const timeout_ms = if (timeout_ns == 0) 0 else (timeout_ns + 999_999) / 1_000_000;
const result = scheduler.futexWaitLocked(addr, timeout_ms);
sync.leave(flags);
architecture.setSystemCallResult(state, switch (result) {
.woken => abi.futex_woken,
.timed_out => abi.futex_timed_out,
});
}
/// futex_wake(addr, count) -> woken: wake up to `count` tasks blocked in futex_wait on
/// `addr` in the caller's address space.
fn systemFutexWake(state: *architecture.CpuState) void {
const addr = architecture.systemCallArg(state, 0);
const count: u32 = @truncate(architecture.systemCallArg(state, 1));
const t = scheduler.current();
if (t.aspace == 0) return fail(state);
if (addr == 0 or (addr & 3) != 0 or addr + 4 > user_half_end) return fail(state);
const flags = sync.enter();
const woken = scheduler.futexWakeLocked(t.aspace, addr, count);
sync.leave(flags);
architecture.setSystemCallResult(state, woken);
}
/// 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`
+46
View File
@@ -80,6 +80,9 @@ pub const Task = struct {
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)
// The user address this task is blocked on in futex_wait (0 = not futex-waiting).
// Cleared to 0 by futexWakeLocked as the "woken, not timed out" signal (docs/threading.md).
futex_addr: u64 = 0,
// 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
// as the user heap grows; user task only.
@@ -516,6 +519,49 @@ pub fn sleep(ms: u64) void {
sync.leave(flags);
}
// --- futex: block/wake on a user address (docs/threading.md) ----------------
//
// A futex waiter is not linked into any queue — it is simply a `.blocked` task
// tagged with the address it waits on (`futex_addr`). Waking scans the task table
// (bounded) for matching waiters. A timed wait also sets `wake_at`, so the timer's
// `wakeExpired` can wake it; `futex_addr` stays non-zero in that case, which is how
// the waiter tells a timeout from a real wake.
pub const FutexResult = enum { woken, timed_out };
/// Block the current task on futex `addr` until woken, or (if `timeout_ms > 0`) the
/// deadline. **Precondition:** the big kernel lock is held and the caller has already
/// checked, under this same lock, that the futex word equals the expected value — so
/// no wake can be missed. Returns with the lock still held.
pub fn futexWaitLocked(addr: u64, timeout_ms: u64) FutexResult {
const t = current();
t.futex_addr = addr;
t.wake_at = if (timeout_ms > 0) architecture.millis() + timeout_ms else 0;
t.state = .blocked;
schedule(); // woken by futexWakeLocked (clears futex_addr) or wakeExpired (timeout)
const woken = t.futex_addr == 0;
t.futex_addr = 0;
t.wake_at = 0;
return if (woken) .woken else .timed_out;
}
/// Wake up to `count` tasks blocked in `futex_wait` on `addr` in address space
/// `aspace`. Precondition: the big kernel lock is held. Returns how many woke.
pub fn futexWakeLocked(aspace: u64, addr: u64, count: u32) u32 {
var woken: u32 = 0;
for (&tasks) |*t| {
if (woken >= count) break;
if (t.state == .blocked and t.aspace == aspace and t.futex_addr == addr) {
t.futex_addr = 0; // the "woken, not timed out" signal to futexWaitLocked
t.wake_at = 0;
t.state = .ready;
enqueue(t);
woken += 1;
}
}
return woken;
}
// --- event-based blocking -------------------------------------------------
//
// A WaitQueue is a set of tasks blocked waiting for something (a resource, a
+49
View File
@@ -145,6 +145,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
threadSpawnTest(boot_information);
} else if (eql(case, "thread-join")) {
threadJoinTest(boot_information);
} else if (eql(case, "thread-futex")) {
threadFutexTest(boot_information);
} else if (eql(case, "args")) {
argsTest(boot_information);
} else if (eql(case, "init")) {
@@ -1551,6 +1553,53 @@ fn threadJoinTest(boot_information: *const BootInformation) void {
result();
}
/// Futex (docs/threading-plan.md M4): `thread-test` in futex mode has a waiter thread
/// block in `futex_wait` on a word; the main thread publishes the word and `futex_wake`s
/// it. The serial order `waiting → waking → woke` shows the kernel handoff (the waiter
/// parked and was woken, not spun), and a `timedWait` on an unwoken word reports a
/// timeout. The verdict marker is emitted only after both hold.
fn threadFutexTest(boot_information: *const BootInformation) void {
log("DANOS-TEST-BEGIN: thread-futex\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;
};
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", "futex" })) true else |_| false;
break;
}
check("thread-test (futex mode) spawned", started);
const ok_marker = "thread-futex: ok";
const fail_marker = "thread-futex: 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);
// Only the freshest marker is checked here — the kernel's log ring buffer may have
// evicted the earlier ones by now. The waiting/waking/woke ordering (the handoff
// proof) is asserted against the full serial stream by the qemu case's regex; the
// verdict marker is emitted by thread-test only after the wake AND the timeout hold.
check("futex handoff + timeout completed (verdict reached, no failure)", bufferHas(ok_marker) and !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