all_lessons/Go/09 · Channels & CSPlesson 10 / 18

Channels and CSP: share memory by communicating

Lesson 08 made the worker nearly free — but a million cheap goroutines that all reach into the same shared variables is a million ways to corrupt it. The hard part of concurrency was never starting the work; it is coordinating it without locks turning the code into a minefield only the original author can walk through. Go's headline answer is a primitive borrowed from Tony Hoare's Communicating Sequential Processes: instead of letting goroutines touch shared state and guarding every touch with a mutex, you have them pass values to each other through a typed pipe, so that at every instant exactly one goroutine owns the value. This lesson is about the channel — what it is, what it guarantees, and why "share memory by communicating" is question 2 of the five turned into a language feature.

The thesis, here
This is the cleanest case in the whole series of complexity being relocated rather than added (Lesson 00). The reader-facing complexity Go wants to delete is the lock discipline: in a shared-memory model, correctness lives in an invisible, unwritten contract — "you must hold mutex M before reading field F" — that no signature states and no compiler checks, so every new engineer can break it. Go's channel makes the safe pattern the default expression: send a value and you have transferred ownership of it, full stop, with a synchronization guarantee the runtime enforces. You still pay — a channel send is more expensive than an unguarded read, and channels do not make every problem disappear (Lesson 11 is the honest counterweight). But the cost buys a model a team can reason about by reading the code, not by memorizing folklore.
Linear position
Prerequisite: Lesson 08 (goroutines — the cheap concurrent worker and the G-M-P scheduler) and Lesson 02 (value semantics — a value is data with a known layout, copied at boundaries). A channel is the bridge between them: it moves a value from one goroutine to another.
New capability: explain why a channel is safer than a shared variable, choose unbuffered vs buffered with a reason, use close and range correctly, and state the happens-before guarantee that makes a send-then-receive safe without any extra lock.
The plan
Four moves. (1) Name the pain — shared memory makes correctness an invisible contract. (2) Introduce the channel as a typed conduit that transfers ownership, and read its mechanics off value semantics. (3) Distinguish unbuffered (synchronization) from buffered (decoupling), and get close right. (4) State the happens-before guarantee precisely — the formal reason a channel replaces a lock — and account honestly for what a channel costs.

1 · The pain: shared memory is an invisible contract

Two goroutines, one counter. Both run counter++. That single line is really three machine operations — read the value, add one, write it back — and nothing stops the scheduler from interleaving two goroutines' reads and writes so that two increments land as one. This is a data race: concurrent access to the same memory where at least one access is a write and there is no synchronization ordering them. The result is not merely "wrong number"; under Go's memory model a data race is a bug whose result is undefined by the spec — you can read a torn or stale value, and on a multiword value like a slice header an inconsistent mix of two writes — though, unlike C/C++, Go's memory model bounds the damage to preserve memory safety: a racy read of a single-word value yields some value actually written, never garbage, and even a torn multiword read cannot fabricate an out-of-thin-air pointer or corrupt unrelated memory.

The classic fix is a mutex: hold a lock around the access. It works, and Lesson 11 covers it properly. But look at what the fix costs the reader. The rule "you must hold mu before touching counter" appears in no type, no signature, no compiler check — it lives in a comment at best, in the author's head at worst. Every function that touches the field must know and obey it; every new engineer can violate it without the build complaining. The complexity isn't in any one line — it's in the unwritten, global, easily-broken contract spread across the whole program. That is precisely the kind of cost the Go bargain is built to attack: correctness that depends on everyone remembering folklore does not scale to a team.

The CSP move
Go's primary answer inverts the problem. Rather than let many goroutines share one variable and guard every access, give the variable to one goroutine and have the others ask it to act by sending messages. Rob Pike's slogan: "Don't communicate by sharing memory; share memory by communicating." The shared state still exists — but only one goroutine ever touches it, so there is no race to guard. This is question 2 of the five — who owns this data, and how does it move? — answered structurally: ownership is singular, and it moves by being sent.

2 · The channel: a typed conduit that hands off ownership

A channel is a typed, thread-safe queue with built-in synchronization. You make one with make, send with ch <- v, and receive with v := <-ch. The arrow points the way the data flows.

ch := make(chan int)   // a channel that carries ints
go func() {
    ch <- 42           // send: hand off the value
}()
v := <-ch              // receive: take ownership of it
fmt.Println(v)         // 42

The type is half the safety. chan int carries ints and nothing else — the compiler rejects sending a string, so a whole class of "wrong message" bugs is gone before runtime. The other half is the handoff. Because Go has value semantics (Lesson 02), sending a value copies it into the channel and the receiver gets that copy; the sender should treat its own copy as gone. For a small value (an int, a small struct) that is a literal byte copy. For something whose ownership matters — a slice, a map, or a pointer to a big struct — you send the header or the pointer (a slice header is 3 words = 24 bytes on 64-bit; a pointer is 8 bytes), and the convention is that the sender stops using it afterward. The channel doesn't physically prevent you from keeping a pointer and racing on it — but the idiom, "send it and forget it," is what makes ownership singular, and the race detector (Lesson 11) catches you if you cheat.

goroutine A goroutine B owns v ──┐ ┌── now owns v │ ch <- v │ ┌────────▼────────┐ <-ch │ │ chan T (queue) ├─────────┘ └─────────────────┘ at every instant, exactly ONE side owns the value. the channel is the only place ownership changes hands.

You can also constrain direction in a signature, which documents intent and lets the compiler enforce it: func produce(out chan<- int) can only send, func consume(in <-chan int) can only receive. The arrow on the type is a contract the reader can trust — question 3 (failure in the signature) and question 4 (depend on a behavior) applied to concurrency.

3 · Unbuffered vs buffered: synchronization vs decoupling

The single most consequential choice is the channel's capacity, because it changes when a send returns.

Unbuffered — make(chan T)Buffered — make(chan T, n)
A send blocks until…a receiver is ready to take it — sender and receiver meet (a rendezvous)there is room in the buffer; only blocks when all n slots are full
A receive blocks until…a sender is ready to give onethe buffer is non-empty
What it gives youSynchronization. A completed send proves the value was received — the two goroutines are now at a known point relative to each other.Decoupling. A fast producer can run ahead of a slow consumer by up to n items without waiting — absorbs bursts.
Reach for it when…you need a handoff or a signal — "this is done," "go now."you have a known burst size or want to cap in-flight work; n is a measured number, not a guess.

The default should be unbuffered, and the reason is the bargain again: an unbuffered channel adds nothing to reason about — the send and the receive are a single synchronized event. A buffer is itself complexity that must be earned. Sizing it is exactly the thread-pool-tuning question from Lesson 08 in miniature: too small and you still block; too large and you hide backpressure, let a producer outrun a consumer, and turn a fast failure into a slow memory leak. Pick n because you measured a burst, not to "make it faster" — an unmeasured buffer is a guess the next reader inherits.

Two unbuffered idioms worth memorizing. A bare struct{} channel is a pure signal that carries no data and costs no payload bytes:

done := make(chan struct{})
go func() {
    work()
    close(done)          // broadcast: "I'm finished"
}()
<-done                   // blocks until work() returns

4 · Closing, ranging, and the rules that bite

close(ch) declares "no more values will ever be sent." It is a signal to receivers, not a cleanup you must always call — an open channel that goes out of scope is garbage-collected fine. Closing matters because of how receivers detect the end of a stream:

func produce(out chan<- int) {
    for i := 0; i < 3; i++ {
        out <- i
    }
    close(out)                 // sender closes — see rule below
}

func main() {
    ch := make(chan int)
    go produce(ch)
    for v := range ch {        // ends when produce closes ch
        fmt.Println(v)
    }
}
The rules that panic if you break them
Only the sender closes, and only the sole sender — a receiver closing a channel is a logic error, and a second close is a panic (close of closed channel). Sending on a closed channel panics (send on closed channel). A send or receive on a nil channel blocks forever (useful with select in Lesson 10, a silent deadlock otherwise). You do not need to close every channel — close one only when a receiver must learn the stream ended.

5 · The happens-before guarantee — why a channel replaces a lock

The reason a channel is safe is not vibes; it is a formal ordering rule in the Go memory model. A data race is defined by the absence of a happens-before ordering between two accesses. Channels create that ordering:

The send/receive guarantee
A send on a channel happens-before the corresponding receive completes. So every memory write the sender did before the send is guaranteed visible to the receiver after the receive — no extra lock, no volatile, no fence you write by hand.
The close guarantee
A close happens-before a receive that observes the channel is closed (the zero-value/ok==false case). That is why "do work, then close(done)" reliably publishes the work's results to whoever was blocked on <-done.

This is the payoff of the CSP model in one sentence: passing a value through a channel transfers both the data and a synchronization point. You don't hand off the slice and then remember to also synchronize — the act of sending it is the synchronization. That collapses the invisible "hold the lock" contract from §1 into something the code states out loud. Note the unbuffered case is the strongest: because the send blocks until the receive, a returned send proves the handoff completed.

6 · The honest cost

This series sells trade-off literacy, so name the price. A channel operation is real work: a send or receive takes a lock on the channel's internal structure and may park and reschedule a goroutine through the runtime — on the order of tens to low hundreds of nanoseconds, versus single-digit nanoseconds for an unguarded variable read or an atomic increment. For a tight, high-frequency counter, a channel is the wrong tool and a sync/atomic add or a mutex wins (Lesson 11). Channels also do not eliminate concurrency bugs — they relocate them: forget to close and a range blocks forever; close too early and a sender panics; an unbuffered send with no receiver is a deadlock the runtime will detect and abort. And a channel does not stop you from sending a pointer and then racing on what it points to — ownership-by-convention is a discipline, not a wall, which is why go test -race exists.

What channels buy
  • Ownership made singular and visible. The unwritten "hold lock M" contract becomes a send you can read in the code.
  • Synchronization bundled with data. The happens-before guarantee comes free with the send — no separate fence to forget.
  • Type-checked messages and direction-checked signatures the compiler enforces.
  • Composability — channels plug into select, pipelines, fan-out/fan-in (Lesson 10).
What they cost
  • Per-op overhead — tens to hundreds of ns; far more than a shared read or an atomic, so wrong for hot single-variable state.
  • New failure modes — deadlocks, close-of-closed and send-on-closed panics, leaks from un-closed channels.
  • A buffer is a tuning knob that hides backpressure if you size it by guessing.
  • Ownership is by convention, not enforced — send a pointer and you can still race.

Checkpoint exercise

Try it
Find code you've written (any language) where two threads or callbacks touch the same variable behind a lock. Apply question 2 — who owns this data, and how does it move? — and redesign it in the CSP shape: one goroutine owns the variable, others send it request messages on a channel, and it replies (often on a reply channel carried inside the request). Then ask the cost question (5): is this state high-frequency and simple enough that a mutex or atomic would be cheaper and clearer (Lesson 11), or is the channel version genuinely easier for the next reader to verify? The skill is not "always use channels" — it is being able to argue the choice with a number and an ownership story.

Where this points next

A single channel handles one stream at a time, but real concurrent programs juggle several at once — wait on whichever of three inputs is ready first, give up after a timeout, stop a whole tree of goroutines when a request is cancelled. Lesson 10 introduces select, the multi-way switch over channel operations, and builds it into the patterns that make channels productive — pipelines, fan-out/fan-in — then closes the most common bug this lesson can create: the leaked goroutine that blocks forever on a channel no one will ever send to, and how context.Context cancels work cleanly so that never happens.

Takeaway
Shared memory makes correctness an invisible, team-wide contract — "hold this lock before touching that field" — that no signature states and any new engineer can break, and that is the complexity Go's concurrency model exists to delete. The channel is a typed conduit that, exploiting value semantics (Lesson 02), hands off ownership of a value from one goroutine to another so that exactly one owns it at every instant — "don't communicate by sharing memory; share memory by communicating." Unbuffered channels give synchronization (a rendezvous; a completed send proves receipt) and are the right default; a buffer buys decoupling but is a tuning knob that must be earned with a measured number, never guessed. Only the sender closes, closing on a closed or sending on a closed channel panics, and a nil channel blocks forever. The formal payoff is the happens-before guarantee — a send happens-before the matching receive, so sending a value publishes everything the sender wrote before it, bundling data transfer and synchronization into one act and collapsing the lock-discipline contract into code you can read. The honest cost: tens-to-hundreds of nanoseconds per op (wrong for hot single-variable state — that's Lesson 11), new deadlock and panic failure modes, and ownership enforced only by convention, which is why -race exists.

Interview prompts