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.
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.
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 captured by an io.async body 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.
Cloneable: 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.
Type Parameters
| Name | Type | Notes |
|---|---|---|
T | Type | comptime |
Trait Implementations
impl(generic(T : Type), 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, 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 — 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), Sender(T), Clone(...))
clone : (fn(inout(self) : Self) -> Self)Create an independent clone of self.
Returns: Self
impl(generic(T : Type), 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 an async channel pair.
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.
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), 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
A bounded FIFO channel for same-thread async tasks.
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. .Err(value) once
the channel is closed or the Receiver is gone (Rust's SendError(T)).
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.
.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.
Returns: Impl(Future(Option(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)
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)
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 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 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
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).
The tuple keeps both handles alive for as long as the tuple binding
lives (measured 2026-09-10): Yo has no destructuring binding, so
tx := pair.0 copies a handle out rather than moving it, and the tuple
holds its own reference to each half. Dropping tx early therefore does
NOT drop the last sender, and the auto-close fires only when the whole
scope — Receiver included — goes away. Use this form when the producer
tasks and the consumer share a scope, or call close() explicitly; use
receiver() + sender() when the receiver must learn that the
producers are finished.
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
Methods
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
== : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> bool!= : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> bool