- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've read the proverb. It's in the Go documentation, on
stickers, in half the conference talks: "Do not communicate by
sharing memory; instead, share memory by communicating."
Then you open a real Go codebase and every concurrent type in it
is a struct with a sync.Mutex on top. The proverb says channels.
The code says mutex. Somebody is wrong, and it's tempting to
assume it's the code.
It isn't. The proverb is a design hint, not a lint rule. Both
tools are correct Go. The skill is knowing which problem you have
in front of you, because they are two different problems wearing
similar clothes. One is about guarding a piece of state that
several goroutines touch. The other is about handing a piece of
state from one goroutine to the next so only one owns it at a
time.
The two problems, stated plainly
A mutex guards state. The data stays in one place. Many
goroutines reach into that place, one at a time, do their thing,
and leave. Nobody owns the data; they take turns.
A channel transfers ownership. The data moves. A goroutine
builds a value, sends it, and then must never touch it again. The
receiver now owns it, exclusively, until it sends the value
somewhere else. There is no shared access because at any moment
exactly one goroutine holds the value.
That second sentence is the whole proverb. "Share memory by
communicating" means: instead of parking data in a shared box and
locking the box, pass the data down a channel so ownership moves
with it. The synchronization is the send and the receive. You
never lock because you never share.
The mutex version
Here is a counter. Many goroutines increment it. This is the
guard-state problem, and a mutex is the right answer.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
Nothing moves. c.n lives in the Counter for the whole
program. Every goroutine that calls Inc reaches into the same
integer and takes its turn under the lock. Pointer receiver, so
every caller shares the one mutex. This is small, obvious, and
fast.
Trying to force a channel here makes the code worse. You'd stand
up a goroutine that owns the counter, plus a request channel, plus
a reply channel for Value. More moving parts to protect a single
int++. The proverb doesn't ask you to do that.
The channel version
Now change the problem. A worker pool processes jobs. Each job is
owned by exactly one worker while it runs, then the result moves
on. Nothing is shared. This is the transfer-ownership problem, and
a channel fits it exactly.
type Job struct {
ID int
Data []byte
}
type Result struct {
ID int
Sum int
}
func worker(jobs <-chan Job, out chan<- Result) {
for j := range jobs {
sum := 0
for _, b := range j.Data {
sum += int(b)
}
out <- Result{ID: j.ID, Sum: sum}
}
}
When worker receives a Job off jobs, it owns that job. No
other goroutine has a reference to it. It computes, builds a
Result, and sends it away. After the send, the worker forgets
the result and loops for the next job. No lock appears anywhere,
because at no point do two goroutines hold the same value.
The wiring shows the ownership handoff:
func run(datas [][]byte) []Result {
jobs := make(chan Job)
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
worker(jobs, out)
}()
}
go func() {
for id, d := range datas {
jobs <- Job{ID: id, Data: d}
}
close(jobs)
}()
go func() {
wg.Wait()
close(out)
}()
var results []Result
for r := range out {
results = append(results, r)
}
return results
}
Notice there is still a sync.WaitGroup in there. That's the
point of the proverb being a hint and not a law: you use a
channel for the data flow, where ownership actually moves, and a
WaitGroup for the "are all workers done" signal, where nothing
moves and a counter is the honest tool. Mixing them is ordinary Go.
The ownership rule that keeps you honest
The line that separates a correct channel design from a subtle
data race is this: after you send a value on a channel, treat it
as gone.
job := Job{ID: 1, Data: buf}
jobs <- job
// buf is now owned by whoever received job.
// Writing to buf here is a data race.
buf[0] = 0x00 // BUG
Job.Data is a slice, which is a pointer to a backing array. The
send copied the slice header, not the bytes. Sender and receiver
now point at the same array. The channel gave you the
synchronization to hand it off, and then you reached back in and
mutated shared memory anyway. The race detector will find this;
go run -race on the real thing prints a DATA RACE report.
The mental discipline is simple. A send is a goodbye. If you need
the value after sending it, copy it before the send, or don't send
the original. This is exactly the guarantee a mutex does not give
you and does not need to: under a mutex, everyone expects to share,
so everyone locks.
A quick way to pick
Ask one question about the data: does it stay put, or does it move?
- Stays put, many readers/writers take turns — mutex. Counters, caches, a config struct reloaded in place, connection pools. The data has a home and goroutines visit it.
- Moves from producer to consumer, one owner at a time — channel. Pipelines, worker pools, event fan-out, request/reply between goroutines. The data has a journey and ownership travels with it.
Two more practical tie-breakers when it's genuinely ambiguous:
- If the shared thing is a single field you read and write in
nanoseconds, a mutex is almost always simpler and faster. Don't
build a goroutine and two channels to protect an
int. - If you find yourself locking a mutex, kicking off work, and unlocking in a different function than you locked in, you're probably fighting the guard-state model. That shape usually wants ownership transfer instead.
Where people misread the proverb
The proverb is not "channels good, mutex bad." The Go standard
library is full of mutexes, and sync.Mutex exists precisely
because guarding state is a real, common, correct pattern. The
proverb is aimed at a specific mistake: reaching for a shared
variable plus a lock when what you actually have is a pipeline,
and then spending your afternoons debugging lock ordering and
missed unlocks that a channel would have made structurally
impossible.
There's also sync/atomic for the narrow case of a single word
of state (a flag, a counter) where even a mutex is more machinery
than you need:
var closed atomic.Bool
func markClosed() { closed.Store(true) }
func isClosed() bool { return closed.Load() }
Same guard-state family as the mutex, one instruction cheaper, and
correct only because a single boolean is exactly what atomic
covers. Reach past it the moment you need to guard more than one
field together.
The takeaway
Go gives you both primitives because both problems exist. The bugs
come from solving one with the tool meant for the other.
Concurrency is where Go's small surface area hides a lot of depth:
the memory model, what a send actually guarantees, how the
scheduler parks and wakes goroutines. The Complete Guide to Go
Programming works through those runtime details, so the choice
between a channel and a mutex stops being a coin flip. Hexagonal
Architecture in Go is about the other half — keeping this
decision at the right boundary, so your domain code isn't the
place where lock ordering and channel plumbing leak in.

Top comments (0)