Skip to content
gohacks.dev
Concurrency

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.

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

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:

main.goGo
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)
}
Text
expected 100000, got a smaller number: true

A 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:

Shell
go run -race -trimpath . 2>&1 | head -n 7
Text
==================
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 +0x54

The 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:

main.goGo
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)
}
Text
100000

Now 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:

main.goGo
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])
	}
}
Text
go 3
rust 2
zig 1

Notice 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:

main.goGo
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)
}
Text
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 2

The 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:

main.goGo
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())
}
Shell
go vet . 2>&1
true
Text
# example.test/lab
# [example.test/lab]
./main.go:13:9: Count passes lock by value: example.test/lab.Stats contains sync.Mutex

go 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:

main.goGo
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))
}
Text
four lookups in parallel took about 20ms

The 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:

Go
// goroutine 1: lock A, then B
// goroutine 2: lock B, then A  -> deadlock

The 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:

contend_test.goGo
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()
	}
}
Text
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.776s

On 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:

Go
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

  1. Which data does this mutex guard? Say so in a comment, and place it above those fields.
  2. Does every access, reads included, happen under the lock?
  3. defer Unlock() on the line after Lock().
  4. Pointer receivers; no copies; go vet in CI.
  5. No exported method calls another that takes the same lock.
  6. No I/O, no channel operations, no callbacks into unknown code while holding the lock.
  7. With two locks, a fixed order.
  8. 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.

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.