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.
| Method | Behaviour |
|---|---|
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 when | Use channel when |
|---|---|
| Protecting shared state (counter, cache, map) | Passing ownership of data between goroutines |
| Simple read/write guards | Signalling / events |
| Performance-critical hot paths | Pipelines and fan-out/fan-in |
| Multiple goroutines share the same data | One goroutine owns the data |
Gotchas
| Gotcha | Detail |
|---|---|
| Forgetting to unlock | Deadlock — always use defer mu.Unlock() immediately after Lock() |
| Locking twice from the same goroutine | sync.Mutex is not reentrant — calling Lock() twice in the same goroutine deadlocks |
| Copying a mutex | Never copy a sync.Mutex by value — copy the pointer or embed it. go vet will warn you |
Using RLock for writes | A read lock does not prevent other readers — your write will race with them |
| Holding a lock too long | Increases contention; keep critical sections as short as possible |
| Lock on value receiver | func (c Counter) Inc() copies the struct — mutex copy, no protection. Always use pointer receivers |