refactor: retire bespoke Task async; one stack behind context.io (Phase 5)
Converge the Io unification (PLAN-IO-UNIFY Phase 5). The bespoke fiber-task layer in sched.sx — Task / TaskState / TaskErr / go / wait / cancel(Task), plus Scheduler.task_allocs and its deinit bookkeeping (~130 lines) — is removed. There is now ONE async stack: context.io.async / await / cancel / race / sleep over the Io protocol, with the Scheduler as the fiber Io's engine + driver (spawn / yield_now / suspend_self / wake / run / block_on_fd remain as the raw primitives; race stays in sched.sx because it needs meta.sx's make_enum/make_variant). Migrated the four go/wait users to context.io: - 1813 — interleave + cancel (sequence 1 2 3 42 100 -99) - 1817 — m1 end-to-end (completion in deadline order, sum 123) - 1819 — double-AWAIT loud-abort via the Future one-awaiter guard - 1820 — deinit: dropped the go/task_allocs tasks; now exercises timers/io_waiters/ kq cleanup (freed=2, live=3 = the documented per-spawn closure-env residual) Updated readme.md (the user-facing async section documents context.io.async / await / race / sleep) and the stale sched.go/sched.Task comments in io.sx. Suite 854/0; no .ir churn (Task removal touched no snapshotted IR); migrated examples byte-identical on aarch64-macOS + aarch64-linux. PLAN-IO-UNIFY Phases 0-5 all complete — the two parallel async stacks are now one, behind context.io.
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
// the result (a value-failable `($R, !IoErr)`, handled with `or`).
|
||||
// `context.io.now_ms()` reads the clock through the same capability.
|
||||
//
|
||||
// Worker form: a nullary lambda capturing any inputs at the CALL SITE
|
||||
// (`() -> i64 => compute(a, b)`) — the colorblind shape that also works when the
|
||||
// worker is deferred onto a fiber (a captured variadic pack can't cross the fiber
|
||||
// boundary), mirroring `sched.go`.
|
||||
// Worker form: a nullary failable lambda capturing any inputs at the CALL SITE
|
||||
// (`() -> (i64, !) => compute(a, b)`) — the colorblind shape that also works when
|
||||
// the worker is deferred onto a fiber (a captured variadic pack can't cross the
|
||||
// fiber boundary).
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// Stream B1 (fibers) B1.4a — a truly-SUSPENDING fiber-task async layer
|
||||
// (`go` / `wait` / `cancel`) over the M:1 scheduler, in pure sx. In contrast
|
||||
// with 1805's `context.io.async` (which runs each worker INLINE to completion
|
||||
// before returning a `.ready` future — no interleaving), here `s.go(work)` runs
|
||||
// `work` as a REAL fiber and `t.wait()` SUSPENDS the caller until that fiber
|
||||
// finishes, so a task that yields mid-body lets a sibling task run before the
|
||||
// first completes — genuine cooperative interleaving.
|
||||
// Stream B2 — the SUSPENDING `context.io.async` layer over the M:1 fiber
|
||||
// scheduler (PLAN-IO-UNIFY: the unified async stack — the bespoke `go`/`wait` was
|
||||
// retired in Phase 5). In contrast with 1805's `context.io.async` UNDER THE
|
||||
// BLOCKING `Io` (which runs each worker INLINE to completion — no interleaving),
|
||||
// here the scheduler is installed as `context.io`, so `context.io.async(work)`
|
||||
// runs `work` as a REAL fiber and `await()` SUSPENDS the caller until it finishes
|
||||
// — a worker that yields mid-body lets a sibling run first (cooperative
|
||||
// interleaving).
|
||||
//
|
||||
// `work` is a NULLARY thunk: any inputs are captured in the lambda at the call
|
||||
// `work` is a NULLARY worker: any inputs are captured in the lambda at the call
|
||||
// site (no `..args` pack crosses the fiber boundary — that would hit issue 0156
|
||||
// Part 2). Outputs flow OUT through pointers captured in the thunk (the shared
|
||||
// Part 2). Outputs flow OUT through pointers captured in the worker (the shared
|
||||
// `Log` struct), since closure capture-by-value does not write back.
|
||||
//
|
||||
// What this proves:
|
||||
// - REAL suspend + interleave: task A records 1, YIELDS; task B then records 2
|
||||
// and completes; A resumes, records 3, completes → interleave order 1 2 3.
|
||||
// - awaited VALUES: A returns 42, B returns 100 (recorded after both waits).
|
||||
// - REAL suspend + interleave: worker A records 1, YIELDS; worker B then records
|
||||
// 2 and completes; A resumes, records 3, completes → interleave order 1 2 3.
|
||||
// - awaited VALUES: A returns 42, B returns 100 (recorded after both awaits).
|
||||
// → sequence: 1 2 3 42 100.
|
||||
// - cancel rides the `!` channel (model (a), like 1806): a canceled task's
|
||||
// `wait()` raises `.Canceled`, taken by the `or` default → -99.
|
||||
// - cancel rides the `!` channel (model (a), like 1806): a canceled worker's
|
||||
// `await()` raises `.Canceled`, taken by the `or` default → -99.
|
||||
//
|
||||
// `wait` must run inside a fiber (it parks `self.current`), so the "main task"
|
||||
// is itself a `s.spawn(...)` fiber that drives the two `go` tasks.
|
||||
@@ -38,36 +39,39 @@ main :: () -> i64 {
|
||||
ps := @s;
|
||||
pl := @lg;
|
||||
|
||||
// The "main task" fiber: drives two real tasks, waits both, then exercises
|
||||
// cancel. It runs as a fiber so `wait` has a `self.current` to park.
|
||||
s.spawn(() => {
|
||||
// Task A yields mid-body so B interleaves before A completes.
|
||||
a := ps.go(() -> i64 => {
|
||||
rec(pl, 1);
|
||||
ps.yield_now(); // suspend A; B (already spawned) runs to completion
|
||||
rec(pl, 3);
|
||||
42
|
||||
});
|
||||
// Task B runs straight through (no yield).
|
||||
b := ps.go(() -> i64 => {
|
||||
rec(pl, 2);
|
||||
100
|
||||
// The coordinator fiber: drives two async workers, awaits both, then exercises
|
||||
// cancel. It runs as a fiber so `await` has a `self.current` to park. The
|
||||
// scheduler is installed as `context.io`, so the unified async layer reaches it.
|
||||
push .{ io = xx s } {
|
||||
ps.spawn(() => {
|
||||
// Worker A yields mid-body so B interleaves before A completes.
|
||||
a := context.io.async(() -> (i64, !) => {
|
||||
rec(pl, 1);
|
||||
ps.yield_now(); // suspend A; B (already spawned) runs to completion
|
||||
rec(pl, 3);
|
||||
42
|
||||
});
|
||||
// Worker B runs straight through (no yield).
|
||||
b := context.io.async(() -> (i64, !) => {
|
||||
rec(pl, 2);
|
||||
100
|
||||
});
|
||||
|
||||
// Await both — suspends the coordinator fiber until each completes.
|
||||
va := a.await() or { -1 };
|
||||
vb := b.await() or { -1 };
|
||||
rec(pl, va);
|
||||
rec(pl, vb);
|
||||
|
||||
// Cancel case: cancel before the worker runs; `await` raises .Canceled
|
||||
// off the sticky flag, the `or` default (-99) is taken.
|
||||
c := context.io.async(() -> (i64, !) => 7);
|
||||
c.cancel();
|
||||
rec(pl, c.await() or { -99 });
|
||||
});
|
||||
|
||||
// Wait both — suspends the main-task fiber until each completes.
|
||||
va := a.wait() or { -1 };
|
||||
vb := b.wait() or { -1 };
|
||||
rec(pl, va);
|
||||
rec(pl, vb);
|
||||
|
||||
// Cancel case: cancel before the worker runs; `wait` raises .Canceled,
|
||||
// the `or` default (-99) is taken.
|
||||
c := ps.go(() -> i64 => 7);
|
||||
c.cancel();
|
||||
rec(pl, c.wait() or { -99 });
|
||||
});
|
||||
|
||||
s.run();
|
||||
ps.run();
|
||||
}
|
||||
|
||||
// Interleaving + value contract: 1 2 3 42 100, then the cancel default -99.
|
||||
print("sequence:");
|
||||
|
||||
@@ -36,22 +36,26 @@ main :: () -> i64 {
|
||||
s := sched.Scheduler.init();
|
||||
ps := @s; pl := @lg;
|
||||
|
||||
// The coordinator runs as a fiber so `wait` has a `current` to park.
|
||||
s.spawn(() => {
|
||||
// Launch three async tasks; each sleeps, logs its completion, returns.
|
||||
a := ps.go(() -> i64 => { ps.sleep(30); rec(pl, 1, ps.now_ms()); 100 });
|
||||
b := ps.go(() -> i64 => { ps.sleep(10); rec(pl, 2, ps.now_ms()); 20 });
|
||||
c := ps.go(() -> i64 => { ps.sleep(20); rec(pl, 3, ps.now_ms()); 3 });
|
||||
// The coordinator runs as a fiber so `await` has a `current` to park. The
|
||||
// scheduler is installed as `context.io`, so the unified async layer
|
||||
// (`context.io.async`/`await`/`sleep`/`now_ms`) reaches it inside the workers.
|
||||
push .{ io = xx s } {
|
||||
ps.spawn(() => {
|
||||
// Launch three async workers; each sleeps, logs its completion, returns.
|
||||
a := context.io.async(() -> (i64, !) => { try context.io.sleep(30); rec(pl, 1, context.io.now_ms()); 100 });
|
||||
b := context.io.async(() -> (i64, !) => { try context.io.sleep(10); rec(pl, 2, context.io.now_ms()); 20 });
|
||||
c := context.io.async(() -> (i64, !) => { try context.io.sleep(20); rec(pl, 3, context.io.now_ms()); 3 });
|
||||
|
||||
// Await in SPAWN order; results come back correct regardless.
|
||||
va := a.wait() or { -1 };
|
||||
vb := b.wait() or { -1 };
|
||||
vc := c.wait() or { -1 };
|
||||
sum := va + vb + vc;
|
||||
// Await in SPAWN order; results come back correct regardless.
|
||||
va := a.await() or { -1 };
|
||||
vb := b.await() or { -1 };
|
||||
vc := c.await() or { -1 };
|
||||
sum := va + vb + vc;
|
||||
|
||||
rec(pl, 9, sum); // sentinel row: id=9 carries the sum in `at`
|
||||
});
|
||||
s.run();
|
||||
rec(pl, 9, sum); // sentinel row: id=9 carries the sum in `at`
|
||||
});
|
||||
ps.run();
|
||||
}
|
||||
|
||||
print("completion order (id @ virtual-ms):\n");
|
||||
i := 0;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
// A `Task` allows ONE awaiter — a second concurrent `wait` on the same pending
|
||||
// task would overwrite the single `waiter` slot, and completion would wake only
|
||||
// A `Future` allows ONE awaiter — a second concurrent `await` on the same pending
|
||||
// future would overwrite the single `park` slot, and completion would wake only
|
||||
// the second, stranding the first forever. Regression (B1.4a review, P1-c): the
|
||||
// guard aborts loudly instead of silently deadlocking.
|
||||
// guard aborts loudly instead of silently deadlocking. Now over the unified
|
||||
// `context.io` async layer (PLAN-IO-UNIFY Phase 5 — the bespoke `Task`/`wait` is
|
||||
// retired).
|
||||
//
|
||||
// aborts (exit 134) after the diagnostic — aarch64-macOS-pinned.
|
||||
#import "modules/std.sx";
|
||||
sched :: #import "modules/std/sched.sx";
|
||||
S :: struct { t: *sched.Task(i64); }
|
||||
S :: struct { t: *Future(i64); }
|
||||
main :: () -> i64 {
|
||||
st : S = ---; st.t = null;
|
||||
s := sched.Scheduler.init(); ps := @s; pst := @st;
|
||||
mkprod :: (ps: *sched.Scheduler, pst: *S) { pst.t = ps.go(() -> i64 => { ps.yield_now(); 42 }); }
|
||||
mkw :: (ps: *sched.Scheduler, pst: *S) { ps.spawn(() => { x := pst.t.wait() or { -1 }; print("got {}\n", x); }); }
|
||||
mkprod(ps, pst); mkw(ps, pst); mkw(ps, pst); // second waiter → loud abort
|
||||
s.run();
|
||||
mkprod :: (ps: *sched.Scheduler, pst: *S) { pst.t = context.io.async(() -> (i64, !) => { ps.yield_now(); 42 }); }
|
||||
mkw :: (ps: *sched.Scheduler, pst: *S) { ps.spawn(() => { x := pst.t.await() or { -1 }; print("got {}\n", x); }); }
|
||||
push .{ io = xx s } {
|
||||
mkprod(ps, pst); mkw(ps, pst); mkw(ps, pst); // second waiter → loud abort
|
||||
s.run();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
// Stream B1 (fibers) — `Scheduler.deinit` releases the scheduler's owned heap
|
||||
// + fd resources, closing the documented bounded leaks (kq fd / heap Tasks /
|
||||
// List backings). Verified by a tracking `GPA`: deinit drives the live
|
||||
// allocation count DOWN, and resets the kqueue fd to -1.
|
||||
// + fd resources, closing the documented bounded leaks (kq fd / List backings).
|
||||
// Verified by a tracking `GPA`: deinit drives the live allocation count DOWN,
|
||||
// and resets the kqueue fd to -1.
|
||||
//
|
||||
// Scenario (one run that touches every freed resource):
|
||||
// - a SLEEPER fiber `sleep(5)`s → exercises the `timers` List
|
||||
// - a READER fiber `block_on_fd`s a pipe → exercises the kqueue fd + the
|
||||
// `io_waiters` List
|
||||
// - a WRITER fiber writes 3 bytes → makes the pipe readable
|
||||
// - two `go` tasks compute 42 / 7 → exercise the heap `Task`s +
|
||||
// the `task_allocs` List
|
||||
// After `run()` drains all of it, `deinit()` frees: the 2 heap Tasks, the
|
||||
// `timers` / `io_waiters` / `task_allocs` List backings, and CLOSES the kqueue
|
||||
// fd (resetting `kq` to -1). The Fibers were already reaped during `run()`.
|
||||
// After `run()` drains all of it, `deinit()` frees: the `timers` / `io_waiters`
|
||||
// List backings, and CLOSES the kqueue fd (resetting `kq` to -1). The Fibers
|
||||
// were already reaped during `run()`. (The unified `context.io.async` layer's
|
||||
// Futures are NOT scheduler-tracked — they leak with the closure-env residual
|
||||
// below; the bespoke `go`/`Task`/`task_allocs` path was retired in Phase 5.)
|
||||
//
|
||||
// WHAT IT PROVES (the contract; numbers below are the snapshot):
|
||||
// - `freed by deinit: N` — live allocations reclaimed by `deinit` (> 0).
|
||||
// - `live after deinit` — the RESIDUAL. This is NOT zero and NOT a bug: it is
|
||||
// exactly the documented closure-env leak — one heap env per `spawn`/`go`
|
||||
// exactly the documented closure-env leak — one heap env per `spawn`
|
||||
// that sx cannot free (the runtime has no name for the env pointer). deinit
|
||||
// reclaims everything it CAN; the env residual is a language limitation.
|
||||
// - `kq open after run: 1` then `kq after deinit: -1` — the lazily-opened
|
||||
@@ -92,10 +92,6 @@ main :: () -> i64 {
|
||||
mk_reader(ps, pst, read_fd);
|
||||
mk_writer(ps, write_fd);
|
||||
|
||||
// Two async tasks — heap Tasks tracked for deinit to free.
|
||||
ps.go(() -> i64 => 42);
|
||||
ps.go(() -> i64 => 7);
|
||||
|
||||
ps.run();
|
||||
|
||||
after_run = gpa.alloc_count;
|
||||
|
||||
@@ -1 +1 @@
|
||||
sched: wait() — task already has a waiter (one awaiter per task in the M:1 model)
|
||||
io: await — future already has an awaiter (one awaiter per future in the M:1 model)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
read: 3 [97 98 99]
|
||||
freed by deinit: 5
|
||||
live after deinit: 5
|
||||
freed by deinit: 2
|
||||
live after deinit: 3
|
||||
kq open after run: true
|
||||
kq after deinit: -1
|
||||
|
||||
Reference in New Issue
Block a user