std/channel: Where Blocking Is a Branch
Every language that grew up after C has to answer the same question: what does
“send a value to another part of the program” mean when both halves are
running? Go answered it with the channel — one construct, chan T, that
parked a goroutine when the other side wasn’t ready. It is the most-copied
concurrency surface in modern languages.
Koru’s answer is a channel built from four mechanisms that already exist — and the interesting part is that none of them is “block.”
What already exists
The data plane is a Vyukov ring. std/rings — covered in the ring post — declares a bounded
MPMC queue in pure Koru and speaks in branches, not blocks: std/rings:new(feed, capacity: 16) { value: u64 } names a static, and enqueue/dequeue answer ok/full and some/none as ordinary
arms. Backpressure and emptiness are syntax, not a parked thread —
and that is precisely the property a channel inherits.
The rendezvous is an effect branch. A tor can declare an effect
arm — ! ask i64 -> i64 — that suspends the flow mid-impl and hands a resume value back from a dynamically-scoped handler:
import std/io
pub tor query { q: i64 }
! ask i64 -> i64
| done i64
query = ask(q): a => done a
query(q: 41)
! ask v -> v + 1
| done r |> std/io:print.ln("{{ r:d }}") query fires ask, the handler at the invocation site produces v + 1,
and the flow resumes with a bound to it. A Go unbuffered channel is a
rendezvous — sender and receiver meet, value crosses, both continue. An
effect pair is exactly that shape: the fire is the handoff, the resume is
the acknowledgment.
The scheduler is std/pump. A pump composes participants at compile
time; each answers up to three verbs — step (make progress), live (how
many instances are running), wait (what you’d block on: an fd, a
deadline, or neither). The join site is the same reference form std/store(name) ! field uses:
std/pump:create(main)
| drained |> std/io:print.ln("all participants retired")
std/pump(main)
! step |> tick-step()
! live |> tick-live()
! wait i |> tick-wait(i) When a pass makes no progress anywhere, the pump does not park N threads. It
composes one poll() over the union of every participant’s declared
interests — one kernel wait for the whole program, capped by the nearest
deadline.
The thread plane exists too — std/threading carries the worker.spawn / .async / .await / .join ladder, with threading kept
deliberately below the tor contract.
What the channel is
The declaration head is ruled — the { } body is the proto-definition
grammar, the same { name: Proto } entries a std/proto speaks, and the
construct decides the algebra. A ring is the degenerate case: one entry,
one element. A channel’s entries are kinds — each name: Proto pair
declares one arm word and the payload it carries:
std/proto(Reading) { id: u64, ts: f64 }
std/channel:new(inbox, capacity: 256) { reading: Reading }
std/channel(inbox) // a join site — anywhere in the program
! reading r |> handle(r) // consumer arm — the word the decl declared
! closed |> shutdown()
std/channel:send(inbox, v: r)
| ok |> ...
| full |> ... // backpressure is an arm
| closed |> ...
std/channel:close(inbox)
| ok |> ... Three decisions carry the weight:
The element type is a proto name. std/proto is the nominal-type
registry — an affinity by name, never a structural coupling — and std/store already drinks from it. chan Reading and chan Score can be
the same i64 underneath and still refuse to interchange at compile time.
Go’s chan int cannot say that.
Consumers join program-wide, then fuse. std/channel(name) is the
bare reference form — the same item shape as std/store(name) (watch)
and std/pump(name) (participant join) — so ! arms under it attach
anywhere in the program, in any order relative to the declaration. At
compile time the declaration collects every join into the channel’s
consumer set — statically enumerable, no registration — and transplants
each arm body into the delivery path it emits. The same machinery as a
store watch: a watch body runs at the write site, not where it’s written,
which is why watches can’t see ambient context. A channel arm runs inside
the drain, under the same rule. The channel holds the consumers’ code and
calls it — and the arms are Go-true: competing consumers, each value to
one unit, join order. The boundary holds across modules too: a channel
declared inside an imported lib.k collects joins the same way — the
generated units resolve by the module’s logical name (app.lib), not the
file’s.
select is the pump — at program level. Go compiles select into a
runtime multi-wait. Joining two channels to one pump — each with its own ! arm — is the same semantic, resolved statically at the join sites.
The union poll() is the select. Per-flow select — one flow waiting
on whichever of two channels delivers first — is a spelling question, not
a mechanism gap.
The head ruling
The ruling landed 2026-09-24, and it resolved into something better than
either candidate that was on the table: one body grammar, everywhere a std/ declaration takes { }. The proto-definition shape — name: Type entries — is the vocabulary declaration; the construct
decides the algebra over it:
std/proto(Reading) { id: u64, ts: f64 }— a product: the fields coexist in one struct.std/store:new(game) { hp: i64 }— columns: the fields are the store’s schema, and its!arms name them.std/rings:new(feed, capacity: N) { value: u64 }— degenerate: one entry, one element shape. The name labels the element in diagnostics and generated units.std/channel:new(inbox, capacity: N) { reading: Reading, alert: Alert }— a sum: each entry is one kind — one lane, one arm word, one payload proto.
Arm names are authored, not derived — { frame: Packet } fires ! frame; a channel of Reading named reading: fires ! reading only because that’s the word the declaration declared. Multi-kind needs
no new grammar: it is literally the field list. And the losing
candidates both died on grammar, not taste: a bare *Proto body borrowed
the * marker, which already means borrowed, not “element of” — a
by-value element has no business wearing it; and an of: arg names the
element but leaves the arm word implicit magic instead of declared.
Two enforcement details the compiler itself contributed: { } entries
comma-separate (struct_literal’s discipline — a newline-separated
second field is a missing-comma refusal, not a second kind), and the
value on a verb is v:-labeled (send(inbox, v: r), enqueue(feed, v: 42)) because the pun law refuses a second bare
positional before any transform sees it. closed stays reserved —
channel state, not a message kind; a body entry named closed refuses.
The ruling’s first fruit is the dependency-order inversion this post
needed anyway: std/rings got the surface first — it is the same
declaration machinery minus close and minus fused joins — and the ring
pins above are it, green. The channel now compiles against a real
substrate instead of emitting raw MpmcRing generics around a shell.
And the contour the ruling exposed is bigger than the channel — and it
is already pinned, not promised: every std/ construct is becoming the
same four moves — declare a named thing with a { name: Type } vocabulary body, let verbs answer | status arms, let ! arms join it
program-wide, let supervised and pump fold and schedule over it.
Status is vocabulary, not a type — which is why std/supervisor’s retry
fold sits on a ring’s | full arm without knowing what a ring is:
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 Bounded backpressure is Go’s select-with-time.After spelled as one
branch — three measured pins hold it (320_110–112), and | exhausted is where escalation lives, including sending into another
channel — Erlang’s supervisor tree as data flow. One boundary is real:
v1 supervises same-module children only, so the supervised call wraps
the ring verb in a tor of your own — and re-entry calls that same event
with remapped args, so | retry |> attempt(x: x + 1) climbs values
while | retry |> make_room() refuses. The unsupervised spelling — | full |> supervised |> std/rings:enqueue(...) directly — is the owed
pin (320_113). A ring is a channel minus close minus joins; the
channel is a ring plus kinds plus joins plus close. The constructs are
deltas on one skeleton, not features.
Three ways to “block,” none of them a goroutine park
| Tier | Mechanism | Go analog |
|---|---|---|
| Non-blocking | full / none / closed arms | select + default |
| Cross-thread | futex wait/wake inside the send/recv impls | gopark / goready |
| Same-thread pump | step returns 0 → wait → re-step | — no analog |
Go’s channel internally is a mutex plus wait queues. A Vyukov CAS fast path in front of a futex park is the same contention story with a lock-free head — which is why the comparison is worth running and not just worth writing.
The channel moves custody, not a copy
Everything above is the data plane — bytes through a ring. Koru carries a second ledger through the same edge, and it is the part even Rust cannot spell: obligation transit.
The distinction worth drawing: ownership is about who has the value;
obligation is about the debt. Rust’s tx.send(v) genuinely moves
ownership — the sender can’t touch v afterward, borrow-checker
enforced — but on arrival the receiver holds an owned T and drop(v) is always legal. Rust is affine: at most one use, and scope-exit is a
silent discharge. There is no way in stable Rust to make a value owed: #[must_use] is a lint silenced by let _ =, the linear_type crate
punts to a runtime panic in Drop (its own docs admit there is no
compile-time path, and mem::forget defeats it anyway), and the linear type modifier pre-RFC — the proposal that would refuse
scope-based drops — was deprecated without landing. A Drop-panics-
unless-disarmed guard can ride a Rust channel, but that’s a bomb, not a
ledger: the failure is a panicked thread at runtime, and the compiler
never knew the vocabulary. Linear Haskell and session types do enforce
must-consume at compile time — this is research-language territory, not
zero languages, but very few shipped ones.
A proto can carry a phantom obligation — mk returning Src<live!> mints
one, dispose taking Src<!live> discharges it, and the compiler refuses a
program that drops one on the floor. Send that value through a Go channel and
the handle copies while ownership stays ambiguous; both sides can think the
other frees it. In Koru the channel itself is a custody boundary:
pub tor mk { i: i64 } -> Src<live!>
mk -> { id: i }
pub tor dispose { s: Src<!live> } -> i64
dispose -> s.id
std/channel:new(inbox, capacity: 8) { src: Src }
std/channel(inbox)
! src v |> dispose(s: v): id |> std/io:print.ln("disposed {{ id:d }}")
mk(i: 1): s |> std/channel:send(inbox, s)
| ok |> std/channel:close(inbox)
| ok |> std/io:print.ln("sealed")
| full |> std/io:print.ln("full")
| closed |> std/io:print.ln("closed") Three moves, all compile-time:
| okconsumes. A successful send of an issued-obligation value consumes the producer binding’s custody —sis dead after| ok, and reading it again refusesKORU030: Use-after-dischargebefore the program ever runs.| full/| closedretain. Custody is conditional on the verdict: a send that doesn’t land leavessobligated, and the arm can dispose it itself or retry — custody survives the branches where nothing crossed.- Delivery mints fresh. The consumer’s
varrivesSrc<live!>— a new obligation bound at the winning!arm. Competing consumers each mint their own: one value, one winner, one ledger entry. The ring carries bytes; the ledger carries custody.
The implementation is worth naming because of what it isn’t: no new
checker rules, no new surface syntax. std/channel’s own transform resolves
the sent binding’s producer phantom at declaration time, synthesizes an
ordinary consuming tor under | ok and an ordinary minting tor at delivery,
and the existing obligation ledger — use-after-discharge, must-dispose — does
the rest. The contract is a composition, not a special case.
One edge stayed open until this week, and closing it is the part worth
reading twice. Auto-discharge is ordinarily the feature: an obligation
bound and never spent gets a disposer spliced at the leaf, and the program
moves on. At a transit boundary that kindness is a lie — a consumer arm
that drops v hasn’t discharged custody, it has abandoned it, and the
compiler settling the debt on its behalf legalizes the exact drop the edge
contract exists to refuse. So the mint carries a mark: the generated
obligate decl stamps a custody annotation, and the inserter flags every
obligation seeded from a custody decl’s return as not
auto-dischargeable. An arm that reads v.id and walks away now refuses:
error[KORU030]: Resource 'v' obligation <live!> was not discharged.
Call one of: send, dispose The wall names the vocabulary you were supposed to speak.
The remaining open edge is a pin that contradicts itself: 699_029’s | ok arm reads v.id after the relay send consumed v — the program as
written asks for use-after-discharge, and the fix is either a corrected
input or a ruling that consumed custody still leaves readable bytes. That
is a language-semantics question, not a channel decision, and it is parked
with the reasoning written down.
What the suite already owes
The comparison is not hypothetical. The regression tree already carries 420_006_rings_vs_channels — a parked pin whose Go baseline sits beside it:
messages := make(chan uint64, BUFFER_SIZE)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := uint64(0); i < MESSAGES; i++ {
messages <- i
}
close(messages)
}()
var sum uint64
for msg := range messages {
sum += msg
}
wg.Wait() Ten million messages through a 1024-deep buffered channel, checksum
validated — Goroutine producer, range consumer. That test is red today,
parked as OWED: “MPMC ring producer/consumer compiles as idiomatic Koru
concurrency.” The pin is not missing machinery — the ring exists, threads
exist. What it is missing is the surface: the spelling that makes this
program idiomatic instead of vendored. That is what std/channel is for.
The frontier that isn’t one
An earlier draft of this post named a “deferred resume frontier” — a missing mechanism where a flow suspends mid-statement and resumes later. That framing was wrong, and the correction is the most Koru-native thing in this post:
There is no suspended statement to resume, because the arrow is already
inverted. A tor doesn’t return into a waiting caller — it calls the
consuming code bound at its invocation site. ! reading r |> isn’t a
consumer pulling from a queue; it’s code the channel calls when a value
lands. Deferred execution was never missing — it’s what the ! arm is.
So cap-0 rendezvous is not a compiler gap: it’s a send whose | ok continuation fires when a consumer takes — a surface question, not a
mechanism one.
When the channel is the overengineered answer
Everything above argues the channel is cheap. The honest follow-up is that it is also frequently unnecessary — because two older mechanisms already do the synchronous half of its job at call cost, and for the cases where they apply, a ring is machinery you didn’t need.
The effect branch is a rendezvous the compiler resolves. ! send v fires into the handler bound at the invocation site — statically
dispatched, no queue, no ring, no pump. The benchmark is koru-benchmarks/suites/channels/’s effect_rendezvous: the same
million-value checksum oracle as chan_throughput, run through a
fire/resume pair instead of a ring. On this machine: 1.7ms — the
same as a bare Zig handler call (1.6ms), because that is what the
emitter produces. For contrast, the same rendezvous contract where the
parties are actually parked and woken: Go’s unbuffered channel lands at 140.8ms threaded and 111.4ms on one thread; crossbeam’s bounded(0) — Rust’s zero-capacity rendezvous — at 1708.3ms. A
two-party handoff that costs a park/wake pair elsewhere costs a function
call here, because both parties were known at compile time. That is a
~66x gap against the nearest real rendezvous, and ~3.4x under Koru’s own
ring on the same checksum.
The tap is the same story pointed at events. std/taps fuses a
listener into an event’s emission at compile time — a guarded branch
inside the call, not a subscription table it consults at runtime.
So the channel’s one extra level of indirection is precisely the thing you pay for only when the two parties should not meet:
- Time. The ring holds values the consumer hasn’t reached yet; the
full/none/drainedlifecycle exists because send and take run on different pump passes. An effect handler cannot hold anything — it must answer before the fire returns. - Fan-out. Many consumers compete on one ring; an effect branch has exactly one handler — the one in scope where it fired.
- Topology. The handler is bound at the call site — the caller
chooses who answers, which is inversion of control, not decoupling.
When the consumer is a participant with its own
step/livecadence — or, once699_030and the futex tier land, a producer on another OS thread — nobody is “in scope,” and the ring is what stands between them.
The one-line version: if the consumer is in scope, call it — that’s the effect branch, or the tap when what you want is to listen. If the consumer is elsewhere — later on the schedule, competing for the same stream, or on another thread — the channel is the detachment, and it costs what a ring costs.
When this post becomes true
docs/CHANNEL.md names the gates. The short version:
The declaration head gets its ruled, legal spelling— done 2026-09-24: the{ name: Proto }vocabulary body, implemented first forstd/rings(the ring pins above are green). An earlier implementation had shipped an inventedstd/channel(name: Proto, cap)form that compiled only because positional tails onstd/heads were never refused anywhere (now pinned red:210_240–210_242, with210_243green on ordinary calls). That implementation was pulled; the surface now stands on grammar that’s actually enforced.— done: 26/30 green, including obligation transit (tests/regression/600_STDLIB/699_CHANNEL/— buffered roundtrip, every arm hit, close semantics, competing consumers, a pump-joined two-channel program699_022–028) and the discharge refusal (699_027). Owed:699_020/021(supervised send),699_029(pin input contradicts the edge contract),699_030(spawned producer — the futex tier’s precondition).koru-benchmarks/suites/channels/— started:chan_throughputmoves a millioni64s through a capacity-1024 channel to four competing consumers, same checksum oracle. One measurement window on this machine: Koru 5.7ms, the same Vyukov ring in bare Zig on the same pass schedule 5.2ms, Rust’s crossbeam — bounded MPMC,try_send/try_recv, one thread, the nearest semantic peer — 12.5ms, Go’s own channel driven non-blocking (select/default, one goroutine) 33.7ms, underGOMAXPROCS=135.8ms, and Go threaded 70.4ms. Three honest reads: the transform and pump cost ~9% over the bare ring — small, real, and priced by the control row; the channel beats the nearest peer implementation of the same contract by more than 2x; andgonbisolates the finding — Go’s channel minus its scheduler is still 6x slower, so the gap is the mutex’d queue, not just parked goroutines. And the row this post exists for:chan_custodyreruns the same workload with an obligation as the payload — every value isSrc<live!>, custody consumed at| ok, retained and settled on| full, re-minted at the winning arm, discharged bydispose— and lands at the same 5.8ms in the same pass. The ledger is checked at compile time and erases at codegen; the runtime price of guaranteed custody is zero. This is not a cross-thread claim:699_030and the futex tier are still owed, and the suite’s README says so where the numbers live. The named showcase — the prime-sieve pipeline, N goroutines vs N pump participants — is the next kernel.- 420_006 and 420_009 unparked — the pins the suite has been holding since
August, promoted by the surface they were waiting for.
chan_throughputis the first row of that promotion.