Skip to content

Specification §4

Channels and inter-process communication

language-design.md §4 · 210 lines · 16 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] = Channel.new() // 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_string() // sends string
}

What one side sends, only the other side receives. A channel whose two directions carry different types (Channel[<-int, ->string] above) is a conversation between two sides: the caller’s ints reach only the holders of the complementary end, and the strings the caller receives come only from them – never its own values back, however the channel is buffered and however many processes hold each side (inside, it is one queue per direction). A symmetric Channel[T] is a single queue that every holder sends into and receives from – and so is Channel[<-T, ->T], the same type both ways: one queue per message type, not per direction. That is what lets a process send to itself, a catch handler notify main, and main wire two children together over one channel. It also means that on a channel with one type, sending and then receiving does not make a request and its reply: port <- x followed by r := -> port can take back the very value just sent, if it is still in the queue when the receive runs (or another sender’s). A request that expects its answer is <-> – r := port <-> x timeout(1s) – which pairs the two (below); the analyzer points at the send-then-receive shape and suggests it.

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.

A channel inside a message is one more end. A channel handle can travel inside a message (or a struct passed to spawn), and it counts as an end the way a spawn argument does: from the send until the message is received, the message itself holds the ends it carries – the ones the field’s (or the message’s) type names, Channel[<-T] a sending end, Channel[->T] a receiving one, Channel[T] both – and the process that receives it holds them from then on, so its death releases them like any end it has. The sender keeps its own. So a channel stays alive for whoever receives it even if the process that made it dies first, and a stream still ends with error.ProcessDown when the last sender is gone. A message that will never be received lets go of what it carries: one still buffered when its channel goes away, a <-> request withdrawn by its client, an answer whose client gave up. A <-> reply address (below) travels the same way: a message carrying it hands it to its receiver, who may answer it.

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.

A reply always reaches the client that asked. The server answers with a plain send on the channel (req := -> ch then ch <- answer), and several clients – and several servers – may be on that channel at once. So each request travels with its own private reply path, which only its client waits on: the process that takes a request with -> ch owes that request its answer, and its next send on the channel IS the answer, delivered to the client that asked and to nobody else (not another client, not another server receiving on the same queue). The debt ends in one of three ways:

  • the server answers (its send goes to the client, or is dropped if the client already gave up);
  • the server receives from the same channel again without answering – it gave up on that request, and the client gets error.ProcessDown at once;
  • the server dies, and the client gets error.ProcessDown at once.

No clock decides any of this, and the exchanges do not wait for each other: a pool of servers on one channel serves its clients in parallel. Each <-> ends with its own reply, error.Timeout or error.ProcessDown – never somebody else’s reply. <-> works on any channel, buffered or not: the buffer only queues requests. A client that gives up while its request still waits in the buffer (no server took it yet) takes it back, so a Timeout before any server saw the request means no server ever will; a request still waiting when nobody but its client could take it any more (the servers are gone) ends in error.ProcessDown. timeout(0) looks once: the request goes out only if a server is receiving right now, and the reply is taken only if it is already there.

Because the plain-send answer goes to the request the server took LAST, a server that holds several requests before answering, or hands a request to another process to answer, needs the request’s reply address (below).

The reply address: req, reply := <-> ch. <-> written before the channel is the server’s side of <->: it takes the request together with its reply address. reply has the type Reply[T], where T is what this side of the channel sends (the answer), and it is answered with reply <- v. The client does not change: it still writes r := ch <-> req timeout(d). With the address, the answer no longer has to be the server’s next send – it can come later, in any order, or from another process:

fn server(ch: Channel[<-int, ->int]) {
loop {
req, reply := <-> ch catch |e| { return }
spawn worker(req, reply) // the worker answers; the server takes the next request at once
}
}
fn worker(n: int, reply: Reply[int]) {
reply <- expensive(n)
}

The request is whatever the channel carries; a request that is a tuple stays whole in the first name (t, reply := <-> ch), and nested destructuring takes it apart in place ((a, b), reply := <-> ch). pair := <-> ch binds the pair itself, and a select arm takes the same form (req, reply := <-> ch => { ... }). A request sent with a plain ch <- v (not <->) has nobody waiting for an answer: taken with <-> ch, its address answers nobody, and answering it does nothing.

A reply address is used once. Answering it, or handing it on – to a spawn, a call, a message, another name – uses it up, and a second use on any path is a compile error: two answers in a row, an answer after the address went to a worker, an answer after a branch that may already have answered, an answer inside a loop body, or inside a lambda passed to an ordinary parameter (a loop body and such a lambda can run more than once, or not at all). A lambda passed to a @consume function parameter (section 5) runs exactly once, so it may answer: with_lock(fn() { reply <- v }) where fn with_lock(f: fn() @consume). Answering never fails: if the client gave up, the answer is dropped, so reply <- v takes no catch trailer. The address belongs to the process that holds it: if that process dies without answering – or hands it to a worker that dies without answering – the client gets error.ProcessDown at once, not at its deadline. Copies the compiler does not follow (a struct that holds the address, copied) can carry the same address twice; the first answer is the one the client gets and a later one is dropped, as in Erlang’s gen_server.

Not wanting the address: req, _ := <-> ch is req := -> ch. The server then answers with a plain send, by the rule above. That is also how a server declines a request it will not answer: it receives again without answering, and that request’s client gets error.ProcessDown at once:

loop {
req := -> ch catch |e| { return }
if !valid(req) {
continue // receive again without answering: this request's client gets error.ProcessDown now
}
ch <- answer(req)
}

With an explicit address, declining is not answering: when the process holding the address dies, the client gets error.ProcessDown.

More than one answer. A reply address carries one answer. A client that wants several – a stream of results, progress reports – sends a channel of its own in the request, and the server sends on it as many times as it needs:

decl Job {
count: int
out: Channel[<-int] // where the answers go: the client's own channel
}
fn server(jobs: Channel[->Job]) {
loop {
job := -> jobs catch |e| { return }
loop i in 0..job.count {
job.out <- i * i // as many answers as the job asks for
}
}
}
// the client
results: Channel[int] = Channel.new(3)
jobs <- Job{count: 3; out: results}
loop i in 0..3 {
v := -> results catch |e| { return }
io.println(v)
}

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.

Who counts as a sender or a receiver. The process that creates a channel holds both of its ends. Passing it to a spawn as a directional parameter gives that end to the child (Channel[<-T] hands the send end, Channel[->T] the receive end), and the creator stops counting on that side – but only after its last use of the channel, not at the first spawn. loop i in 0..100 { spawn client(req) } keeps main counting as a sender until the loop is over, so a first client that finishes before the hundredth is spawned does not leave the server with “no sender alive” while ninety-nine more are coming. The last use is the last statement of the block that created the channel that still mentions it (a spawn, a send, a receive, a call that takes it, a lambda that captures it). When the channel leaves that block – returned, stored in a binding, a field or a collection, sent on another channel – its last use is not visible there, and the creator keeps counting on the sides it gave away until it dies. A process’s own ends never count as a counterpart for itself: a receive in the creator whose only remaining senders are its own is the stream over, ProcessDown.

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.