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.
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.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.
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.
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 one | the buffer is non-empty |
| What it gives you | Synchronization. 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:
- A receive on a closed channel returns immediately with the zero value and never blocks. The two-value form tells you which:
v, ok := <-ch—okisfalseonce the channel is closed and drained. for v := range chreceives until the channel is closed, then ends the loop cleanly. This is the canonical consumer.
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)
}
}
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:
volatile, no fence you write by hand.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.
- 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).
- 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
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.
-race exists.Interview prompts
- What is a data race, and why is "just use a mutex" a cost to the team even when it's correct? (§1 — concurrent access where one is a write with no ordering, which is undefined behavior under Go's memory model; a mutex works but encodes an unwritten "hold M before touching F" contract no signature or compiler enforces, so it scales badly across a team.)
- Explain "don't communicate by sharing memory; share memory by communicating." (§1–2 — give the state to one goroutine and have others send it messages instead of sharing the variable and guarding every access; ownership becomes singular and moves by being sent, so there's no race to guard — question 2 made structural.)
- What's the difference between unbuffered and buffered channels, and which is the default? (§3 — unbuffered blocks the send until a receiver meets it (a rendezvous → synchronization; a completed send proves receipt); buffered only blocks when full (decoupling, absorbs bursts). Default unbuffered — a buffer is complexity that must be earned with a measured n, not guessed.)
- Who closes a channel, and what panics? (§4 — only the sender, and only one closer; closing a closed channel panics, sending on a closed channel panics; receiving on a closed channel returns the zero value with ok==false; a nil channel blocks forever.)
- State the channel happens-before guarantee and why it lets you drop a lock. (§5 — a send happens-before the corresponding receive completes (and a close happens-before a receive that sees it closed), so every write the sender did before the send is visible to the receiver after it; passing the value transfers data and a synchronization point together, so no separate lock is needed.)
- You send a *T on a channel and the sender keeps using it. Is that safe? (§2, §6 — no; the channel copies the pointer, not the pointee, and ownership is by convention only, so both goroutines can now race on the pointed-to data. Send-and-forget is the discipline; go test -race catches violations.)
- When are channels the wrong tool? (§6 — for hot, simple shared state like a counter, a channel's tens-to-hundreds-of-ns per op loses badly to a sync/atomic add or a mutex (single-digit ns); channels orchestrate work, mutexes protect state — Lesson 11.)