Programming Languages · Concurrency Models
Rendezvous and Buffering in CSP-Style Channels
How an unbuffered channel makes send and receive a single synchronisation event, what a buffer actually buys, and why a blocked channel turns into a deadlock instead of an error.
Hoare’s 1978 paper describes a language in which processes have no variables in common at all. Two processes interact only by naming each other in an input or output command, and that command completes only when both sides are ready. The arrangement removes shared state entirely, so the question of which write raced with which read never arises. It also makes communication a scheduling event rather than a memory operation, which is the source of both the model’s clarity and its costs.
The mechanism is small enough to state completely: an input command in one process and an output command naming that process in another become a single atomic action when both are reachable. Neither can proceed alone.
What “both sides ready” actually requires
An unbuffered channel has no storage. A send evaluates its value and then waits. A receive waits for a value. When a sender and a receiver are both parked on the same channel, the runtime moves the value across and wakes both. The two continuations then resume in some order; the language guarantees only that the transfer happened before either resumed.
This is a rendezvous, and it has an unusual property: the sender cannot run ahead of the receiver by even one message. The producer’s rate is capped by the consumer’s rate exactly, with no queue to absorb a burst. In exchange, the channel gives a synchronisation edge for free. Because the transfer is atomic and observable by both parties, everything the sender did before the send is ordered before everything the receiver does after the receive. That edge is what lets a channel replace a mutex for the handoff case: the send carries the memory, not just the pointer.
The Go memory model states this directly: a send on a channel happens before the corresponding receive completes. The specification’s channel section defines an unbuffered channel as one where a send completes only when a receiver is ready. A buffered channel of capacity C weakens the other direction. Its k-th receive is ordered before its (k+C)-th send completes, which is the happens-before statement of a full buffer blocking a sender: on a channel of capacity 4, the fifth send cannot complete until the first receive has completed. The edge that matters for publishing data, send before its own receive, survives buffering unchanged.
The buffer is a queue with an index, nothing more
A buffered channel holds a ring buffer of fixed capacity chosen at construction. Sends complete while the buffer has space; receives complete while it holds items. A full buffer blocks senders, an empty buffer blocks receivers. Values are handed out in the order they were buffered, on every implementation.
The buffer changes three things and leaves one alone.
It decouples timing: a producer can run ahead by exactly capacity items and then stops. That number is a design decision, not a tuning knob to raise until the blocking stops, because the block is the flow control. A capacity chosen to absorb a burst relocates the latency rather than removing it; the queue depth bounds how stale an item can be by the time it is finally processed.
It changes the number of scheduling operations. An unbuffered handoff requires the sender to park and the receiver to be woken, or the reverse. A buffered send that finds space is a couple of index updates and a copy, with no scheduler involvement at all. For high message rates the difference in cost per message is dominated by that avoided context switch, which is why a buffer is usually worth its memory when the stages are already balanced.
It changes where memory lives: the ring buffer is allocated once, at construction, so the channel’s footprint is fixed and independent of load. Compare the actor mailbox in Mailbox Semantics and Ordering Guarantees in the Actor Model, whose queue grows without bound unless someone imposes a limit.
What it does not change is the delivery contract. Buffered or not, a channel is point-to-point, ordered, and each value is received exactly once by exactly one receiver. There is no broadcast, no persistence, and no retry.
Choosing between the two shapes
The distinction that matters in practice is whether the channel is a synchronisation primitive or a queue.
When the channel carries a handoff, such as a token, a work item whose completion the sender must know about, or a shutdown signal, an unbuffered channel is the honest encoding. The sender’s completion is evidence that the receiver took the value, and the code can rely on that. Adding a buffer of one converts the guarantee into “the value was placed somewhere”, which is not the same statement, and the difference shows up as a race that only appears when the receiver is slow.
When the channel carries a stream whose stages are independently paced, a buffer of some small size smooths the jitter between them. The size should be derived from the observed variance in stage latency, not from a wish to avoid blocking. A capacity larger than the chain’s worst-case skew buys nothing but memory and worse tail latency.
// Unbuffered: the sender does not proceed until the worker has taken the job.
func main() {
jobs := make(chan int)
done := make(chan struct{})
go func() {
for j := range jobs {
process(j)
}
close(done)
}()
for i := 0; i < 10; i++ {
jobs <- i // waits until the worker is ready to receive
}
close(jobs)
<-done
}
func process(j int) { /* work on j */ }
The close is a separate mechanism from the buffer, and it is one-directional: closing a channel means no further sends will occur, and receives drain the remaining buffer and then yield zero values immediately. Sending on a closed channel panics, which is the one place a channel operation reports an error rather than blocking.
Deadlock, and why it is not an exception
A process blocked on a channel that nobody will ever use is stuck, and the runtime has no general way to know that. In a closed world where all goroutines are parked and none can run, the Go runtime detects the situation and terminates the program with a deadlock report; that detection is a whole-program property and disappears the moment any goroutine is runnable for an unrelated reason, such as a timer or a blocked system call.
So the practical discipline is that every blocking operation needs a way out. The idiom is a second channel in a select:
select {
case v := <-results:
handle(v)
case <-time.After(2 * time.Second):
// the deadline is a policy decision, not a cleanup
}
select with multiple ready cases chooses pseudo-randomly, which prevents starvation of low-traffic cases; a select with a default clause never blocks and turns the operation into a poll. A nil channel blocks forever in any case, so a nil entry in a select disables that branch, which is how a case is switched off conditionally.
Deadlock in a channel program is a property of the communication topology, not of any single call. Two processes each sending to a channel the other should be receiving from will park forever, and no amount of buffering fixes it unless the buffer is large enough to hold the entire exchange. This is why the formal treatment matters: CSP’s trace and refusal models let a design be checked for deadlock before it runs, by asking whether there is a state in which every process refuses every event. Roscoe’s book develops that machinery, and the failure it predicts is exactly the one the runtime cannot distinguish from a legitimate wait.
Where the model stops being local
The rendezvous model assumes both parties are in the same runtime and can be scheduled against each other. Across a network there is no shared scheduler, so a send must be buffered by the transport, and the ordering and exactly-once properties have to be reconstructed at the protocol layer. That is the point at which channel-based code starts to look like the actor model with extra steps, and the reason the two families converge in distributed settings while remaining quite different in-process.
Within one process, the model’s remaining weakness is that a channel identifies a destination but not a protocol. Nothing in the type of a channel says which of the two sides sends first, how many messages the exchange comprises, or what happens if a participant exits early. Those are exactly the properties that session types make checkable, as described in Session Types and the Shape of a Channel Protocol.
References
- Communicating Sequential Processes — C. A. R. Hoare, Communications of the ACM 21(8), 1978
- The Theory and Practice of Concurrency — A. W. Roscoe, Prentice Hall, 1997
- The Go Programming Language Specification: Channel Types — The Go project