Back to Notes

Go Mutexes

Overview

A mutex (mutual exclusion lock) protects shared data from concurrent access. While channels communicate data between goroutines, a mutex guards data that multiple goroutines read or write directly. From the sync package — no imports beyond "sync" needed.

Rule of thumb: use a channel when passing ownership of data; use a mutex when multiple goroutines need shared access to the same data.


sync.Mutex — Basic Lock / Unlock

Lock() acquires the mutex — any other goroutine calling Lock() blocks until it's released. Unlock() releases it. Always pair them; always defer Unlock() immediately after Lock() so it runs even if the function panics.

import "sync"

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

func main() {
    var wg sync.WaitGroup
    counter := &SafeCounter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Inc()
        }()
    }

    wg.Wait()
    fmt.Println(counter.Value()) // 1000 — always
}

Always embed the mutex in the struct it protects — keeps the lock and data co-located and makes the ownership obvious.


sync.RWMutex — Read / Write Lock

RWMutex allows multiple concurrent readers but only one writer at a time. Use it when reads are frequent and writes are rare — it's more efficient than a plain Mutex in that case.

MethodBehaviour
Lock()Exclusive write lock — blocks all readers and writers
Unlock()Releases write lock
RLock()Shared read lock — multiple goroutines can hold it simultaneously
RUnlock()Releases read lock
type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()           // exclusive — no reads or writes during set
    defer c.mu.Unlock()
    c.items[key] = value
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()          // shared — multiple goroutines can read at once
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

sync.Once — Run Exactly Once

sync.Once guarantees a function is executed only once, regardless of how many goroutines call it. Useful for lazy initialisation of shared resources (DB connections, configs).

var (
    instance *Config
    once     sync.Once
)

func GetConfig() *Config {
    once.Do(func() {
        instance = &Config{} // runs only the first time
        instance.load()
    })
    return instance
}

Mutex vs Channel — When to Use Which

Use mutex whenUse channel when
Protecting shared state (counter, cache, map)Passing ownership of data between goroutines
Simple read/write guardsSignalling / events
Performance-critical hot pathsPipelines and fan-out/fan-in
Multiple goroutines share the same dataOne goroutine owns the data

Gotchas

GotchaDetail
Forgetting to unlockDeadlock — always use defer mu.Unlock() immediately after Lock()
Locking twice from the same goroutinesync.Mutex is not reentrant — calling Lock() twice in the same goroutine deadlocks
Copying a mutexNever copy a sync.Mutex by value — copy the pointer or embed it. go vet will warn you
Using RLock for writesA read lock does not prevent other readers — your write will race with them
Holding a lock too longIncreases contention; keep critical sections as short as possible
Lock on value receiverfunc (c Counter) Inc() copies the struct — mutex copy, no protection. Always use pointer receivers