threads(M10): per-thread fs.base — the TLS thread-pointer mechanism

Each thread gets its own x86_64 thread pointer (FS base) for user-space TLS.
Task.fs_base is restored on every context switch only when it changes (same
conditional-load discipline as CR3; architecture.setFsBase -> wrmsr
IA32_FS_BASE). New set_thread_pointer=44 syscall sets the caller's fs_base and
loads it now. The kernel never touches FS, so no swapgs complication.

The runtime lays a small per-thread TLS block at the top of each thread's stack
(self-pointer at %fs:0 + scratch) and the thread trampoline calls
set_thread_pointer before any user code — so every spawned thread has a private,
switch-stable thread pointer, reclaimed with the stack.

thread-test tls mode: two threads write unique markers to their own %fs:8 and,
after both wrote, read back — a shared fs.base would clobber one (cross-talk).

Deferred: the Zig threadlocal *compiler* layer (ELF variant-II PT_TLS + linker
sections + template copy) — high-uncertainty, no consumer today; this lands the
load-bearing per-thread fs.base it builds on. See docs/threading-plan.md M10.

Gate thread-tls PASS (3x); full guardrail 25/25; build + host tests clean.
This commit is contained in:
2026-07-20 23:55:51 +01:00
parent 6bc329456a
commit c7e9b5a4f6
9 changed files with 207 additions and 21 deletions
@@ -357,6 +357,59 @@ fn runAllocMode() void {
write("thread-alloc: ok\n"); // the M7 verdict marker
}
// --- M10: tls mode (per-thread fs.base storage) -----------------------------
fn writeTlsSlot(value: u64) void {
asm volatile ("movq %[v], %%fs:8"
:
: [v] "r" (value),
: .{ .memory = true });
}
fn readTlsSlot() u64 {
return asm volatile ("movq %%fs:8, %[out]"
: [out] "=r" (-> u64),
:
: .{ .memory = true });
}
var tls_written = std.atomic.Value(u32).init(0);
var tls_ok = std.atomic.Value(u32).init(0);
fn tlsWorker(marker: u64) void {
writeTlsSlot(marker);
_ = tls_written.fetchAdd(1, .release);
// Wait until both threads have written their own slot. If fs.base were shared, the
// second write would clobber the first, and the read below would return the wrong
// marker — cross-talk. Per-thread fs.base keeps each thread's slot private.
var spins: usize = 0;
while (tls_written.load(.acquire) < 2 and spins < 50_000_000) : (spins += 1) {
runtime.system.yield();
}
if (readTlsSlot() == marker and runtime.Thread.getCurrentId() != 0) {
_ = tls_ok.fetchAdd(1, .monotonic);
}
}
fn runTlsMode() void {
write("thread-tls: starting\n");
const t0 = runtime.Thread.spawn(.{}, tlsWorker, .{@as(u64, 0xAAAA_0000)}) catch {
write("thread-tls: FAIL spawn\n");
return;
};
const t1 = runtime.Thread.spawn(.{}, tlsWorker, .{@as(u64, 0xBBBB_0000)}) catch {
write("thread-tls: FAIL spawn\n");
return;
};
t0.join();
t1.join();
if (tls_ok.load(.acquire) == 2) {
write("thread-tls: ok\n"); // the M10 verdict marker
} else {
write("thread-tls: FAIL cross-talk (fs.base not per-thread)\n");
}
}
pub fn main(init: runtime.process.Init) void {
const mode = init.arguments.get(1) orelse "spawn";
if (std.mem.eql(u8, mode, "join")) {
@@ -369,6 +422,8 @@ pub fn main(init: runtime.process.Init) void {
runIdMode();
} else if (std.mem.eql(u8, mode, "alloc")) {
runAllocMode();
} else if (std.mem.eql(u8, mode, "tls")) {
runTlsMode();
} else {
runSpawnMode();
}