Skip to content

Specification §4

Channels and inter-process communication

language-design.md §4 · 135 lines · 9 min read

Channels are typed and bidirectional. The direction is annotated on the type:

Channel[<-int, ->string] # sends int, receives string
Channel[<-int] # send-only
Channel[->string] # receive-only

The <- and -> notation indicates the perspective of whoever holds the handle: <-T = “I send T”, ->T = “I receive T”. This eliminates the invisible asymmetry of having to invert the types in the process’s signature: each side declares what it does from its own perspective. A raw Channel[T] (without arrows, as it appears in the network operations further on) is the symmetric one, sugar for Channel[<-T, ->T], which sends and receives the same T.

A channel is a core primitive (on the shelf with Result/Optional/Ptr), not a user decl. It has no fields of its own to initialize: what it holds (the queue, the control block, the synchronization) is opaque runtime state. A channel’s buffer belongs to the runtime, not to the @mm of whoever creates it (see section 5). Hence there is no raw Channel{...} literal and no zero-value: an “uninitialized” channel would be null, which the language does not have (section 6). It comes into being only through explicit construction.

Construction is an associated function (the Type.new() idiom from section 2), because only the runtime knows how to allocate the control block:

c := Channel.new() // unbuffered (the default: rendezvous, the send blocks until the receive)
c := Channel.new(64) // buffered, capacity 64

new() gives the default (unbuffered); capacity as an argument gives the buffer. Direction (<-T/->T) is part of the type, checked at assignment and at spawn; the factory returns the channel, and the side holding it takes the perspective its signature declares.

The channel is a contract between the two ends. The compiler verifies that the two sides are complementary at the spawn:

chan: Channel[<-int, ->string] // caller: sends int, receives string
spawn worker(chan)
fn worker(chan: Channel[<-string, ->int]) { // process: sends string, receives int
val := -> chan catch |e| { return } // receives int; the failure is ProcessDown
chan <- val.to_str() // sends string
}
chan <- data // async send (fire and forget)
data := -> chan catch |e| { return } // receive: blocks until data arrives, or the senders are gone
result := chan <-> data timeout(5s) // sync request-reply with mandatory timeout
catch |e| { ... } // Timeout | ProcessDown

The <-> does not introduce async “coloring”; it simply blocks the current process. The scheduler takes care of the rest. There is no infection up the call stack.

An async send chan <- data on a full bounded channel BLOCKS the sender (backpressure, the OOM-prevention layer of section 5): the producer suspends until space frees, so it cannot outrun the consumer. If the consumer has died, the blocked send does not hang forever: it unblocks with error.ProcessDown (section 7). Between “block while the consumer is alive” and “ProcessDown when it is dead”, there is no separate “buffer full” error and no non-blocking try_send: a load-shedding “reject if full” policy is built explicitly (a bounded structure the producer inspects, or <-> with a timeout), not baked into the send.

Delivery is FIFO per channel. Two sends on the same channel arrive in the order they were sent, and this does not depend on which scheduler you compiled with — the two backends have the same communication API (section 3), so a build flag must never change what a program means. Between different senders on one channel there is no relative order: each sender’s own stream is preserved, and the streams interleave. (This is the mailbox-free form of the guarantee: there is no shared inbox where several senders merge, so the channel is the pair.)

What crosses is copied DEEPLY, or moved with @transfer. Heaps are isolated (section 5), so a value handed to a channel must not leave the receiver pointing into the sender’s heap: when the sender dies, its @mm is released (section 8) and such a pointer would dangle — the cross-heap corruption the isolation exists to prevent. A shallow copy is not enough, because the user-visible pointers (*T, [*]T) are not the only indirection: a string and a []T carry one internally, as does any struct containing them. So the crossing copies the whole reachable value into the receiver’s heap. @transfer is the way to avoid the copy: it moves, invalidating the source, and the object travels with its mm.

Timeout is mandatory on <->. Without it, compile error. The reason: two processes doing <-> to each other simultaneously produces guaranteed deadlock, and the timeout is the only structural way out. Forcing the declaration makes the contract explicit: “I expect a reply, but for at most N time”.

// COMPILE ERROR: <-> without timeout
result := chan <-> data
// OK: recovering with a value is match over the Result (error is a union → branch by variant)
result := match (chan <-> data timeout(5s)) {
Ok(reply) => return reply
Err(error.Timeout) => return retry_logic()
Err(error.ProcessDown) => return escalate()
}

The timeout does not change the “guaranteed request-reply” semantics; it changes it to “request-reply with a deadline”. If the deadline is long enough for your system, the behavior is identical. The difference is that the deadlock has a way out.

Waiting for several channels at the same time and reacting to the first one that becomes ready is the match without a subject, what other languages call select (the full form is in section 14). There is no select keyword: it is the same match, distinguished by not having a value between match and {. The timeout(...) is a trailer of the select: the block runs if no channel becomes ready within the deadline; without it, the select waits indefinitely, and timeout(0) is the non-blocking poll.

match {
msg := -> chan_a => handle_a(msg)
msg := -> chan_b => handle_b(msg)
} timeout(100ms) {
handle_timeout()
}

When more than one channel is ready, the arms take turns. The select does not always pick the first ready arm, and it does not pick at random: each select site remembers which arm it served last and, among those ready now, takes the next one. Picking the first ready arm would starve the others (a busy producer on the first arm and the second never runs — a liveness bug that never announces itself); picking at random, as Go does, fixes the starvation but throws away reproducibility. Taking turns buys both: no arm starves, and the same sequence of arrivals always produces the same sequence of choices, which is what the deterministic scheduler exists for. What remains non-deterministic is only which channels are ready — that is the concurrency itself, and it is visible in the code (section 14).

There is no close. A channel is not terminated by hand; the process lifecycle already ends a conversation. A receive blocked on a channel with no live sender left unblocks with error.ProcessDown, the mirror of the blocked send in the same situation, so a consuming loop ends by handling that error.

That is also what a receive’s TYPE says: -> chan delivers Result[T, error{ProcessDown}], because it produces a value or fails, and a failure has to be handled. Only ProcessDown, and no Timeout: a plain receive carries no deadline. The deadline is what <-> adds, and it adds it because two processes doing <-> at each other deadlock, which a one-way receive cannot do.

A receive whose senders are all gone ends the stream even if values are still buffered: the buffered ones are delivered first, and the failure comes when there is nothing left AND nobody can add more.

loop {
v := -> chan catch |e| { break } // the producer died: the stream is over
process(v)
}

Adding close would be a second way to end a stream, competing with the death of the process that feeds it, and the two would have to be reconciled at every receive. The closed field of the introspection API (section 19) is therefore an observation, not a state anybody sets: it says the channel has no live counterpart left.

Everything so far is a local channel: two processes in the same runtime, each with its isolated heap (section 5), fast and reliable communication. But the “isolated worlds talking by channel” model does not stop at the runtime: two runtimes on different machines (a server and a client, two of your microservices) are just two more isolated worlds, and the communication between them is the same concept, a channel. The difference is the physics: this channel crosses the network.

The design decision is that there is no network channel type. It is the same Channel[T], obtained by a network operation that imposes what the network physically requires, and the two things it requires already exist in the language:

1. The payload has to serialize. Even locally, the two sides have isolated heaps (section 5): a local channel moves or copies the value between them, by @transfer (the object travels with its mm) or copy, and never aliases a raw pointer (the sender’s heap is not visible at the destination, so a *T would point to nothing). It is cheap because it is the same machine: the in-memory form suffices, without translating to bytes. Over the network, not even that: a pointer’s address means nothing on the other side and the in-memory representation does not cross machines, so T has to become bytes. This is not a property of the channel; it is of what you send. So the constraint falls on T: the network operations require T + Serializable (a stdlib interface, encoding; see section 9 for how interfaces work). You cannot send something non-serializable over the network, because the operation’s signature does not accept a T that does not satisfy Serializable: it does not compile. The network self-selects by the constraint; no new type nor decorator marks “this is network”, because the payload’s type already says everything.

2. Establishing and maintaining can fail. A local channel almost always succeeds (the other process is in the same runtime). Over the network, the other side may not be there (it went down, closed, latency), so establishing the connection returns Result, and the obtained channel inherits the mandatory sync timeout (above) for each operation. The failure does not live in a special channel type; it lives in the operation that creates it (a Result/catch) and in the timeout the channel already has.

The operations live in the net package of the stdlib:

use net
// server: listens, and each accepted connection becomes a Channel[T]
listener := net.listen("0.0.0.0:8080") catch |e| { return } // can fail (port busy)
loop {
chan := net.accept[Message](listener) catch |e| { continue } // Message implements Serializable; can fail
spawn handle(chan) // treats each client as a process
}
// client: connects, obtains a Channel[T]
chan := net.connect[Message]("api.exemplo:8080") catch |e| { return } // can fail (network)
chan <- request // SAME channel API
reply := -> chan timeout(5s) // timeout already mandatory

net.connect[T] / net.accept[T] require T + Serializable and return Result (the catch). What you get is an ordinary Channel[Message]: send, recv, match/select, catch, all identical to the rest of the section. The network did not add a communication model; it reused the whole channel and let the physics (serialization, failure) express itself through the constraint and the Result that already exist. Programming against a network channel is programming against a channel; the type only forces you to respect what a local channel let you ignore.

And it is more general on purpose than UI: any communication between runtimes over the network (microservices, remote workers, serverclient) uses this. The server/client boundary of the UI extension (section 20) is the first consumer, but it is not a UI concept; it is concurrency extended to the network.