ADR 0032 — L1: channels between threads and between tasks
- Status: Accepted by the owner, 2026-10-31 ("Accept as written"): every question as recommended (§5).
- Scope:
Channel<T>inlib/std/channel.cee. It is the increment ADR 0016 §3.4 deferred ("Condvarand channels follow as a separate increment, each spec-first"), and the transport ADR 0031 §3.5 needs between executors.
1. Context
Threads share state today only through Arc<Mutex<T>> and the atomics (§38.3). ADR 0031 runs one executor per thread and never moves a task between threads, so tasks on different threads need a way to pass values that wakes a waiting task without blocking its thread.
Constraints from the tree:
- No tuples (
AGENTS.md), solet (tx, rx) = channel()cannot be written. Mutex.lockhassleep(§38.3), so async code cannot use one (N0295, type-system.md §40.1).- Allocation failure is a value (OD-6 and
ownership.md), so a send must not allocate behind the caller's back. - The interpreter runs one thread at a time (ADR 0016 §3.8).
2. Options
| A. One cloneable handle, bounded buffer (proposed) | B. Separate Sender<T>/Receiver<T> | C. Unbounded | |
|---|---|---|---|
| Creation | Channel<T>.create(n) | needs a pair: no tuples, so an out-parameter or a struct | as A or B |
| Who may send and receive | every handle | senders and receivers | as A or B |
| Allocation | once, at create | once | on every send that grows the buffer |
| Back-pressure | a full channel waits | as A | none |
A is proposed. B adds two types and a pair struct for what close() and documentation can say. C breaks the rule that allocation failure is a value at the place it happens.
3. Proposed decision
Channel<T: Send>is a library type,@threadSafe: a handle to a shared, reference-counted buffer ofcapacityvalues. It isSendandShare.Channel<T>.create(capacity: usize) -> Result<Channel<T>, AllocError>allocates the buffer once. A capacity of 0 panics: a rendezvous channel is not in v1.c.clone()returns another handle to the same channel. Dropping the last handle drops the values still buffered, in order, and frees the buffer.c.close()makes every later send fail. Receivers still take what is buffered, then see the end.
- Blocking operations, for threads, with the
sleepeffect:c.send(v: T) -> Result<void, T>waits while the channel is full, and returnsErr(v)if it is closed.c.recv() -> T?waits while it is empty, and returnsnullonce it is closed and empty.
- Async operations, for tasks (type-system.md §40), with the
suspendeffect. While they wait, the task is parked and its thread runs other tasks:await c.sendAsync(v: T) -> Result<void, SendError<T>>.SendError<T>isClosed(T)orCancelled(T), so a value is never lost: a cancelled or refused send hands it back.await c.recvAsync() -> Result<T?, AsyncError>. It returnsErr(AsyncError.Cancelled)only while it has taken nothing, so no value is lost to cancellation (ADR 0031 §3.4).
- Waking. A parked task registers its waker (
Async.waker()) with the channel. A send wakes the oldest waiting receiver, and a receive wakes the oldest waiting sender. Waking a task of another thread goes through that thread's executor (ceesafe_async_wake, safe from any thread), so a channel connects executors as well as tasks on one executor. Waiters are served first come, first served. - The lock. The channel's state is guarded by a spin lock over
lib/core's atomics. Its critical sections are a constant number of steps and never wait, allocate or call user code. Taking it therefore has no effect, and async code may use the channel. The blocking operations wait outside the lock, on a runtime condition variable (ceesafe_cond_*,@effects(sleep)). - Drops run outside the lock. A value is moved out of the buffer under the lock and dropped by the caller, so a
dropblock never runs while the lock is held. - The interpreter. Threads run one at a time and tasks on the virtual clock. A thread or task waiting on a channel that no one else can serve is a deadlock, reported as an interpreter fault (as ADR 0016 §3.8).
- Profiles. Hosted only, with threads (§38.5):
Channelis N0293 elsewhere.
4. Consequences
- Three runtime additions:
ceesafe_cond_*(a condition variable besideceesafe_mutex_*), thread-safe wakers, and each executor's wake descriptor (a self-pipe, or eventfd on Linux) that itspollwaits on with its other registrations. - The spin lock's
unsafecode keepsownership.mdT1–T3. OnlySendvalues cross (T1), and the buffer is reached only under the lock (T2). - The tests to add:
- run-pass: one thread to another, task to task on one executor, and a task on one thread to a task on another;
- check-fail: a value that is not
Send(N0334), and a blockingrecvin async code (N0295); - the fault sweep reaches
create, the only allocation.
5. Implementation (2026-10-31, L1 A2c)
- The runtime (
runtime/hosted/ceesafe_rt_async.c) keeps the channel element-blind: a reference count, a ring ofcapacityslots of the element's stride, a spin lock, an epoch that every send, receive and close advances, a list of task wait nodes, and a mutex and condition variable for blocked threads.begin_sendandbegin_recvreturn a slot under the lock, andend_sendandend_recvrelease it.waitblocks a thread until the epoch moves; it is the one call withsleep.parkqueues a node in the task's frame unless the epoch has moved (then the task retries at once), and every change wakes every queued task through its executor.
- The library (
lib/std/channel.cee) moves values withp.write(v)andp.read()between begin and end, and drops them after. A cancelled task leaves the queue before it returns. A capacity of 0 panics through an unwrappednull, until the language has a panic with a message. - The interpreter models it in its memory. A blocking wait is a thread wait, a task's wait parks it until the next change, and when every task waits, a thread that has not started runs. Its threads run one at a time, so a thread that blocks on a full channel cannot be resumed: values that cross threads in the interpreter need a channel large enough for them (ADR 0016 §3.8).
- Tests: run-pass/async-channel, check-fail/async-channel and check-fail/async-effects. The channel test passed 20 runs under ThreadSanitizer with small capacities, after one race it found in the cancellation sweep's counter.
6. Questions for the owner
- One handle type rather than a sender/receiver pair? Yes (§3.1).
- Bounded only, with a capacity of at least 1? Yes: unbounded and rendezvous channels can come later.
- A spin lock inside the channel, so async code can use it? Yes (§3.5).