Channels

A channel carries values between tasks. Where await is fork-join, a channel is a pipeline.

jobs = channel()

async fn produce() {
    for n in [1, 2, 3] { send(jobs, n) }
    close(jobs)                       # "no more values"
}
produce()

for job in jobs { handle(job) }       # drains until closed

The operations

callbehaviour
channel()Unbounded, multi-producer, multi-consumer
channel(n)Bounded to n buffered values (n >= 1)
send(ch, v)Enqueue. Never blocks unbounded; blocks when a bounded buffer is full. Errors on a closed channel
recv(ch)Next value, blocking. null once closed and drained
try_recv(ch)Next value or null immediately, without blocking
close(ch)Mark done; wakes blocked senders and receivers
select([a, b])First available value across channels; null when all are closed and drained

Backpressure

channel(n) is the reason to prefer a bounded channel: send blocks while the buffer is full, so a fast producer is throttled by a slow consumer instead of growing a queue until memory runs out.

work = channel(10)      # at most 10 items in flight

An unbounded channel is a memory leak waiting for a load spike. Reach for the bounded form unless you know the total is small.

The blocked send is cancellation-aware: it wakes as receivers drain, and errors if the channel closes while it waits.

Iterating

for v in ch is the idiomatic consumer. It ends when the channel is closed, and unlike recv it distinguishes a null you sent as data from the end-of-channel sentinel - a sent null is yielded.

If a producer forgets to close, a for loop over its channel waits forever. Close in the producer, and close it once.

select for fan-in

v = select([results, errors])

Blocks until one channel has a value; the earlier channel wins when several are ready, and only the winner is consumed. It does not tell you which channel produced the value, so tag values before sending if you need to know:

send(results, { from: "worker-1", value: v })

try_recv for polling

Use it when there is other work to do between attempts. A null means "nothing right now" and also "closed and drained" - the same convention as recv, so check try_recv in a loop that has its own termination condition rather than treating null as an end marker.

A worked pipeline

raw = channel(100)
done = channel(100)

async fn reader() {
    for line in lines(fs.read("input.txt")) { send(raw, line) }
    close(raw)
}

async fn worker() {
    for line in raw { send(done, transform(line)) }
}

reader()
workers = map(range(0, 4), fn(_) worker())
map(workers, fn(w) await w)
close(done)

for r in done { print(r) }

Four workers share one input channel, and the bounded channels keep the reader from running ahead of them.