std/rings: The One-Kind Channel
Koru has had a lock-free bounded queue for a while — a Vyukov MPMC ring
living in koru_std/rings.kz, power-of-2 slots, per-cell sequence numbers,
CAS on head and tail. What it did not have was a way to say one. Using it
meant dropping into host Zig: const Ring = koru_std.koru_rings.MpmcRing(u64, 16) inside a .kz file, typed *anyopaque at the boundary. The algorithm was
Koru infrastructure; the surface was a leak.
std/rings closes that. The surface is three lines of grammar:
std/rings:new(ring, capacity: 16) { value: u64 }
std/rings:enqueue(ring, v: 42)
| ok |> std/rings:enqueue(ring, v: 100)
| ok |> std/rings:dequeue(ring)
| some v |> std/io:print.ln("First: {{ v:d }}")
| none |> std/io:print.ln("ERROR: empty")
| full |> std/io:print.ln("ERROR: full")
| full |> std/io:print.ln("ERROR: full") new declares a named static — one declaration, addressed by name from any
flow or thread. enqueue and dequeue are chain steps that answer in
branches: ok/full on the way in, some v/none on the way out. full is not an error code and none is not an exception — they are arms, the
same shape every other Koru tor returns. Backpressure and emptiness are syntax, and a flow that doesn’t spell them doesn’t compile — the branch
set is checked, not conventional.
The declaration body is a vocabulary
The { value: u64 } is not new syntax. It is the same { name: Type } entry grammar std/proto already speaks — the proto-definition body. What
changed is the ruling about what a body means: the entries declare the
vocabulary the construct’s operations will talk in, and each construct
decides the algebra.
std/proto(R) { id: u64, ts: f64 }— the product: fields coexist in one struct.std/store:new(g) { hp: i64 }— columns: a row is all of them, SoA.std/rings:new(feed) { value: u64 }— the degenerate case: one entry, the element.std/channel:new(inbox) { reading: Reading, alert: Alert }— the sum: each entry is one kind, one lane, one!arm word.
One grammar, one sentence to teach, and the compiler can check it: a ring body with a second entry refuses and points up — “more than one named lane is a channel.” The name earns its keep even on a ring: it names the element in diagnostics, in generated units, and in whatever vocabulary the construct grows later.
Protos ride by value
The element can be a proto, not just a scalar — which is where a ring stops being a counter pipe and starts being a wire:
std/proto(Reading) {
id: u64
ts: f64
}
std/rings:new(feed, capacity: 8) { reading: Reading }
pub tor mk-reading { i: u64, t: f64 } -> Reading
mk-reading -> { id: i, ts: t }
mk-reading(i: 7, t: 2.5): r |> std/rings:enqueue(feed, v: r) proto is the nominal-type registry: a ring of Reading and a ring of Score can be the same i64 underneath and still refuse to interchange. dequeue | some v binds the payload whole — the name in the body
(reading) names the kind; the fields deconstruct in ordinary Koru
afterward (v.id, v.ts), because a queue moves cargo, it doesn’t
project columns.
The one new-looking detail is v: — enqueue(feed, v: r) labels the
value. That isn’t a ring convention; it’s the pun law. A second bare
positional must name a parameter; identifiers pun (enqueue(feed, r) works), but a literal or expression (v: 42, v: x + 1) needs its label.
Same reason std/list spells push(xs, v: 10).
Policy is a branch
Because full is an arm, it composes with anything that folds over arms —
including std/supervisor. Bounded backpressure — retry a bounded number
of times, then give up loudly — is a four-line spelling over a same-module
wrapper:
std/rings:new(feed, capacity: 2) { value: u64 }
pub tor attempt { x: u64 }
| ok
| full
attempt = std/rings:enqueue(feed, v: x)
| ok => ok
| full => full
pub tor push { x: u64 }
| ok
| full
push = attempt(x)
| ok => ok
| full |> std/supervisor:supervised
| retry t when t < 3
| exhausted => full In Go this is select { case ch <- v: case <-time.After(d): } — a second
construct plus a timer channel, for what is fundamentally “bounded
patience.” Here it is a fold over a vocabulary word: supervised doesn’t
know what a ring is; it doesn’t need to. | exhausted => full forwards
the outcome in kind — the caller’s own | full arm still decides what
giving up means. The consumer’s symmetrical arm works identically: | none supervised is bounded re-poll.
The honest edge: supervising std/rings:enqueue directly — no wrapper —
refuses today: v1 supervises same-module children only. That pin
(320_113) is TODO/OWED, and the wrapper shape above is the working
spelling until cross-module supervision exists.
What it costs
A queue that can’t move cargo at wire speed is a toy, so here are the
numbers — and, more importantly, which of them are proven and which
aren’t. The pin is 420_006: 10M u64 messages, one producer thread,
one consumer thread, capacity-256 ring, checksum-validated, against a
hand-written Zig baseline running the same Vyukov implementation. All
numbers below are from one laptop, ReleaseFast, this week.
Instruction-identical where it matters, not where it doesn’t. Disassembling both binaries shows the consume loop emitting the same acquire-load / CAS / release-store sequence on both sides — the algorithm costs what the algorithm costs. The programs are not identical: Koru’s emitted consumer carries ~9 flag-byte ops per unrolled pair of iterations — union-tag traffic the branch machinery leaves behind. It’s real emitted weight and worth deleting, but a hand-fused variant of the same binary ran the same wall time: in this workload it is noise, not cost.
The earlier “deficit” was measurement error — ours. A previous
revision of this benchmark stack-allocated the baseline’s ring while
Koru’s was heap-allocated, and read the ~25% skew as a language
problem. Giving both sides the same page_allocator allocation erased
it. The benchmark was wrong, not the codegen; the fix landed in the
test, and the lesson lives in the commit message.
Head to head at equal setup: near parity, small real tilt. On a shared laptop, 80 interleaved runs — Koru, Zig, alternating so each pair draws the same machine state: Koru won 31, mins within 2%. On dedicated hardware (DigitalOcean c-2, x86_64, 50 interleaved pairs): minimums 122.6 vs 121.0 ms, medians within 0.5% — but Koru won only 11. The tilt is small and it is real: a systematic ~1% edge for the hand-written baseline, consistent enough to take four head-to-heads out of five. The residual is attributed but not yet eliminated — the consumer’s union-tag dispatch debris (~9 flag-byte ops per unrolled pair where Zig has none) is the live suspect, and the emitter fix is the follow-up.
For context on the same board, block-ordered and therefore softer: Rust lands ~1.13× and Go ~3.35× the Koru mean.
The sentence we’d sign: at equal setup, Koru moves 10M messages through a contended lock-free ring within ~1% of a hand-written Zig baseline — behind, systematically, by a margin whose mechanism is identified as a suspect but not yet proven — and closing it is compiler work, not benchmark work.
A ring is a channel minus close minus joins
This is the claim the surface was built to make true. A ring is a bounded
queue with one vocabulary entry and two operations; a channel is the same
skeleton plus named kinds, program-wide !-arm joins, and a closed transition. The constructs are deltas on one grammar, not separate
features — and now that the ring is pinned green, the channel inherits a
public commitment: its declaration must speak this body language or be
visibly broken.
The channel post — with its pump drains, competing consumers, and the close transition — is the next one. The ring is where the grammar proved itself small enough to say in three lines.