diff --git a/docs/README.md b/docs/README.md index f763866..5778206 100644 --- a/docs/README.md +++ b/docs/README.md @@ -104,12 +104,13 @@ Start with the north star: port to **one seam** (`std.os.danos`), so we build `runtime.os` (→ that seam) plus a thin `runtime.fs`, retire the `posix` shim, and follow a phased path to `zig build-exe hello.zig` running on danos — **not** Linux-ABI emulation. -- **[threading.md](threading.md) — threads, the std-shaped way.** A design note (not - built yet) on `runtime.Thread`: a type that mirrors `std.Thread`'s API (spawn/join, - Mutex/Condition) over a **private** thread ABI — several tasks sharing one address - space via a `thread_spawn` syscall, futex-backed blocking, aspace refcounting. Why - it's the native type and not literal `std.Thread` (the [private ABI](syscall.md)), - and why threads stay a narrow opt-in against the [resilience](resilience.md) default. +- **[threading.md](threading.md) — threads, the std-shaped way.** **Built** (M1–M6): + `runtime.Thread` mirrors `std.Thread`'s API (spawn/join/detach, Mutex/Condition/ + Semaphore) over a **private** thread ABI — several tasks sharing one address space via + a `thread_spawn` syscall, futex-backed blocking, aspace refcounting. Why it's the + native type and not literal `std.Thread` (the [private ABI](syscall.md)), and why + threads stay a narrow opt-in against the [resilience](resilience.md) default. Build + plan + gates: [threading-plan.md](threading-plan.md). - **[vdso.md](vdso.md) — the vDSO, the public system-call boundary.** A design note (not built yet) on keeping `abi.zig` genuinely private: a kernel-supplied, C-ABI entry blob mapped into every process as the *only* way into the kernel — so the diff --git a/docs/threading-plan.md b/docs/threading-plan.md index e46f884..04a0708 100644 --- a/docs/threading-plan.md +++ b/docs/threading-plan.md @@ -240,20 +240,38 @@ green. > `thread-mutex` gate exercises them under real concurrency instead; a host-side mock is > future work. -## M6 — TLS, `getCurrentId` polish, docs, and CI wiring +## M6 — `getCurrentId`, docs, and CI wiring ✅ -- [ ] Per-thread TLS: `thread_spawn` sets `fs.base` to a runtime-allocated per-thread - block; enough for `getCurrentId` and a `threadlocal` slot. Only expanded if a - consumer needs full `threadlocal`. -- [ ] `RwLock` / `WaitGroup` if a consumer wants them (else deferred). -- [ ] All `thread-*` cases wired into [test/qemu_test.py](../test/qemu_test.py); - threading.md status line updated to "built"; a short worked example in the doc. -- [ ] `-Dtest-case=thread-tls`: two threads read `getCurrentId` (distinct) and each - writes its own `threadlocal` slot without cross-talk. +- [x] `getCurrentId` via a small `thread_self = 42` syscall (`runtime.Thread.getCurrentId` + returns the kernel task id). **Per-thread `threadlocal` TLS is deferred** — no + consumer needs it, and it would require context-switching `fs.base` per task (real + kernel + per-switch cost) for an unused feature; threaded binaries have run fine + without it through M2–M5. threading.md's TLS reasoning already scoped it as + deferred-unless-needed. When a consumer appears, the shape is: `thread_spawn` + allocates a per-thread TLS block, sets `fs.base`, and the context switch saves/ + restores it. +- [x] `RwLock` / `WaitGroup` deferred (no consumer yet); they slot onto the same + `Futex`/`Mutex`/`Condition` when wanted. +- [x] All `thread-*` cases wired into [test/qemu_test.py](../test/qemu_test.py) + (`thread-spawn`/`-join`/`-futex`/`-mutex`/`-id`); threading.md + docs/README.md + status updated to **built**; the worked example is threading.md's win-condition. +- [x] `-Dtest-case=thread-id` (`smp: 4`): two workers read `getCurrentId`; the main + thread confirms all three ids are non-zero and distinct — each thread has its own + kernel identity. (Renamed from `thread-tls`, which implied `threadlocal`.) -**Gate:** `python3 test/qemu_test.py thread-tls` logs `thread: per-thread id + tls ok`; -the whole `thread-*` suite plus the full guardrail set pass; default `zig build` is -clean and `zig build test` is green. +**Gate (met):** `python3 test/qemu_test.py thread-id` passes; the whole `thread-*` suite +(`thread-spawn`/`-join`/`-futex`/`-mutex`/`-id`) plus the full guardrail set pass; default +`zig build` clean, `zig build test` green. + +--- + +## Status: built + +M1–M6 complete. danos has `runtime.Thread` — `spawn`/`join`/`detach`, cross-core +parallelism, futex, and `Mutex`/`Condition`/`Semaphore`, all over a private thread ABI +behind the runtime. Deferred (with rationale, no consumer yet): `threadlocal` TLS, +`RwLock`/`WaitGroup`, kernel clear-on-exit for a futex-completion `join`, a per-aspace +mmap arena / thread-safe runtime heap, and host-side unit tests via a mockable `Futex`. --- diff --git a/docs/threading.md b/docs/threading.md index 4a9a828..abcd350 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -1,12 +1,15 @@ # Threading: `runtime.Thread`, a std-shaped API over a private thread ABI -A design note (not built yet) for giving danos **threads** — several tasks sharing -one address space — through a `runtime.Thread` type that mirrors the shape of Zig's -`std.Thread` while keeping every kernel entry behind the [runtime](../library/runtime). -It is forward-looking, like [zig-self-hosting.md](zig-self-hosting.md) and -[vision.md](vision.md): it fixes the decisions now so the code we write bends toward -them. The analysis is against **Zig 0.16** (the pinned toolchain); `std.Thread`'s -internals move between releases, so treat upstream shapes as "0.16.x." +A note on danos **threads** — several tasks sharing one address space — provided by a +`runtime.Thread` type that mirrors the shape of Zig's `std.Thread` while keeping every +kernel entry behind the [runtime](../library/runtime). **Built** (M1–M6, see +[threading-plan.md](threading-plan.md)): `spawn`/`join`/`detach`, cross-core +parallelism, a futex (`futex_wait`/`futex_wake`), and a futex-backed +`Mutex`/`Condition`/`Semaphore`, plus `getCurrentId`/`currentCore`. Deferred by design +(no consumer yet): per-thread `threadlocal` TLS, `RwLock`/`WaitGroup`, and migrating +`join` to a futex completion word — see the plan's M5/M6 notes. The analysis is against +**Zig 0.16** (the pinned toolchain); `std.Thread`'s internals move between releases, so +treat upstream shapes as "0.16.x." ## The win condition diff --git a/library/runtime/thread.zig b/library/runtime/thread.zig index cba45e7..2f84ebb 100644 --- a/library/runtime/thread.zig +++ b/library/runtime/thread.zig @@ -100,6 +100,11 @@ pub const Thread = struct { _ = self; } + /// The calling thread's id (its kernel task id). Mirrors `std.Thread.getCurrentId`. + pub fn getCurrentId() Id { + return @intCast(sc.systemCall0(.thread_self)); + } + /// The dense 0-based index of the core the calling thread is running on. A danos /// extension beyond `std.Thread`, used to observe genuine cross-core parallelism. pub fn currentCore() Id { diff --git a/system/abi.zig b/system/abi.zig index 509f441..d536237 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -68,6 +68,7 @@ pub const SystemCall = enum(u64) { current_core = 39, // current_core() -> index: the dense 0-based index of the core the caller is running on (for parallelism/affinity introspection) futex_wait = 40, // futex_wait(addr, expected, timeout_ns) -> status: if *addr == expected, block until woken or the timeout; returns futex_woken/mismatch/timed_out (docs/threading.md) futex_wake = 41, // futex_wake(addr, count) -> woken: wake up to `count` tasks blocked in futex_wait on `addr` in this address space + thread_self = 42, // thread_self() -> tid: the calling thread's kernel task id (runtime.Thread.getCurrentId) _, }; diff --git a/system/kernel/process.zig b/system/kernel/process.zig index f3a8762..4fbe9c0 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -229,6 +229,7 @@ fn system_call(state: *architecture.CpuState) void { .shm_physical => systemShmPhysical(state), .thread_spawn => systemThreadSpawn(state), .current_core => systemCurrentCore(state), + .thread_self => systemThreadSelf(state), .futex_wait => systemFutexWait(state), .futex_wake => systemFutexWake(state), .thread_exit => { @@ -694,6 +695,11 @@ fn systemCurrentCore(state: *architecture.CpuState) void { architecture.setSystemCallResult(state, scheduler.currentCpuIndex()); } +/// thread_self() -> tid: the calling thread's kernel task id. +fn systemThreadSelf(state: *architecture.CpuState) void { + architecture.setSystemCallResult(state, scheduler.currentId()); +} + /// 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, diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 130553c..d1c0f61 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -149,6 +149,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { threadFutexTest(boot_information); } else if (eql(case, "thread-mutex")) { threadMutexTest(boot_information); + } else if (eql(case, "thread-id")) { + threadIdTest(boot_information); } else if (eql(case, "args")) { argsTest(boot_information); } else if (eql(case, "init")) { @@ -1646,6 +1648,47 @@ fn threadMutexTest(boot_information: *const BootInformation) void { result(); } +/// Thread identity (docs/threading-plan.md M6): `thread-test` in id mode spawns two +/// workers that each read `runtime.Thread.getCurrentId`; the main thread confirms all +/// three ids are non-zero and distinct — proof each thread has its own kernel identity. +fn threadIdTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: thread-id\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", "id" })) true else |_| false; + break; + } + check("thread-test (id mode) spawned", started); + + const ok_marker = "thread-id: ok"; + const fail_marker = "thread-id: 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("each thread has a distinct, non-zero getCurrentId", 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 diff --git a/system/services/thread-test/thread-test.zig b/system/services/thread-test/thread-test.zig index e166bd7..bd15643 100644 --- a/system/services/thread-test/thread-test.zig +++ b/system/services/thread-test/thread-test.zig @@ -257,6 +257,43 @@ fn runMutexMode() void { write("thread-mutex: ok\n"); // the M5 verdict marker } +// --- M6: id mode (getCurrentId identity) ------------------------------------ + +var worker_ids: [2]std.atomic.Value(u32) = .{ std.atomic.Value(u32).init(0), std.atomic.Value(u32).init(0) }; + +fn idWorker(slot: usize) void { + worker_ids[slot].store(runtime.Thread.getCurrentId(), .release); +} + +fn runIdMode() void { + write("thread-id: starting\n"); + const main_id = runtime.Thread.getCurrentId(); + + const t0 = runtime.Thread.spawn(.{}, idWorker, .{@as(usize, 0)}) catch { + write("thread-id: FAIL spawn\n"); + return; + }; + const t1 = runtime.Thread.spawn(.{}, idWorker, .{@as(usize, 1)}) catch { + write("thread-id: FAIL spawn\n"); + return; + }; + t0.join(); + t1.join(); + + const id0 = worker_ids[0].load(.acquire); + const id1 = worker_ids[1].load(.acquire); + // Each thread has its own kernel task id: all three distinct and non-zero. + if (main_id == 0 or id0 == 0 or id1 == 0) { + write("thread-id: FAIL a thread reported id 0\n"); + return; + } + if (id0 == id1 or id0 == main_id or id1 == main_id) { + write("thread-id: FAIL thread ids collided\n"); + return; + } + write("thread-id: ok\n"); // the M6 verdict marker +} + pub fn main(init: runtime.process.Init) void { const mode = init.arguments.get(1) orelse "spawn"; if (std.mem.eql(u8, mode, "join")) { @@ -265,6 +302,8 @@ pub fn main(init: runtime.process.Init) void { runFutexMode(); } else if (std.mem.eql(u8, mode, "mutex")) { runMutexMode(); + } else if (std.mem.eql(u8, mode, "id")) { + runIdMode(); } else { runSpawnMode(); } diff --git a/test/qemu_test.py b/test/qemu_test.py index 0c0b2f7..f5753ec 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -331,6 +331,14 @@ CASES = [ "timeout": 60, "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + + # docs/threading-plan.md M6: thread identity — getCurrentId is distinct and non-zero + # for the main thread and two workers. + {"name": "thread-id", + "smp": 4, + "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 # name, argv[1..] = the system_spawn argument blob) and echoes back intact. {"name": "args",