Module async/channel
Async (event-loop) channel — deliver values BETWEEN TASKS on one thread's
event loop without parking it (plans/archive/STD_API_AUDIT.md §7 P0 item 6 /
D7). std/sync/channel's blocking recv() inside a future parks the
entire single-threaded loop — every task, including the sender that would
have unblocked it. This channel's send/recv are futures instead: the
waiting task suspends and the loop keeps running.
NOT thread-safe — same-thread tasks only (no Send bound, no atomics, by
design: Yo's async runtime is per-thread). Across OS threads use
std/sync/channel.
A blocked send/recv is WAKER-BASED: the task publishes a waker on the
side it is waiting for and suspends, and the other side wakes it when it
makes progress — a recv that takes a value wakes one blocked sender, a
send that buffers one wakes one blocked receiver, and close wakes
everybody so they can observe the closure. So a hand-off costs a loop turn
rather than a millisecond; the fast path (space or data already available)
still never suspends at all. Until 2026-09-11 both sides re-checked on a
1 ms timer tick, which put a millisecond floor under every hand-off and
capped a producer/consumer pair at ~1000 values per second
(plans/backlog/WAKER_BASED_SCHEDULING.md stage 3).
{ Channel } :: import("std/async/channel");
ch := Channel(i32).new(usize(8));
producer := io.spawn(
io.async((io : Io) => {
_s := io.await(ch.send(i32(42), io), io);
return(());
}),
io
);
v := io.await(ch.recv(io), io); // .Some(42)
Two APIs over one channel
Channel(T) is the FUSED handle both ends share, closed by hand. Its
state lives in an Rc(RefCell(...)), so a copy of the handle is another
handle on the same channel (plans/VALUES_BY_DEFAULT.md §3.13 A5), and the
state is written through the RefCell, since an Rc's payload is
read-only through the handle (decision 46).
Sender(T)/Receiver(T) add a LIFETIME on top of it: the channel counts
its live senders, so dropping the last one closes it and a suspended
recv resolves to Disconnected instead of waiting for a value nobody
will send. Dropping the Receiver makes every further send fail.
Both are move-only values: a Sender is copied only by clone(), which
counts the new producer, and exactly one Receiver exists per channel,
which is what makes "the receiver is gone" a fact a sender can act on.
rx := Channel(i32).receiver(usize(4)); // channel + its one consumer
{
tx := rx.sender(); // a counted producer
io.await(tx.send(i32(1), io), io);
}; // last sender dropped -> closed
io.await(rx.recv(io), io); // .Ok(1)
io.await(rx.recv(io), io); // .Err(Disconnected)
A Sender moved into an io.async body (io.async({ tx }(io : Io) => …))
is released when the task and its future are done, so a producer task's
sender closes the channel like any other. The same is now true of a
Thread.spawn capture on the blocking channel; it was not until 2026-09-11
(issues/fixed/spawn-closure-captures-never-dropped-leak.md).
Stability
unstable — Channel.recv answers Option(T), where .None means the
channel closed, and whether that should be a Result the way try_recv's
answer now is remains open. (Receiver.recv, the split's consumer, IS
already a Result(T, TryRecvError).) The 1 ms timer tick is GONE as of
2026-09-11 — both sides are waker-based now — which changes
handoff latency but no signature.
new, send, try_send, try_recv, close, is_closed and len are
intended to keep their names and meanings, as are receiver, pair,
Receiver.sender and Sender.clone.
Types
The sending half of an async channel pair — the same Sender/Receiver
split std/sync/channel has, for tasks on one event loop.
A move-only value with Clone (§3.13 A5): every clone is another producer
over the same channel, and the channel counts them. When the LAST
Sender is dropped the channel closes itself, so a Receiver learns that
no more values are coming without anyone remembering to call close() — a
recv that would otherwise suspend forever resolves to Disconnected
instead. A task takes its producer by move (io.async({ tx }(io : Io) => …)), or a clone() of one.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), Sender(T), ...)
_attach : (fn(ch : &Channel(T)) -> Self)Attach a new producer to ch and count it. Internal: Channel.receiver,
Channel.pair, Receiver.sender and clone are the callers.
Returns: Self
send : (fn(self : &Self, value : T, io : Io) -> Impl(Future(Result(unit, T))))Send a value, suspending while the buffer is full. .Err(value) once
the channel is closed or the Receiver is gone (Rust's SendError(T)).
Returns: Impl(Future(Result(unit, T)))
try_send : (fn(self : &Self, value : T) -> Result(unit, T))Send without suspending: .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 — by an explicit close(), or by the
last Sender being dropped.
Returns: bool
len : (fn(self : &Self) -> usize)Number of values currently buffered.
Returns: usize
close : (fn(self : &Self) -> unit)Close the channel now, without waiting for the other senders to go.
Returns: unit
impl(generic(T : Type), Sender(T), Clone(...))
clone : (fn(self : &Self) -> Self)Create an independent clone of self.
Returns: Self
impl(generic(T : Type), Sender(T), Drop(...))
drop : (fn(self : &mut Self) -> unit)One ArcWeak fewer; the cell is freed when its payload is gone and
this was the last handle. A zeroed handle names no cell.
Returns: unit
The receiving half of an async channel pair.
A move-only value, deliberately NOT cloneable, so that "the receiver is
gone" is a fact a sender can act on; Channel(T).receiver mints exactly
one per channel.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), 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 channel, counted like every other Sender.
This is how the first sender is created after Channel(T).receiver(...)
and how a fan-in of producer tasks is set up. A sender minted after the
channel has already closed does not reopen it — its sends fail.
Returns: Sender(T)
recv : (fn(self : &Self, io : Io) -> Impl(Future(Result(T, TryRecvError))))Receive the oldest value, suspending 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. Same
TryRecvError as try_recv rather than a second error type, and
Empty is impossible here: a suspending receive that found the channel
empty and open would have kept waiting.
Values already buffered when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported.
The future holds its own copy of the channel handle, so it borrows
nothing from the Receiver.
Returns: Impl(Future(Result(T, TryRecvError)))
try_recv : (fn(self : &Self) -> Result(T, TryRecvError))Receive without suspending: .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.
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), Receiver(T), Drop(...))
drop : (fn(self : &mut Self) -> unit)One ArcWeak fewer; the cell is freed when its payload is gone and
this was the last handle. A zeroed handle names no cell.
Returns: unit
A bounded FIFO channel for same-thread async tasks.
A handle on state that lives in an Rc(RefCell(...)): copying a Channel shares
the channel (the fused MPMC handle, §3.13 A5). Its send/recv futures
hold their own copy of the handle, so they borrow nothing and a task may
own them.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), Channel(T), ...)
new : (fn(capacity : usize) -> Self)A channel holding at most capacity buffered values (must be > 0).
Returns: Self
send : (fn(self : &Self, value : T, io : Io) -> Impl(Future(Result(unit, T))))Send a value, suspending while the buffer is full. Resolves to
.Ok(()) once buffered, or .Err(value) if the channel is (or
becomes) closed, or its Receiver has been dropped — the failed value
is handed back.
Returns: Impl(Future(Result(unit, T)))
recv : (fn(self : &Self, io : Io) -> Impl(Future(Option(T))))Receive the oldest value, suspending while the channel is empty.
Resolves to .None once the channel is closed AND drained.
Returns: Impl(Future(Option(T)))
try_send : (fn(self : &Self, value : T) -> Result(unit, T))Try to send without suspending: .Ok(()) if buffered, .Err(value)
if the channel is full, closed, or has no Receiver any more.
Returns: Result(unit, T)
try_recv : (fn(self : &Self) -> Result(T, TryRecvError))Try to receive without suspending — .Ok(value), or the reason there is
none.
This used to answer Option(T), which collapsed the two answers a
polling caller must act on differently: .None meant both "nothing
buffered yet, retry" and "closed and drained, no value will ever
arrive". TryRecvError is the SAME type std/sync/channel.try_recv
returns, so the two channels now report the same distinction with the
same vocabulary.
A closed channel with buffered values still yields them: Disconnected
means closed AND drained.
Returns: Result(T, TryRecvError)
close : (fn(self : &Self) -> unit)Close the channel: pending and future sends fail with .Err;
recv keeps draining buffered values, then resolves .None.
Returns: unit
_mark_receiver_dropped : (fn(self : &Self) -> unit)Record that this channel's Receiver handle is gone: every further
send fails, and a task suspended on a full buffer finds out on its
next tick. Internal — Receiver's dispose is the only caller.
Returns: unit
_wake_one_receiver : (fn(self : &Self) -> unit)Wake the longest-waiting receiver, if any. Called wherever a value becomes available.
Returns: unit
_wake_one_sender : (fn(self : &Self) -> unit)Wake the longest-waiting sender, if any. Called wherever a slot frees up.
Returns: unit
_wake_all_senders : (fn(self : &Self) -> unit)Wake every suspended sender and drop the queue.
Returns: unit
_wake_all : (fn(self : &Self) -> unit)Wake every suspended task on both sides.
Returns: unit
is_closed : (fn(self : &Self) -> bool)True once close() has been called.
Returns: bool
len : (fn(self : &Self) -> usize)Number of values currently buffered.
Returns: usize
_push : (fn(self : &Self, value : T) -> unit)Buffer value (moved in). Internal: the callers check the capacity.
Returns: unit
_add_sender : (fn(self : &Self) -> unit)Count one more Sender. Internal: Sender._attach and Sender.clone.
Returns: unit
_remove_sender : (fn(self : &Self) -> usize)Count one Sender fewer, and answer how many are left. Internal:
Sender.drop.
Returns: usize
impl(generic(T : Type), Channel(T), ...)
receiver : (fn(capacity : usize) -> Receiver(T))Create a channel and its single consumer — the tuple-free half of the
Sender/Receiver split. Mint producers with Receiver.sender() and
Sender.clone().
rx := Channel(i32).receiver(usize(4));
tx := rx.sender();
_s := io.await(tx.send(i32(1), io), io);
v := io.await(rx.recv(io), io).unwrap();
This is the form to use when the end of the stream matters: each handle
is its own binding and therefore has its own lifetime, so dropping the
last Sender closes the channel while the Receiver is still alive to
notice. pair cannot do that — see its note.
A Receiver with no Sender yet is not closed, so recv on it
suspends; mint the senders before receiving.
Returns: Receiver(T)
pair : (fn(capacity : usize) -> Tuple(Sender(T), Receiver(T)))Create a channel and hand back both halves at once — the shape of Rust's
mpsc::sync_channel(capacity) (this channel is bounded, like the
blocking one).
Both halves are move-only, so the tuple is taken apart by a consuming
match, which moves each half into its own binding:
match(
Channel(i32).pair(usize(2)),
(tx, rx) => {
_s := io.await(tx.send(i32(1), io), io);
v := io.await(rx.recv(io), io);
}
);
Each binding then has its own lifetime, as with receiver() +
sender(): dropping tx drops the last sender and closes the channel.
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. |
Methods
clone : (TryRecvError) fn(self : &TryRecvError) -> TryRecvErrorto_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
== : (TryRecvError) fn(lhs : &TryRecvError, rhs : &TryRecvError) -> bool!= : (TryRecvError) fn(lhs : &TryRecvError, rhs : &TryRecvError) -> bool