threads(M1): address-space reference counting

Route address-space lifetime through a refcount keyed by the page-table root
(scheduler.zig aspace_refs): retainAspace on the spawnUserLocked success path,
releaseAspace from both teardown paths (exitUserLocked, destroyTaskLocked),
destroying the space only when the last task on it exits. Behaviour is identical
today (every space has exactly one task); this is the foundation shared-address-
space threads (docs/threading.md) build on.

Test-observable liveAspaceCount/aspaceDestroyCount + a new aspace-refcount kernel
self-test and QEMU case: spawn and reap 5 ring-3 probes, assert live spaces return
to baseline and destructions advance by exactly 5 (destroyed once each, no leak,
no double-free). Gate passes; 13 guardrail cases green; build + host tests clean.
This commit is contained in:
2026-07-20 20:47:37 +01:00
parent 6e8b02d771
commit 11e363896f
4 changed files with 132 additions and 14 deletions
+69 -2
View File
@@ -136,6 +136,67 @@ pub const ipc_maximum_handles = 16;
pub const HandleObject = struct { kind: u8, ptr: *anyopaque };
var tasks = [_]Task{.{}} ** maximum_tasks;
/// Address-space reference counts: one live entry per address space, counting the
/// tasks that share it. An address space is 1:1 with a process today; threads
/// (docs/threading.md) will push a count above 1, and `destroyAddressSpace` must run
/// only when the **last** task on an address space exits. All access is under the big
/// kernel lock. There can be no more live address spaces than tasks, so the table is
/// sized to the task pool and never overflows in practice.
const AspaceRef = struct { root: u64 = 0, count: u32 = 0 };
var aspace_refs = [_]AspaceRef{.{}} ** maximum_tasks;
var aspace_destroy_count: u64 = 0;
/// Take a reference to address space `root` (0 = a kernel task, which owns none).
/// Returns false only if the ref table is full — bounded by `maximum_tasks`, so in
/// practice it never is. Caller holds the kernel lock.
fn retainAspace(root: u64) bool {
if (root == 0) return true;
var free: ?*AspaceRef = null;
for (&aspace_refs) |*entry| {
if (entry.count != 0 and entry.root == root) {
entry.count += 1;
return true;
}
if (entry.count == 0 and free == null) free = entry;
}
const slot = free orelse return false;
slot.* = .{ .root = root, .count = 1 };
return true;
}
/// Drop a reference to `root`; destroy the address space when the **last** one drops.
/// A `root` with no entry — never retained, e.g. a hand-built test space — is
/// destroyed directly, preserving the pre-refcount behaviour. Caller holds the lock.
fn releaseAspace(root: u64) void {
if (root == 0) return;
for (&aspace_refs) |*entry| {
if (entry.count == 0 or entry.root != root) continue;
entry.count -= 1;
if (entry.count == 0) {
entry.root = 0;
architecture.destroyAddressSpace(root);
aspace_destroy_count += 1;
}
return;
}
architecture.destroyAddressSpace(root);
aspace_destroy_count += 1;
}
/// Test-observable: how many address spaces are live (entries with a nonzero count).
pub fn liveAspaceCount() u32 {
var live: u32 = 0;
for (&aspace_refs) |*entry| {
if (entry.count != 0) live += 1;
}
return live;
}
/// Test-observable: total address-space destructions since boot.
pub fn aspaceDestroyCount() u64 {
return aspace_destroy_count;
}
var next_id: u32 = 1;
/// Per-CPU scheduler state: the task each core is running, its own idle task, and a
@@ -330,6 +391,12 @@ pub fn spawnOn(entry: *const fn () void, priority: Priority, cpu: u32) bool {
pub fn spawnUserLocked(aspace: u64, entry: u64, user_sp: u64, priority: Priority, task_name: []const u8, supervisor: u32, exit_endpoint: ?*anyopaque) ?u32 {
const t = freeSlot() orelse 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
// failure here leaves nothing to unwind (the caller still owns the raw `aspace`).
if (!retainAspace(aspace)) {
heap.allocator().free(stack);
return null;
}
t.* = .{
.id = next_id,
.state = .ready,
@@ -720,7 +787,7 @@ pub fn exitUserLocked() noreturn {
const kroot = architecture.kernelPageTable();
architecture.loadPageTable(kroot); // off the process tables before freeing them
pc.loaded_aspace = kroot;
architecture.destroyAddressSpace(as);
releaseAspace(as); // destroys only when this was the last task on the space
}
dying.state = .free;
dying.aspace = 0;
@@ -741,7 +808,7 @@ pub fn exitUserLocked() noreturn {
/// task isn't running). The kernel stack is leaked, as in `exitUser` (no reaper
/// yet). Precondition: the big kernel lock is held.
pub fn destroyTaskLocked(t: *Task) void {
if (t.aspace != 0) architecture.destroyAddressSpace(t.aspace);
if (t.aspace != 0) releaseAspace(t.aspace); // destroys only on the last reference
t.aspace = 0;
t.kill_pending = false;
t.in_system_call = false;