Mutexes in Go and How to Protect Shared State Properly
How sync.Mutex works in Go: a lost-update demo, Lock with defer Unlock, guarding structs, no reentrancy, TryLock, lock ordering, and holding locks too long.

Two goroutines, one variable, no coordination: the first piece of advice about shared state in Go is "don't", and the second is "if you must, use a mutex". A mutex is the plainest tool in sync, and most real concurrent programs have several. This guide shows what goes wrong without one, how to use it correctly with a worked struct, and the five ways programs go wrong with it anyway.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It follows goroutines explained and channels, and belongs to the concurrency material. It leads to RWMutex, atomics and the race detector. Everything ran on Go 1.25.6.
What goes wrong without it
Incrementing a counter looks like one operation and is three: read, add, write. When two goroutines interleave those steps, increments are lost:
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
for range 1000 {
count++ // read, add, write: not atomic
}
})
}
wg.Wait()
fmt.Println("expected 100000, got a smaller number:", count < 100000)
}expected 100000, got a smaller number: trueA hundred goroutines each add 1000, so the answer should be 100,000. Run on a multi-core machine it comes out lower, because two goroutines read the same old value and both write back the same new one, so one increment vanishes. (On a program this short the loss is nearly certain with many goroutines but not guaranteed; the point is that nothing makes it correct.) That is a data race. The race detector reports it plainly:
go run -race -trimpath . 2>&1 | head -n 7==================
WARNING: DATA RACE
Read at 0x00c000124038 by goroutine 9:
main.main.func1()
example.test/lab/main.go:15 +0x38
sync.(*WaitGroup).Go.func1()
sync/waitgroup.go:239 +0x54The report names the two goroutines and the lines of the conflicting read and write, which is exactly the information needed to add the lock (reading race reports).
The fix: Lock and Unlock
sync.Mutex has two methods. Lock waits until no one holds the mutex and takes it; Unlock releases it. Code between them is a critical section that only one goroutine runs at a time:
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
count int
wg sync.WaitGroup
)
for range 100 {
wg.Go(func() {
for range 1000 {
mu.Lock()
count++
mu.Unlock()
}
})
}
wg.Wait()
fmt.Println(count)
}100000Now it prints 100000 every time. The mutex also provides ordering: everything one goroutine did before Unlock is visible to the next goroutine after its Lock (the memory model). A mutex's zero value is an unlocked mutex ready for use, so there is no constructor, which fits the idea of the useful zero value.
Put the mutex in the struct
A global mu and a global count is the demo form. In real code the lock and the data it guards belong together in a type, so the lock cannot be forgotten and the invariant has a home:
package main
import (
"fmt"
"sort"
"sync"
)
// SafeCounter is safe for concurrent use.
type SafeCounter struct {
mu sync.Mutex // guards counts
counts map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{counts: make(map[string]int)}
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[key]++
}
func (c *SafeCounter) Snapshot() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]int, len(c.counts))
for k, v := range c.counts {
out[k] = v
}
return out
}
func main() {
c := NewSafeCounter()
var wg sync.WaitGroup
for _, w := range []string{"go", "rust", "go", "go", "rust", "zig"} {
wg.Go(func() { c.Inc(w) })
}
wg.Wait()
snap := c.Snapshot()
keys := make([]string, 0, len(snap))
for k := range snap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, snap[k])
}
}go 3
rust 2
zig 1Notice the conventions, because they are the whole craft. The mutex is declared immediately above the field it guards, with a comment saying so. Each method that touches the data locks at its top and defers the unlock on the next line, so no early return can leave it held. Snapshot returns a copy and not the map itself, so callers cannot read it unprotected after the lock is released. Concurrent writes to a plain map are wrong and, in Go, fatal: the runtime detects them and kills the program with fatal error: concurrent map writes (that error).
Mistake 1: it is not reentrant
A goroutine that holds the lock and calls a method that locks the same mutex deadlocks itself, because Go's mutex does not track owners:
package main
import (
"fmt"
"sync"
)
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Deposit(n int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += n
}
func (a *Account) DepositTwice(n int) {
a.mu.Lock()
defer a.mu.Unlock()
a.Deposit(n) // tries to take the lock again
a.Deposit(n)
}
func main() {
var a Account
a.DepositTwice(5)
fmt.Println(a.balance)
}fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(0x1400007ce88?, 0x6c?, 0x100e2b160?)
runtime/sema.go:95 +0x28
internal/sync.(*Mutex).lockSlow(0x14000010130)
internal/sync/mutex.go:149 +0x170
internal/sync.(*Mutex).Lock(...)
internal/sync/mutex.go:70
sync.(*Mutex).Lock(...)
sync/mutex.go:46
main.(*Account).Deposit(0x14000010130, 0x5)
example.test/lab/main.go:14 +0x78
main.(*Account).DepositTwice(0x14000010130, 0x5)
example.test/lab/main.go:22 +0xa0
main.main()
example.test/lab/main.go:28 +0x30
exit status 2The program dies with fatal error: all goroutines are asleep - deadlock! and the state sync.Mutex.Lock (deadlocks). The convention that avoids it is a pair of methods: an exported one that takes the lock, and an unexported one (deposit) that assumes the lock is held and does the work. Exported methods call the unexported ones, never each other.
Mistake 2: copying a mutex
A mutex has state, and a copy has its own. A struct that contains a mutex must be passed by pointer, and value receivers on such a struct copy the lock:
package main
import (
"fmt"
"sync"
)
type Stats struct {
mu sync.Mutex
n int
}
func (s Stats) Count() int { // value receiver: copies the mutex
s.mu.Lock()
defer s.mu.Unlock()
return s.n
}
func main() {
var s Stats
fmt.Println(s.Count())
}go vet . 2>&1
true# example.test/lab
# [example.test/lab]
./main.go:13:9: Count passes lock by value: example.test/lab.Stats contains sync.Mutexgo vet reports Count passes lock by value: Stats contains sync.Mutex. Use pointer receivers for every method on a type that holds a lock, consistently (pointer or value receivers).
Mistake 3: holding the lock too long
The longer a critical section, the longer other goroutines wait. Anything slow inside it, a network call, a disk read, a time.Sleep, serialises all callers behind that slowness, and a call that blocks indefinitely holds the lock indefinitely. The rule is to compute outside the lock and lock only to read or write the shared data:
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.Mutex
data map[string]string
}
func slowLookup(key string) string {
time.Sleep(20 * time.Millisecond) // pretend this is a network call
return "value-for-" + key
}
// Get holds the lock only around the map access, never around slowLookup.
func (c *Cache) Get(key string) string {
c.mu.Lock()
v, ok := c.data[key]
c.mu.Unlock()
if ok {
return v
}
v = slowLookup(key) // many goroutines may compute the same key; that is the trade-off
c.mu.Lock()
c.data[key] = v
c.mu.Unlock()
return v
}
func main() {
c := &Cache{data: map[string]string{}}
var wg sync.WaitGroup
start := time.Now()
for _, k := range []string{"a", "b", "c", "d"} {
wg.Go(func() { c.Get(k) })
}
wg.Wait()
fmt.Println("four lookups in parallel took about", time.Since(start).Round(20*time.Millisecond))
}four lookups in parallel took about 20msThe four lookups overlap and finish in about 20 ms instead of 80, because the slow work is outside the lock. The trade-off, visible in the comment, is that two goroutines can both miss and both compute; if that is unacceptable, deduplicate with singleflight (singleflight). Holding the lock across slowLookup would have been correct and four times slower.
Mistake 4: inconsistent lock order
Two goroutines that take two mutexes in opposite orders can each hold one and wait for the other:
// goroutine 1: lock A, then B
// goroutine 2: lock B, then A -> deadlockThe cure is a global order: always acquire the lower-numbered lock first, or restructure so that one lock covers both pieces of data. A bank-transfer function that locks two accounts is the standard example, and ordering by account ID solves it. A runtime deadlock detector will not help you here if other goroutines are running; it only fires when everything is blocked.
Mistake 5: unlocking an unlocked mutex
Calling Unlock on a mutex that is not locked is a fatal error, not a recoverable panic: fatal error: sync: unlock of unlocked mutex. It comes from an Unlock on a path where the lock was not taken, or a double Unlock. The defer immediately after Lock pattern prevents it, since the two calls are adjacent and paired.
What contention costs
An uncontended lock and unlock is cheap, about 14 ns on the machine used here (see channels explained for the figure). The cost changes when many goroutines fight for the same mutex, because losers must park and be woken. This benchmark has every available core increment one shared counter, once under a mutex and once with an atomic:
package main
import (
"sync"
"sync/atomic"
"testing"
)
func BenchmarkMutexParallel(b *testing.B) {
var mu sync.Mutex
var n int
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
n++
mu.Unlock()
}
})
}
func BenchmarkAtomicParallel(b *testing.B) {
var n atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
n.Add(1)
}
})
}
func BenchmarkMutexSingleGoroutine(b *testing.B) {
var mu sync.Mutex
var n int
for i := 0; i < b.N; i++ {
mu.Lock()
n++
mu.Unlock()
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMutexParallel-10 10181739 111.6 ns/op 0 B/op 0 allocs/op
BenchmarkMutexParallel-10 10908871 111.5 ns/op 0 B/op 0 allocs/op
BenchmarkMutexParallel-10 10904530 109.9 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicParallel-10 24621507 59.45 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicParallel-10 21729862 57.17 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicParallel-10 18866811 60.17 ns/op 0 B/op 0 allocs/op
BenchmarkMutexSingleGoroutine-10 87100375 13.63 ns/op 0 B/op 0 allocs/op
BenchmarkMutexSingleGoroutine-10 87266906 13.76 ns/op 0 B/op 0 allocs/op
BenchmarkMutexSingleGoroutine-10 87762871 13.62 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 12.776sOn an Apple M1 Max with ten cores the medians were about 13.7 ns per increment for a single goroutine, 111 ns per increment with all cores contending for the mutex, and 59 ns with the atomic. So the same increment cost about eight times more under contention, because the cores keep taking the lock away from each other and the cache line holding it bounces between them. The atomic is faster than the mutex but pays the same bouncing, since every core writes the same word. The general lesson is that shared mutable state does not scale with cores, whatever protects it. The fixes are structural: give each goroutine its own counter and add them at the end, shard the data across several locks, or batch updates so the lock is taken once per hundred items (counters, sharded maps and lock contention).
Protecting an invariant, not a variable
The most useful way to think about a mutex is that it protects an invariant, a relationship between values that must hold whenever the lock is free. A bank account's balance and its transaction log must agree. A cache's map and its size counter must agree. The mutex is what lets a method break the invariant temporarily, in the middle of an update, because nobody else can look. This framing answers the question of scope: lock around everything that must change together, and read under the lock too, because a reader that sees the balance updated and the log not yet is observing a broken invariant. It also explains why two separate mutexes for two fields of one invariant is a bug: each field is protected, and their relationship is not.
Waiting for a condition
A mutex answers "who goes first", not "wait until something is true". A loop that locks, checks a condition, unlocks and sleeps is a poor wait: it adds latency and wastes wake-ups. The tools built for it are a channel (wake the waiter by sending), or sync.Cond, which pairs a mutex with a wait queue so a goroutine can atomically release the lock and sleep until another goroutine signals that the condition may have changed (sync.Cond). In most programs a channel is the simpler answer, and Cond earns its place when many waiters must be woken together, as with a broadcast on state change.
Fairness and starvation mode
Go's mutex has two modes. In normal mode a goroutine that wakes up competes with new arrivals, and the new arrivals, already running on a CPU, often win. That favours throughput. If a waiter fails to get the lock for more than 1 ms, the mutex switches to starvation mode, in which ownership is handed directly to the oldest waiter, trading throughput for fairness until the queue empties. You do not control this, and you rarely need to know it. It matters when you read a profile showing long waits on a mutex: the lock may be starving some goroutines, and the cure is shorter critical sections or less sharing, not tuning.
Embedding a mutex
You will see code that embeds the mutex in the struct, so c.Lock() works directly:
type Counter struct {
sync.Mutex
n int
}It saves a few characters and makes Lock and Unlock part of the type's public method set, which exposes the locking to every caller and lets them lock and unlock from outside, breaking the encapsulation the type should give. A named field, mu sync.Mutex, keeps the lock private. Use the named field unless the type is deliberately a lockable object.
Finding lock problems
Two tools matter. The race detector finds missing locks and runs with go test -race and go run -race; it only reports races that actually occur in the run, so a test that exercises concurrent paths is required. The mutex profile and the block profile find contention and long waits: enable them with runtime.SetMutexProfileFraction and runtime.SetBlockProfileRate, then read them with go tool pprof (mutex and block profiles). A goroutine dump with many goroutines in sync.Mutex.Lock on the same line points at the hot lock. Together they cover the two questions: is it correct, and is it fast.
TryLock, and why it is rarely right
Go 1.18 added TryLock, which takes the lock if it is free and reports whether it did, without blocking. The documentation itself says that correct uses are rare and that the need often signals a design problem. A legitimate use is skipping work that another goroutine is already doing, for example a periodic refresh: if the lock is busy, someone is refreshing, so skip. If you find yourself polling TryLock in a loop, you wanted a channel or a condition variable (sync.Cond).
Alternatives, and when to prefer them
A mutex is the right tool when several goroutines read and update a shared structure. Other tools fit other shapes. For a single counter or flag, an atomic is simpler and faster (atomics). For read-heavy data with rare writes, sync.RWMutex may help, but it costs more per operation and often loses (RWMutex). For data that one goroutine can own, a channel and a single goroutine need no lock at all (channels or mutexes). For a map that is written once and read often, sync.Map or copy-on-write may fit (sync.Map or a mutex). For heavily contended hot state, sharding the data across several mutexes cuts contention (sharded maps).
A quick design review
Before you commit a type with a mutex, read it once as a stranger would. Can you point to every field the lock covers? Is there a path that reads one of them without locking, such as a String() method used in logging? Does a method hand out a pointer or slice into protected data, so that callers mutate it after the lock is gone? Does any method call out to user-supplied code, a callback or an interface method, while holding the lock, where the callee might call back in? Each answer that is not an easy "no" is a bug waiting for the right timing, and each is cheap to fix while the type is small. Most mutex bugs in production are not exotic races. They are a logging statement that reads a field without the lock, or a getter that returns the internal map.
Checklist
- Which data does this mutex guard? Say so in a comment, and place it above those fields.
- Does every access, reads included, happen under the lock?
defer Unlock()on the line afterLock().- Pointer receivers; no copies;
go vetin CI. - No exported method calls another that takes the same lock.
- No I/O, no channel operations, no callbacks into unknown code while holding the lock.
- With two locks, a fixed order.
- Tests run with
-race.
The reference is the sync.Mutex documentation, and the Go blog's Introducing the Go Race Detector explains the tool that finds the locks you forgot.


