Skip to content
gohacks.dev
Concurrency

Channels or Mutexes in Go and How to Decide Between Them

Channels or mutexes in Go, decided with a benchmark and a rule of thumb: who owns the data, whether a reply is needed, and what each choice costs.

8 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

"Share memory by communicating" is the Go proverb that gets quoted whenever someone reaches for sync.Mutex, and it is half of a decision rather than an instruction. The standard library is full of mutexes. The net/http server uses channels where goroutines hand work over. Both are right in their place, and picking the wrong one produces code that is harder to read and, sometimes, slower by a factor of ten. This article compares the two on the question that matters in practice, with a benchmark you can rerun, and ends with a short list of rules I use. It belongs to the concurrency patterns cluster and builds on channels and mutexes. Everything ran on Go 1.25.6 on an Apple M1 Max.

Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64

They answer different questions

A mutex answers: how do I let several goroutines touch this variable without corrupting it? A channel answers: how do I move a value, or a signal, from one goroutine to another? The overlap exists because you can often model the first question as the second, by giving the variable to one goroutine and having everyone else send it messages. That is legal, and it is where the trade-off lives.

The two tools differ in what they make easy:

MutexChannel
Natural fitState read and updated in short burstsWork, results and events moving between goroutines
OwnershipShared, protectedTransferred, or held by one goroutine
Cost per operationA few tens of nanoseconds uncontendedA hundred or more with a hand-off
WaitingBlocks until the lock is freeBlocks until the other side is ready
Signals (done, cancel, timeout)Cannot express themBuilt for them, and select combines them
Failure modeForgetting to lock, or locking in the wrong orderLeaks and deadlocks from a missing sender or receiver

Measuring the overlap

Two jobs can be done with either tool: a counter that many goroutines increment, and a map that many goroutines read and write. The benchmark below implements both ways. The counter has three versions (mutex, atomic, and a goroutine that owns the count and receives increments over a buffered channel). The map has two: an RWMutex and an owner goroutine that handles requests and sends replies. Every benchmark uses RunParallel, so all ten cores contend:

main_test.goGo
package main

import (
	"sync"
	"sync/atomic"
	"testing"
)

func BenchmarkMutex(b *testing.B) {
	var mu sync.Mutex
	n := 0
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			mu.Lock()
			n++
			mu.Unlock()
		}
	})
}

func BenchmarkAtomic(b *testing.B) {
	var n atomic.Int64
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			n.Add(1)
		}
	})
}

func BenchmarkChannelOwner(b *testing.B) {
	inc := make(chan struct{}, 256)
	done := make(chan int)
	go func() {
		n := 0
		for range inc {
			n++
		}
		done <- n
	}()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			inc <- struct{}{}
		}
	})
	close(inc)
	<-done
}

type store struct {
	mu sync.RWMutex
	m  map[int]int
}

func BenchmarkMapMutex(b *testing.B) {
	s := &store{m: map[int]int{}}
	b.RunParallel(func(pb *testing.PB) {
		i := 0
		for pb.Next() {
			i++
			if i%10 == 0 {
				s.mu.Lock()
				s.m[i%64] = i
				s.mu.Unlock()
			} else {
				s.mu.RLock()
				_ = s.m[i%64]
				s.mu.RUnlock()
			}
		}
	})
}

type req struct {
	key, val int
	write    bool
	reply    chan int
}

func BenchmarkMapOwner(b *testing.B) {
	reqs := make(chan req, 256)
	go func() {
		m := map[int]int{}
		for r := range reqs {
			if r.write {
				m[r.key] = r.val
				r.reply <- 0
			} else {
				r.reply <- m[r.key]
			}
		}
	}()
	b.RunParallel(func(pb *testing.PB) {
		reply := make(chan int, 1)
		i := 0
		for pb.Next() {
			i++
			reqs <- req{key: i % 64, val: i, write: i%10 == 0, reply: reply}
			<-reply
		}
	})
	close(reqs)
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMutex-10           	 9967186	       119.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkAtomic-10          	24701204	        52.63 ns/op	       0 B/op	       0 allocs/op
BenchmarkChannelOwner-10    	14706579	        80.73 ns/op	       0 B/op	       0 allocs/op
BenchmarkMapMutex-10        	37670449	        32.57 ns/op	       0 B/op	       0 allocs/op
BenchmarkMapOwner-10        	 2656702	       412.6 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	7.000s

Read the numbers as shapes, not as constants; your CPU will move them, and so will core count. On this machine the contended mutex counter came out at about 120 ns per increment, the atomic at about 53 ns, and the channel into an owner goroutine at about 81 ns. For the map, the RWMutex version cost about 33 ns per operation with nine reads for every write, and the owner goroutine with a reply channel cost about 413 ns.

Three conclusions follow. First, atomics beat everything for a single word, which is why atomics are the answer for counters and flags. Second, a channel into one owner is not slow for fire-and-forget work. The buffered channel did better than the contended mutex here because producers rarely block and the single consumer never contends with itself. That is the batching effect: the buffer decouples the callers from the owner. Third, the request and reply shape is the expensive one. Each operation involves two channel hand-offs and likely a goroutine wake-up. Twelve times slower than the lock for a map lookup is the price of asking the owner a question, and it is a price you pay on every read.

When a mutex is the right tool

Reach for a mutex when the shared thing is a small piece of state with quick operations: a map, a counter, a configuration struct, a connection pool's free list. The critical section is a few instructions, nobody holds the lock while doing I/O, and the data has no natural "owner" because every goroutine is equally entitled to it. The standard library does this everywhere. sync.Pool, http.Transport's connection cache and the log package's writer all use locks.

Signs that a mutex is correct:

  • The operation you protect is short and does not block on anything else.
  • Readers need an answer immediately (a lookup), not eventually.
  • The state is a data structure rather than a stream of work.
  • You can put the lock and the data in one struct with methods, so callers cannot forget it (mutex rules).

A mutex held across a network call or a disk write is a different matter: it serializes the slow thing and turns a lock into a queue. Copy what you need under the lock, release it, then do the slow work.

When a channel is the right tool

Channels are the right tool when the structure of the program is a flow. A producer finds work and a pool of workers does it (worker pools). Stages transform data in order (pipelines). A goroutine waits for either a result or a timeout (select). Ownership moves with the value: after you send a buffer down a channel, you do not touch it again, and the receiver is its only user. No lock is needed because the data is never shared, only handed over.

Signals are the other territory that belongs to channels alone. A closed done channel wakes every waiter at once and carries the happens-before edge that makes shared state visible (happens-before). A mutex has no way to say "tell me when X happens". A sync.Cond can, but it is awkward enough that most code uses a channel or a context instead.

The in-between case: one owner, many askers

The owner-goroutine design is attractive for state that has behavior: a rate limiter that refills over time, a cache with expiry and eviction, a session table that notifies subscribers. Putting that logic in one goroutine means only that goroutine can break the invariants, and the code reads as a sequential loop. That is the actor style. The benchmark shows what it costs: a request and reply per operation. So it works well when each operation does enough that 400 ns is noise (a request that was going to take a millisecond anyway), and badly when the operation is a map lookup in a hot path.

A good compromise is to keep the structure under a mutex and use a channel for the notifications around it: workers share a cache protected by an RWMutex, and a separate channel carries "cache invalidated" events. Each tool does the job it is cheap at.

Mistakes in both directions

I see these in review often enough to list them.

A mutex where there should be a channel. A sync.Cond or a polling loop with time.Sleep waiting for another goroutine to set a flag. The code is trying to express "wake me up when this happens", and a channel does that in one line. A related one is a mutex-protected slice used as a work queue, with workers spinning on it.

A channel where there should be a mutex. A goroutine and a pair of channels wrapped around a counter or a map, when the shared value is hit from a hot path. It is more code, slower, and it has a new way to deadlock: a caller that sends a request to the owner and never reads the reply, or an owner that blocks sending a reply to a caller who has gone away.

Holding a lock while sending on a channel. If the receiver needs the same lock, the program deadlocks, and it can happen several calls away from where either piece of code is written. The rule is to release the lock before the channel operation.

Unbuffered by default. Making every channel unbuffered because it is the default turns each send into a rendezvous. That is correct for a hand-off and wasteful for a queue; size the buffer deliberately (buffered and unbuffered channels).

A rule I actually use

Start with the data. If it is a value that moves through the program, make it a channel. If it is a structure that lives in one place and many goroutines use, make it a mutex. If you are about to build an owner goroutine for a map, ask whether the operations are slow enough to hide the hand-off, and if they are not, use the lock. When you cannot decide, write the mutex version first: it is smaller, and the race detector (race detector) will tell you if you got it wrong. You can always move to a channel design when the code grows behavior that wants a single owner.

The Go FAQ and the proverb collection both say this less bluntly. The wiki page on mutex or channel gives the same guidance as a table, and the sync package documentation states plainly that higher-level synchronization is better done through channels and communication, which is a recommendation about structure, not a ban on locks.

Taggedchannels
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.