Go by Example

Mutexes

Use sync.Mutex to safely share state across goroutines when atomic operations are not enough.

A mutex (mutual exclusion lock) ensures only one goroutine can access a critical section at a time. sync.Mutex provides exclusive locking; sync.RWMutex allows concurrent readers but exclusive writers.

Embed the mutex in the struct it protects. Lock before reading or writing shared state, and defer the unlock so it runs even on early return or panic.

package main
 
import (
    "fmt"
    "sync"
)
 
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}
 
func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}
 
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}
 
func main() {
    c := SafeCounter{v: make(map[string]int)}
 
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc("key")
        }()
    }
 
    wg.Wait()
    fmt.Println(c.Value("key")) // always 1000
}

Use sync.RWMutex when reads are far more frequent than writes. Multiple goroutines can hold RLock simultaneously; Lock waits for all readers to release before acquiring:

type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}
 
func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}
 
func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

A deadlock happens when two goroutines each hold a lock and wait for the other's lock, so neither can ever proceed. The classic trigger is acquiring two mutexes in opposite order across goroutines.

package main
 
import (
    "sync"
    "time"
)
 
func main() {
    var mu1, mu2 sync.Mutex
 
    go func() {
        mu1.Lock()
        time.Sleep(1 * time.Millisecond) // let main goroutine run
        mu2.Lock()                        // waits forever: mu2 is held below
        mu2.Unlock()
        mu1.Unlock()
    }()
 
    mu2.Lock()
    mu1.Lock() // waits forever: mu1 is held above
    mu1.Unlock()
    mu2.Unlock()
    // fatal error: all goroutines are asleep - deadlock!
}

Another way to avoid deadlocks entirely is to confine state to a single goroutine and access it through channels - covered in Stateful Goroutines.

The Go race detector (go test -race or go run -race) catches lock violations at runtime. Run it in CI; it has a modest overhead but is essential for correctness.

In production

Embed the mutex in the struct it protects and name it clearly (mu sync.Mutex). Always acquire and release in the same goroutine. Use defer mu.Unlock() immediately after Lock() to prevent leaks on early returns or panics. Profile lock contention with pprof mutex profiling before switching to a sharded or lock-free design.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free