How to Use sync.Mutex and sync.RWMutex in Go

Use sync.Mutex for exclusive access to shared data and sync.RWMutex to allow multiple readers but only one writer at a time.

The shared whiteboard problem

You built a hit counter for your API. It works perfectly when you test it locally. You deploy it, traffic spikes, and suddenly the count is wrong. Or worse, the program crashes with a runtime panic about concurrent map writes. You didn't write concurrent code explicitly, but the HTTP server spawns a goroutine per request. Those goroutines are stomping on each other's data.

Shared state is the enemy of concurrency. When multiple goroutines access the same variable, the scheduler can interleave their operations in unpredictable ways. One goroutine reads a value, another updates it, and the first goroutine writes back the old value, overwriting the update. This is a race condition. The result depends on timing, not logic.

Go provides sync.Mutex and sync.RWMutex to coordinate access. A mutex is a mutual exclusion lock. It ensures only one goroutine accesses a resource at a time. An RWMutex allows multiple readers or one writer, but not both simultaneously. These tools turn chaotic concurrent access into orderly, predictable behavior.

Concept: locks and critical sections

Think of shared data like a whiteboard in a busy office. If one person is writing on the board, everyone else must wait. If everyone is just reading, they can all look at the board at once. The rule is simple: writers need exclusive access. Readers can share access, but only when no one is writing.

A critical section is the block of code that accesses shared data. You must hold the lock for the entire critical section. If you unlock too early, another goroutine can interfere. If you hold the lock too long, other goroutines block and performance suffers.

sync.Mutex is the basic lock. It has two states: locked and unlocked. When a goroutine calls Lock, it checks the state. If unlocked, it takes the lock and proceeds. If locked, it parks and waits. Unlock releases the lock and wakes a waiting goroutine.

sync.RWMutex adds a read lock. RLock allows multiple goroutines to hold the read lock simultaneously. Lock blocks until all readers release their locks, then grants exclusive write access. This is useful when reads far outnumber writes.

Locks are coordination, not computation. Keep the critical section tiny.

Minimal example: a safe counter

Here's the simplest goroutine: spawn one, send a message, close the channel.

Here's a counter protected by a mutex. The struct holds the data and the lock together. This keeps the invariant clear: you can't access the data without the lock.

package main

import (
	"fmt"
	"sync"
)

// Counter tracks a value protected by a mutex.
type Counter struct {
	mu    sync.Mutex
	value int
}

// Add increments the value safely.
func (c *Counter) Add(delta int) {
	c.mu.Lock()
	defer c.mu.Unlock() // defer guarantees unlock runs on return or panic
	c.value += delta
}

// Get returns the current value safely.
func (c *Counter) Get() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func main() {
	c := &Counter{}
	var wg sync.WaitGroup

	// Spawn 1000 goroutines to increment the counter.
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Add(1)
		}()
	}

	wg.Wait()
	fmt.Println(c.Get()) // prints: 1000
}

The defer c.mu.Unlock() call is essential. If the function returns early or panics, defer ensures the lock is released. Without defer, a missing unlock on a code path causes a deadlock. The program hangs forever because no goroutine can acquire the lock again.

Convention aside: receiver names should be short and match the type. c for Counter is standard. this or self are not Go style. The mutex field is usually named mu.

Walkthrough: what happens at runtime

When Add is called, the goroutine executes c.mu.Lock(). The mutex checks its internal state. If the state is zero, it sets the state to one and returns immediately. The goroutine proceeds to update c.value.

If another goroutine calls Add while the first holds the lock, the second goroutine sees the state is one. It parks and waits. The scheduler moves it to a wait queue. When the first goroutine calls Unlock, the state resets to zero and one waiting goroutine wakes up.

The defer statement schedules Unlock to run when Add returns. This happens after c.value += delta completes. The critical section is the single line that updates the value. The lock protects that line from interleaving.

If you remove the lock, the compiler won't complain. Go doesn't enforce thread safety at compile time. The race detector catches these bugs at runtime. Run your code with go run -race to enable it. The detector instruments memory accesses and reports data races.

Locks are cheap to acquire when uncontended. They become expensive when goroutines block and wake up. Profile your code to measure lock contention.

Realistic example: a read-heavy cache

Many services are read-heavy. A cache serves thousands of reads per second but updates rarely. sync.RWMutex shines here. Multiple goroutines can read the cache simultaneously without blocking each other. Writers block readers and other writers, but writes are infrequent.

Here's a cache implementation using RWMutex. The struct groups the lock and the map. The Get method uses RLock for concurrent reads. The Set method uses Lock for exclusive writes.

package main

import (
	"fmt"
	"sync"
)

// Cache stores key-value pairs protected by an RWMutex.
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

// Get retrieves a value. Multiple goroutines can call this simultaneously.
func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock() // RUnlock releases the read lock
	val, ok := c.data[key]
	return val, ok
}

// Set updates a value. Only one goroutine can hold the write lock.
func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock() // Lock blocks all readers and other writers
	c.data[key] = value
}

func main() {
	c := &Cache{data: make(map[string]string)}
	c.Set("key", "value")
	val, ok := c.Get("key")
	fmt.Println(val, ok) // prints: value true
}

Map access in Go is not atomic. Reading or writing a map from multiple goroutines without a lock causes a runtime panic. The error fatal error: concurrent map writes appears when two goroutines modify the map simultaneously. The lock prevents this by serializing writes and protecting reads.

RWMutex has a trade-off. If readers hold the lock for a long time, writers starve. A writer waits until all readers release their locks. If new readers keep arriving, the writer waits forever. This is writer starvation. Use RWMutex only when reads are fast and frequent, and writes are rare.

RWMutex helps reads, but watch for writer starvation.

Pitfalls and errors

Mutexes are simple but easy to misuse. Here are common mistakes and how to avoid them.

Copying a mutex breaks it. A mutex contains internal state. If you copy a struct with a mutex, the copy has a duplicate state. The original and copy no longer coordinate. The compiler rejects this with cannot copy mutex if you try to pass a mutex by value in a function parameter. If you copy a struct containing a mutex, the compiler doesn't catch it. The bug manifests as a race condition or deadlock. Never copy a struct with a mutex. Pass pointers instead.

Unlocking without locking panics. If you call Unlock on a mutex that isn't locked, the runtime panics with sync: unlock of unlocked mutex. This happens if you unlock twice or unlock on a code path that didn't lock. Always pair Lock with defer Unlock. This prevents double unlocks and ensures symmetry.

Deadlocks from lock ordering. If you hold multiple locks, acquire them in a consistent order. If goroutine A locks X then Y, and goroutine B locks Y then X, they can deadlock. Each waits for the other to release a lock. Define a global order for locks and follow it.

Holding locks during I/O. Never hold a lock while making network calls, disk reads, or any blocking operation. Other goroutines block waiting for the lock. The program throughput drops to zero. Keep the critical section to memory operations only. Do the I/O outside the lock.

Race detector is mandatory. The compiler won't catch race conditions. Run go test -race in CI. The detector adds overhead, so don't run it in production. Use it to verify correctness during development.

The race detector is your best friend. Run it in CI.

Decision: when to use what

Concurrency tools serve different purposes. Pick the right one for your workload.

Use sync.Mutex when writes are frequent or you need simplicity. It provides exclusive access for all operations. The overhead is low when uncontended. It's the default choice for shared state.

Use sync.RWMutex when reads vastly outnumber writes and read latency matters. It allows concurrent reads, reducing contention. Verify that writes are rare and reads are fast to avoid writer starvation.

Use channels when you need to coordinate data flow between goroutines. Mutexes protect shared state. Channels send values and synchronize execution. Choose channels when goroutines communicate by passing messages rather than sharing memory.

Use sync/atomic operations for simple counters or flags where a lock is overkill. Atomic operations are faster than mutexes but limited to specific types and operations. Use them for high-performance counters or boolean flags.

Use immutable data when you can avoid mutation entirely. If data never changes, you don't need locks. Pass values by value or use read-only structures. Immutability eliminates race conditions by design.

Locks are heavy. Keep the critical section small.

Where to go next