Module sync/channel
Bounded multi-producer multi-consumer (MPMC) channel. Uses blocking send/recv via condition variables — does not require Io.
Channel uses atomic object with atomic reference counting, so it can be
safely shared across threads without Arc wrapping.
Example
{ Channel } :: import "std/sync/channel";
{ Thread } :: import "std/thread";
ch := Channel(i32).new(usize(10));
t := Thread(unit).spawn((io) => {
ch.send(i32(42));
});
val := ch.recv(); // blocks until data available
assert((val.unwrap() == i32(42)), "received value");
t.join();
Two APIs over one queue, and why both exist
Channel(T) is the FUSED handle: one object that both ends hold, closed
by hand. It is genuinely multi-consumer — several threads may recv from
it — and that is what std/thread's pool internals and the cross-thread
tests use it for, so it stays.
Sender(T)/Receiver(T) add the piece a fused handle cannot express: a
LIFETIME. The queue counts its live senders, so dropping the last one
closes the channel and a Receiver finds out that no more values are
coming — no close() call to remember, and no consumer parked forever on
a queue whose producers have all finished. Dropping the Receiver
symmetrically makes every further send fail instead of blocking on a
queue nobody will drain. The price is the single consumer: exactly one
Receiver exists per queue, which is what makes "the receiver is gone" a
fact worth acting on. The split is a layer ON TOP of Channel, sharing
the same buffer, mutex and condvars.
{ Channel } :: import "std/sync/channel";
rx := Channel(i32).receiver(usize(4)); // queue + its one consumer
{
tx := rx.sender(); // a counted producer
tx.send(i32(1));
}; // last sender dropped -> closed
rx.recv().unwrap(); // 1
rx.recv(); // .Err(Disconnected)
Producers on other threads
A Sender MOVED INTO a Thread.spawn closure disposes when the thread
finishes, so it closes the channel like any other sender:
rx := Channel(i32).receiver(usize(16));
{
tx := rx.sender();
w := Thread(unit).spawn((io) => {
tx.send(i32(7)); // the capture is released when
}); // the thread's body returns
w.join();
}; // and tx itself drops here
rx.recv().unwrap(); // 7
rx.recv(); // .Err(Disconnected)
This did NOT work before 2026-09-11: a spawn closure's captures were never
released at all, so such a sender's dispose never ran and the count never
reached zero (issues/fixed/spawn-closure-captures-never-dropped-leak.md).
Minting each producer's Sender inside its own thread, with one keeper
in the parent, is still a fine pattern and is what you want when the
senders outnumber the threads:
rx := Channel(i32).receiver(usize(16));
{
keeper := rx.sender(); // holds the count above zero
w := Thread(unit).spawn((io) => {
tx := rx.sender(); // this thread's producer
tx.send(i32(7));
}); // tx drops when the body ends
w.join();
}; // keeper drops -> closed
Stability
unstable — the shapes are settled and the ownership model just changed.
try_recv -> Result(T, TryRecvError) landed in #506 and the blocking core
(send / recv / close / len) has not moved since; the
Sender/Receiver split landed 2026-09-11 and is what this release is
asking for use on before freezing. Still missing, all additive: Rust's
recv_timeout, an iterator over received values, and an unbounded variant
(plans/STD_API_STABILIZATION_FINDINGS.md item 10).
This is the BLOCKING channel — it parks OS threads and needs no Io. The
async one is std/async/channel.
Types
Why a non-blocking receive came back empty-handed — Rust's
TryRecvError.
try_recv used to answer Option(T), which collapsed these two into one
.None: a caller could not tell "nothing yet, try again" from "the sender
is gone, stop trying", so every polling loop over a closed channel spun
forever or exited early by guessing.
Variants
| Variant | Fields | Description |
|---|---|---|
Empty | The channel is open but has nothing buffered right now. Retry later. | |
Disconnected | The channel is closed AND drained. No value will ever arrive; stop polling. |
Trait Implementations
impl(generic(T : Type), where(T <: ToString), T : (ToString))
impl(generic(T : Type), where(T <: ToString), T : (ToString), Format)
format : fn(self : Self, spec : str) -> StringRender self under spec. An unrecognised spec degrades to the plain
to_string() rendering rather than failing.
Parameters
| Name | Type | Notes |
|---|---|---|
self | Self | |
spec | str |
Returns: String
impl(TryRecvError, ToString(...))
to_string : (TryRecvError) fn(self : TryRecvError) -> StringRender self as the text a USER should read — Rust's Display::fmt,
not its Debug. Hand-written (or generated by derive(Error) from a
per-variant format string) whenever the structural form would be wrong.
Parameters
| Name | Type | Notes |
|---|---|---|
self | TryRecvError |
Returns: String
impl(TryRecvError, Eq(TryRecvError))
Methods
== : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> bool!= : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> boolThe sending half of a Channel(T).receiver/pair split — Rust's
mpsc::Sender<T>.
Cloneable: every clone is another producer over the SAME queue, and the
queue counts them. When the LAST Sender is dropped the channel closes
itself, which is the whole point of the split — a Receiver learns that
no more values are coming without anyone remembering to call close(),
and a blocked recv wakes up rather than parking forever.
atomic(ref(...)) for the same reason Channel is: the handle is meant
to be moved into another thread, and its reference count has to be atomic
for that to be sound.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), ...)
_attach : (fn(ch : Channel(T)) -> Self)Attach the consumer to ch. Internal: Channel.receiver and
Channel.pair are the only callers.
Returns: Self
send : (fn(self : Self, value : T) -> Result(unit, T))Send a value, blocking while the buffer is full.
.Err(value) — Rust's SendError(T) — once the channel is closed or
the Receiver has been dropped; the value that could not be sent comes
back rather than being dropped on the floor.
Returns: Result(unit, T)
try_send : (fn(self : Self, value : T) -> Result(unit, T))Send without blocking: .Err(value) if the buffer is full, the channel
is closed, or the Receiver is gone.
Returns: Result(unit, T)
is_closed : (fn(self : Self) -> bool)True once the channel is closed — including by the last Sender being
dropped. Buffered values may still be waiting; try_recv answering
Disconnected is the closed-AND-drained test.
Returns: bool
len : (fn(self : Self) -> usize)Number of values currently buffered (a snapshot, like Channel.len).
Returns: usize
close : (fn(self : Self) -> unit)Stop accepting values now. Buffered values still come out of recv,
then it reports Disconnected.
Returns: unit
impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Clone(...))
clone : (fn(inout(self) : Self) -> Self)Create an independent clone of self.
Returns: Self
impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Dispose(...))
dispose : (fn(self : Self) -> unit)Release the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Returns: unit
The receiving half of a Channel(T).receiver/pair split — Rust's
mpsc::Receiver<T>.
Deliberately NOT cloneable, so that "the receiver is gone" is a fact a
sender can act on. (The underlying Channel is MPMC and a second consumer
is expressible by sharing the Channel itself; what a Receiver adds is
the single-owner lifetime, and cloning it would take that away.)
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), ...)
_attach : (fn(ch : Channel(T)) -> Self)Attach the consumer to ch. Internal: Channel.receiver and
Channel.pair are the only callers.
Returns: Self
sender : (fn(self : Self) -> Sender(T))Mint a producer for this queue, counted like every other Sender.
This is how the first sender is created after Channel(T).receiver(...),
and how a fan-in is set up: one sender() per producer, or one plus
clone()s. Each of them keeps the channel open until it is dropped.
A sender minted after the channel has already closed does NOT reopen it
— _closed is one-way — so its sends fail; mint the producers while the
previous ones are still alive.
Returns: Sender(T)
recv : (fn(self : Self) -> Result(T, TryRecvError))Receive the oldest value, blocking while the channel is empty.
.Err(TryRecvError.Disconnected) once the channel is closed AND
drained — which happens on its own when the last Sender is dropped.
It is the same TryRecvError try_recv reports rather than a second
error type, and Empty is impossible here: a blocking receive that
found the channel empty and open would have kept waiting.
Values already queued when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported — closing a channel must not lose what it already accepted (Rust guarantees the same).
Returns: Result(T, TryRecvError)
try_recv : (fn(self : Self) -> Result(T, TryRecvError))Receive without blocking: .Ok(value), Empty while the channel is
open but has nothing buffered, Disconnected once it is closed and
drained.
Returns: Result(T, TryRecvError)
is_closed : (fn(self : Self) -> bool)True once the channel is closed — including by the last Sender being
dropped. Buffered values may still be waiting; try_recv answering
Disconnected is the closed-AND-drained test.
Returns: bool
len : (fn(self : Self) -> usize)Number of values currently buffered (a snapshot, like Channel.len).
Returns: usize
is_empty : (fn(self : Self) -> bool)True while nothing is buffered (a snapshot).
Returns: bool
close : (fn(self : Self) -> unit)Stop accepting values now. Buffered values still come out of recv,
then it reports Disconnected.
Returns: unit
impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), Dispose(...))
dispose : (fn(self : Self) -> unit)Release the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Returns: unit
Thread-safe bounded channel for passing values between threads.
Uses atomic reference counting — implements Send and can be
safely shared across threads without Arc wrapping.
T must implement Send (values transfer between threads) and Acyclic
(atomic reference counting is only sound for acyclic data).
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), ...)
new : (fn(capacity : usize) -> Self)Create a bounded channel with the given capacity. Capacity must be > 0.
Returns: Self
send : (fn(self : Self, value : T) -> Result(unit, T))Send a value, blocking while the buffer is full.
.Err(value) — Rust's SendError(T) — once the channel is closed or
the Receiver has been dropped; the value that could not be sent comes
back rather than being dropped on the floor.
Returns: Result(unit, T)
recv : (fn(self : Self) -> Option(T))Receive the oldest value, blocking while the channel is empty.
.Err(TryRecvError.Disconnected) once the channel is closed AND
drained — which happens on its own when the last Sender is dropped.
It is the same TryRecvError try_recv reports rather than a second
error type, and Empty is impossible here: a blocking receive that
found the channel empty and open would have kept waiting.
Values already queued when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported — closing a channel must not lose what it already accepted (Rust guarantees the same).
Returns: Option(T)
try_send : (fn(self : Self, value : T) -> Result(unit, T))Send without blocking: .Err(value) if the buffer is full, the channel
is closed, or the Receiver is gone.
Returns: Result(unit, T)
try_recv : (fn(self : Self) -> Result(T, TryRecvError))Receive without blocking: .Ok(value), Empty while the channel is
open but has nothing buffered, Disconnected once it is closed and
drained.
Returns: Result(T, TryRecvError)
close : (fn(self : Self) -> unit)Stop accepting values now. Buffered values still come out of recv,
then it reports Disconnected.
Returns: unit
_mark_receiver_dropped : (fn(self : Self) -> unit)Record that this queue's Receiver handle is gone: every further
send fails, and senders parked on a full buffer wake up to find out.
Internal — Receiver's dispose is the only caller. The flag is set
and the waiters are woken with the mutex HELD, for the same reason
close does it: a sender that has evaluated its wait predicate but not
yet parked still holds the mutex, so a broadcast issued outside it could
land before the park and be lost.
Returns: unit
is_closed : (fn(self : Self) -> bool)True once the channel is closed — including by the last Sender being
dropped. Buffered values may still be waiting; try_recv answering
Disconnected is the closed-AND-drained test.
Returns: bool
len : (fn(self : Self) -> usize)Number of values currently buffered (a snapshot, like Channel.len).
Returns: usize
is_empty : (fn(self : Self) -> bool)True while nothing is buffered (a snapshot).
Returns: bool
impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), Dispose(...))
dispose : (fn(self : Self) -> unit)Release the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the last
reference to the value goes away, so an implementor never calls it
directly and must tolerate being the only one who ever does.
It must be safe to run exactly once: the runtime calls it at refcount
zero, and a type that also exposes an explicit close/release is
responsible for making the second call a no-op.
Returns: unit
impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), ...)
receiver : (fn(capacity : usize) -> Receiver(T))Create a bounded queue and its single consumer — the tuple-free half of
the Sender/Receiver split. Mint producers with Receiver.sender()
and Sender.clone().
{ Channel } :: import "std/sync/channel";
rx := Channel(i32).receiver(usize(4));
tx := rx.sender();
tx.send(i32(1));
v := rx.recv().unwrap();
This is the form to use when the end of the stream matters, because each
handle is its own binding and therefore has its own lifetime: dropping
the last Sender closes the channel while the Receiver is still
alive to notice. pair below cannot do that — see its note.
Exactly one Receiver exists per queue by construction, which is what
makes "the receiver is gone" a fact a sender can act on.
A Receiver that has no Sender yet is not closed — nothing has been
dropped — so recv on it blocks, exactly as Channel.recv on a queue
nobody sends to does. Mint the senders before receiving.
Returns: Receiver(T)
pair : (fn(capacity : usize) -> Tuple(Sender(T), Receiver(T)))Create a bounded queue and hand back both halves at once — the shape of
Rust's mpsc::sync_channel(capacity).
pair := Channel(i32).pair(usize(4));
tx := pair.0;
rx := pair.1;
The tuple keeps both handles alive for as long as the tuple binding
lives (measured 2026-09-10). Yo has no destructuring binding, so pair
stays in scope beside tx and rx and holds its own reference to each
half; tx := pair.0 copies a handle rather than moving it out. The
consequence is specific: dropping tx early does NOT drop the last
sender, so the auto-close does not fire until the whole scope — the
Receiver included — goes away. Use this form when the producers and
the consumer share a scope and the count of values is known, or call
close() explicitly; use receiver() + sender() when the receiver
needs to learn that the producers are finished.