std/channel: Where Blocking Is a Branch

· 7 min read

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.”

The channel end to end, narrated: declared like a proto, sends landing in a bounded ring, joins attaching at compile time, `full` branching instead of throwing, `close` sealing the head while the buffer drains, custody crossing the edge — and costing what a ring costs, because it is one.

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

TierMechanismGo analog
Non-blockingfull / none / closed armsselect + default
Cross-threadfutex wait/wake inside the send/recv implsgopark / goready
Same-thread pumpstep 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:

  • | ok consumes. A successful send of an issued-obligation value consumes the producer binding’s custody — s is dead after | ok, and reading it again refuses KORU030: Use-after-discharge before the program ever runs.
  • | full / | closed retain. Custody is conditional on the verdict: a send that doesn’t land leaves s obligated, and the arm can dispose it itself or retry — custody survives the branches where nothing crossed.
  • Delivery mints fresh. The consumer’s v arrives Src<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/drained lifecycle 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/live cadence — or, once 699_030 and 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 for std/rings (the ring pins above are green). An earlier implementation had shipped an invented std/channel(name: Proto, cap) form that compiled only because positional tails on std/ heads were never refused anywhere (now pinned red: 210_240–210_242, with 210_243 green on ordinary calls). That implementation was pulled; the surface now stands on grammar that’s actually enforced.
  • tests/regression/600_STDLIB/699_CHANNEL/ — buffered roundtrip, every arm hit, close semantics, competing consumers, a pump-joined two-channel program — done: 26/30 green, including obligation transit (699_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_throughput moves a million i64s 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, under GOMAXPROCS=1 35.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; and gonb isolates 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_custody reruns the same workload with an obligation as the payload — every value is Src<live!>, custody consumed at | ok, retained and settled on | full, re-minted at the winning arm, discharged by dispose — 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_030 and 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_throughput is the first row of that promotion.
same workload, six substrates — zig-ring is the same ring in bare Zig (its distance is the machinery's price), crossbeam is the same try-contract on the same queue family, gonb is Go's channel minus its scheduler, and the outer Go rows are what parking adds