golangtutorialAll tutorials

Go Mutex: Protecting Shared State from Race Conditions

A golang mutex tutorial that proves the race first, then teaches Lock/Unlock, RWMutex, the copy bug, atomics, and the honest mutex-vs-channel call.

Concurrency · Lesson 2Saved in this browser. No account required.

A sync.Mutex lets exactly one goroutine touch shared state at a time, which is how you stop concurrent writes from corrupting a counter, a map, or any struct. This tutorial proves a real race first, then teaches Lock/Unlock, RWMutex, atomics, and when a channel is the better tool. Every example ran on Go 1.24.7, including the race detector.

Works with Go 1.21+ (all examples verified on Go 1.24.7). You should already know how to start goroutines. If go func() is new, read the goroutines tutorial first, because everything below assumes concurrent execution.

Why concurrent writes corrupt data: prove the race first

A data race is two goroutines touching the same memory at the same time, with at least one writing. views++ looks atomic in the source, but the CPU does three things: read views, add one, write it back. Interleave two goroutines and both can read the same old value, both add one, both write it back, and one increment vanishes.

Here are 1000 goroutines each adding one to the same counter:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	views := 0

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			views++ // read, add one, write back: three steps, not one
		}()
	}

	wg.Wait()
	fmt.Println("final view count:", views)
}

Output:

final view count: 965

Not 1000. Thirty-five increments were lost to interleaving, and the number changes every run. That sync.WaitGroup blocks main until all 1000 goroutines finish; if it is unfamiliar, the WaitGroup guide covers it. The bug is views++.

You do not have to catch these by eye. Go ships a race detector. Run the exact same program with -race:

==================
WARNING: DATA RACE
Read at 0x00c000012178 by goroutine 7:
  main.main.func1()
      /tmp/mx/race/main.go:16 +0x84

Previous write at 0x00c000012178 by goroutine 77:
  main.main.func1()
      /tmp/mx/race/main.go:16 +0x96
...
final view count: 986
Found 2 data race(s)
exit status 66

The detector names the exact line, the two goroutines, and the memory address. Run your tests with go test -race in CI and it catches these before production does. The Go memory model is explicit that a program with a data race has undefined behavior, so this is not a style issue you can defer.

sync.Mutex: Lock, Unlock, and the deferred-unlock idiom

A sync.Mutex has two methods: Lock and Unlock. Between them lies the critical section, the code only one goroutine runs at a time. A goroutine that calls Lock while another holds the lock blocks until Unlock releases it. The zero value is an unlocked mutex, ready to use, no constructor needed.

Wrap the increment and the race disappears:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu    sync.Mutex
		wg    sync.WaitGroup
		views int
	)

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			defer mu.Unlock()
			views++
		}()
	}

	wg.Wait()
	fmt.Println("final view count:", views)
}

Output (run with -race, clean):

final view count: 1000

Every run, 1000. Note defer mu.Unlock() on the line right after Lock. This is the idiom you should reach for by default. Defer runs the unlock however the function exits, including an early return or a panic, so you cannot leak a held lock down an error path. Manual Unlock is worth it only in a hot loop where you must release before the function ends; even then, be sure every branch unlocks exactly once.

Put the mutex next to the data it guards

A loose mu and a loose views in main work for a demo. In real code, bundle the lock into a struct directly above the fields it protects, and expose methods that lock. Then the invariant “hold mu to touch counts” lives in one type instead of scattered across call sites.

package main

import (
	"fmt"
	"sync"
)

// SafeCounter bundles the lock with the data it protects. The mu field
// sits directly above the map it guards, so the invariant is obvious.
type SafeCounter struct {
	mu     sync.Mutex
	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) Get(key string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[key]
}

func main() {
	hits := NewSafeCounter()
	var wg sync.WaitGroup

	for i := 0; i < 500; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			hits.Inc("/checkout")
		}()
	}

	wg.Wait()
	fmt.Println("/checkout hits:", hits.Get("/checkout"))
}

Output:

/checkout hits: 500

A plain Go map is not safe for concurrent use; a simultaneous read and write panics with concurrent map writes. The mutex is what makes this map safe. This “unexported mutex plus the state it guards, methods do the locking” shape is the standard way to build a thread-safe type in Go, and callers cannot forget to lock because they never see the field.

Never copy a Mutex after first use

A sync.Mutex must not be copied once used. Copying it duplicates its internal lock state, and the two copies no longer exclude each other. The trap that catches everyone: a value receiver. func (c SafeCounter) copies the whole struct, mutex included, on every call.

// BUG: value receiver copies the whole struct, including the Mutex.
// Each call locks a different copy of mu, so it guards nothing.
func (c SafeCounter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[key]++
}

You do not have to reason about this. go vet catches it, and it runs as part of go test:

./main.go:15:9: Inc passes lock by value: copybug.SafeCounter contains sync.Mutex

Under -race the same code reports a data race, because each goroutine locks its own copy and they all write the shared map. The fix is the pointer receiver func (c *SafeCounter) from the previous section: methods on *SafeCounter share one mutex. This is also why a constructor returns *SafeCounter, never SafeCounter, and why you pass these types by pointer. If you embed a mutex in a struct, that struct is now pointer-only.

sync.RWMutex when reads dominate writes

sync.RWMutex has two locks in one. RLock/RUnlock is a read lock that any number of goroutines can hold at once. Lock/Unlock is the write lock, fully exclusive: it waits for all readers to leave, then blocks everyone until it releases. Use it when reads massively outnumber writes and a plain Mutex would needlessly serialize readers that only observe.

A feature-flag store is the textbook case: read on nearly every request, written a few times a day.

type FeatureFlags struct {
	mu    sync.RWMutex
	flags map[string]bool
}

// Enabled takes a read lock: many readers hold it at once.
func (f *FeatureFlags) Enabled(name string) bool {
	f.mu.RLock()
	defer f.mu.RUnlock()
	return f.flags[name]
}

// Set takes the write lock: exclusive, blocks all readers.
func (f *FeatureFlags) Set(name string, on bool) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flags[name] = on
}

Driving it with 1000 concurrent readers and one writer flipping the flag mid-run, under -race:

readers that saw new_checkout on: 554 of 1000
final flag value: true

Clean, and the split before-and-after count shows readers ran concurrently with the write. Now the honest part the tutorials skip: RWMutex is not a free upgrade. It carries more bookkeeping than a Mutex, so it only wins under genuinely read-heavy contention, many readers, long-ish read sections, rare writes. For a short critical section like a map lookup under low contention, a plain Mutex is simpler and often faster. Do not reach for RWMutex by default. Reach for it when a profiler shows readers contending, or when reads clearly dwarf writes as they do here.

sync/atomic for single-word counters

If all you are protecting is one integer, a mutex is heavier than you need. sync/atomic provides lock-free operations on single words. atomic.Int64 does the read-modify-write in one uninterruptible CPU instruction, so Add and Load are safe with no lock:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var requests atomic.Int64 // zero value is ready to use
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			requests.Add(1) // atomic read-modify-write, no lock
		}()
	}

	wg.Wait()
	fmt.Println("total requests:", requests.Load())
}

Output:

total requests: 1000

Atomics are cheaper than a mutex for this, and the typed helpers (atomic.Int64, atomic.Bool, atomic.Pointer[T], added in Go 1.19) are hard to misuse. The boundary matters: atomics protect exactly one word. The moment your invariant spans two fields, say a count and a sum that must agree, atomics cannot keep them consistent together, and you need a mutex. Use an atomic for a hit counter or a boolean flag. Use a mutex when several pieces of state must change as a unit.

sync.Once for one-time initialization

sync.Once runs a function exactly once, no matter how many goroutines race to call it. The rest block until the first call finishes, then see the result. It is the clean way to do lazy, concurrency-safe initialization without a “did I already do this” flag guarded by a mutex.

var (
	once   sync.Once
	config *Config
)

// loadConfig runs the initializer exactly once, no matter how many
// goroutines call it concurrently. The rest block until it finishes.
func loadConfig() *Config {
	once.Do(func() {
		fmt.Println("parsing config (runs once)")
		config = &Config{DSN: "postgres://localhost:5432/app"}
	})
	return config
}

Called from five goroutines plus once more in main, under -race:

parsing config (runs once)
config DSN: postgres://localhost:5432/app

The initializer printed once despite six calls. Since Go 1.21 there are also sync.OnceFunc and sync.OnceValue, which wrap this pattern into a returned function. Use Once for a database pool, a compiled regexp, or any expensive singleton you want built on first use rather than at startup.

Mutex or channel: decide by ownership

The Go wiki’s guidance is pragmatic: use whichever is more expressive and simpler, and do not over-use channels just because they are idiomatic. The working rule that falls out of it: a mutex protects shared state that stays in place; a channel transfers ownership of data from one goroutine to another.

Here is the same problem, a shared balance summed by 100 goroutines, solved both ways. Version A guards the balance with a mutex. Version B gives one goroutine sole ownership and sends it deposits:

// Version A: mutex guarding shared state in place.
type MutexBalance struct {
	mu      sync.Mutex
	balance int
}

func (b *MutexBalance) Deposit(amount int) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.balance += amount
}

// Version B: a single owner goroutine, updates arrive as messages.
func channelBalance(deposits <-chan int, done chan<- int) {
	balance := 0
	for amount := range deposits { // only this goroutine touches balance
		balance += amount
	}
	done <- balance
}

Both print 1000 and both are race-clean. But the mutex version is a struct and one method; the channel version needs an owner goroutine, a deposits channel, a done channel, and a close to end the loop. For “many writers bump one shared value,” the mutex is plainly simpler. The channel earns its keep when data genuinely moves, a job handed to a worker, a result flowing to a collector, where the type system models the handoff. If you find yourself building request-and-reply channel pairs to read one value, you have rebuilt a mutex the hard way. The channels guide covers the transfer-ownership side in full.

A concurrent metrics collector, built with RWMutex

Here is the pattern in a real service shape: an in-memory metrics collector that records per-endpoint latency on every request and is scraped occasionally by a /metrics handler. Reads are rarer than writes here, but reads must be consistent snapshots, and a scrape should not block other scrapes, so RWMutex fits. Build it in two moves.

First, the write path. Record takes the write lock, updates the count and total for an endpoint, and releases:

type endpointStat struct {
	count   int64
	totalNs int64
}

type MetricsCollector struct {
	mu    sync.RWMutex
	stats map[string]endpointStat
}

func (m *MetricsCollector) Record(endpoint string, latency time.Duration) {
	m.mu.Lock()
	defer m.mu.Unlock()
	s := m.stats[endpoint]
	s.count++
	s.totalNs += latency.Nanoseconds()
	m.stats[endpoint] = s
}

Two fields, count and totalNs, must move together, which is exactly why this is a mutex and not two atomics. Second, the read path. Snapshot copies the map out under a read lock, then releases before doing the averaging math, so the lock is held for the copy only, not the arithmetic:

func (m *MetricsCollector) Snapshot() map[string]time.Duration {
	m.mu.RLock()
	copied := make(map[string]endpointStat, len(m.stats))
	for k, v := range m.stats {
		copied[k] = v
	}
	m.mu.RUnlock()

	avg := make(map[string]time.Duration, len(copied))
	for k, v := range copied {
		if v.count > 0 {
			avg[k] = time.Duration(v.totalNs / v.count)
		}
	}
	return avg
}

Driving it with 3000 concurrent request writers and 50 concurrent scrape readers, then running under the race detector:

/cart      avg latency 30ms
/checkout  avg latency 30ms
/search    avg latency 30ms

Found 0 data race(s), exit 0. The copy-out-then-compute trick is the important habit: hold the lock for the shortest possible span, get the data out, do slow work unlocked. That keeps the write path from stalling behind a scrape.

Common mistakes that deadlock, panic, or slow you down

Forgetting to Unlock deadlocks the program. A held lock nobody releases stops every goroutine that later wants it. The simplest version locks twice on one goroutine:

	mu.Lock()
	fmt.Println("locked once")
	// bug: forgot to Unlock (or an early return jumped over it)
	mu.Lock() // second Lock on the same goroutine blocks forever

Output:

locked once
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Mutex.Lock]:

Go’s mutex is not reentrant: the same goroutine locking twice blocks on itself. The fix is defer mu.Unlock() right after Lock, so no code path can skip the release.

Unlocking without locking panics. Unlock on a mutex that is not locked is a fatal error, not a no-op:

	var mu sync.Mutex
	mu.Unlock() // Unlock without a matching Lock

Output:

fatal error: sync: unlock of unlocked mutex

This usually means an unlock ran twice, or an unlock landed on a path where the lock was never taken. Pairing Lock with a deferred Unlock in the same function prevents it.

Copying the mutex is the value-receiver bug from earlier. Run go vet; it flags “passes lock by value” for free.

Holding a lock during slow I/O serializes your whole program behind one network call. Do not do this:

func (c *PriceCache) RefreshBad(sku string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	price := fetchFromVendor(sku) // 200ms network call under the lock
	c.prices[sku] = price
}

Every other goroutine wanting the cache waits the full 200ms. Fetch first, lock only to store:

func (c *PriceCache) RefreshGood(sku string) {
	price := fetchFromVendor(sku) // slow call, no lock held
	c.mu.Lock()
	c.prices[sku] = price
	c.mu.Unlock()
}

Using a mutex where an atomic or channel is cleaner. A mutex around a single counter should be an atomic.Int64. A mutex simulating a queue of work should be a channel. The mutex is right when several fields change together, as in the metrics collector.

What next

You can now prove a race, fix it with the right primitive, and choose between mutex, RWMutex, atomic, and channel on purpose. Keep going:

  • Goroutines tutorial: how the scheduler runs the goroutines these locks coordinate.
  • Channels guide: the transfer-ownership side of concurrency, and when it beats a mutex.
  • WaitGroups: coordinating goroutines to completion, used throughout this article.

If the concurrency held but the syntax felt shaky, step back to the complete Go tutorial and work forward; mutexes reward solid fundamentals.