DEV Community

Cover image for Share Memory by Communicating: When a Channel Beats a Mutex in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

Share Memory by Communicating: When a Channel Beats a Mutex in Go


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
}
Enter fullscreen mode Exit fullscreen mode

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}
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() }
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)