std/rings: The One-Kind Channel

· 6 min read

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 sample above in motion: the ring fills, `full` bounces into the supervised retry orbit, a dequeue frees a slot, and `exhausted => full` forwards back to the caller's arm.

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.