sync.Mutex
When multiple goroutines read and write the same variable at the same time, the result is a data race — a bug where the outcome depends on unpredictable timing between goroutines. Go’s sync.Mutex (short for "mutual exclusion") is the fundamental tool for preventing this: it lets only one goroutine at a time enter a section of code that touches shared data, so the rest of the program can rely on that data staying consistent.
Overview / How it works
A sync.Mutex is a struct with two states: locked and unlocked. It starts unlocked (its zero value is ready to use — there is no constructor to call). A goroutine calls Lock() to acquire it and Unlock() to release it. If a goroutine calls Lock() while another goroutine already holds the lock, it blocks — the Go runtime parks that goroutine (it is not spinning or burning CPU) until the lock becomes available, then wakes it up and hands it the lock.
Internally, sync.Mutex is implemented with a 32-bit state word manipulated via atomic compare-and-swap operations, plus a runtime semaphore used to park and wake blocked goroutines efficiently. The state word packs together whether the mutex is locked, whether a goroutine has been woken but not yet acquired the lock, whether the mutex is in "starvation mode", and a count of waiting goroutines. Under low contention, acquiring an unlocked mutex is a single fast atomic compare-and-swap — essentially free. Under high contention, Go’s mutex normally hands the lock to whichever waiter is next in a semaphore queue (not strictly FIFO), but if a goroutine has been waiting more than 1ms it switches to starvation mode, handing the lock directly to the longest-waiting goroutine in FIFO order so no goroutine is starved indefinitely. You never need to configure this — it happens automatically — but it explains why Go’s mutex is both fast in the common case and fair under load.
A sync.Mutex protects data, not code: the convention is to place the mutex next to the field(s) it guards (often as an unexported field in a struct) and to document which fields require the lock. Anyone calling a method that touches those fields must hold the lock for the entire time they read or write them, and release it as soon as they’re done so other goroutines aren’t kept waiting longer than necessary.
Go also provides sync.RWMutex, a reader/writer variant. It allows any number of concurrent readers (via RLock/RUnlock) as long as no writer holds the lock, but a writer (via Lock/Unlock) gets exclusive access with no readers or other writers active. This is a valuable optimization when reads vastly outnumber writes, since concurrent readers don’t have to queue behind each other the way they would with a plain Mutex.
Syntax
var mu sync.Mutex
mu.Lock()
// critical section: code that touches shared data
mu.Unlock()
ok := mu.TryLock() // non-blocking; returns false if already locked
| Method | Type | Meaning |
|---|---|---|
Lock() |
Mutex / RWMutex |
Blocks until the mutex is free, then acquires exclusive access. |
Unlock() |
Mutex / RWMutex |
Releases exclusive access. Panics if called on an unlocked mutex. |
TryLock() |
Mutex / RWMutex |
Attempts to acquire the lock without blocking; returns true/false. |
RLock() |
RWMutex |
Acquires a shared read lock; multiple readers may hold it at once. |
RUnlock() |
RWMutex |
Releases a read lock acquired with RLock(). |
- The zero value of
sync.Mutex(andsync.RWMutex) is already unlocked and ready to use — just declarevar mu sync.Mutex. - A mutex must never be copied after it has been used; always share it via a pointer or embed it in a struct that itself is passed by pointer.
TryLock(added in Go 1.18) is rarely the right default — prefer blockingLockunless you have a specific reason to avoid waiting.
Examples
Example 1: A safe counter shared by many goroutines
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var wg sync.WaitGroup
counter := &Counter{}
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
counter.Increment()
}
}()
}
wg.Wait()
fmt.Println("Final counter value:", counter.Value())
}
Output:
Final counter value: 5000
Fifty goroutines each increment the counter 100 times, for 5000 total increments. Without the mutex, many of those increments would be lost: c.value++ is really a read, an add, and a write, and if two goroutines interleave those three steps the result is wrong. The Lock()/Unlock() pair around the increment forces every goroutine to perform its read-modify-write as one atomic step relative to the others, so the final value is always exactly 5000.
Example 2: TryLock for a non-blocking attempt
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
mu.Lock()
locked := mu.TryLock()
fmt.Println("TryLock while held:", locked)
mu.Unlock()
locked = mu.TryLock()
fmt.Println("TryLock while free:", locked)
if locked {
mu.Unlock()
}
}
Output:
TryLock while held: false
TryLock while free: true
The first call to TryLock() happens while the mutex is already locked, so it immediately returns false instead of blocking. After Unlock() is called, the mutex is free, so the second TryLock() succeeds and returns true — at which point the caller now owns the lock and is responsible for unlocking it, exactly as with a normal Lock().
Example 3: sync.RWMutex for many readers, few writers
package main
import (
"fmt"
"sync"
)
type Account struct {
mu sync.RWMutex
balance int
}
func (a *Account) Balance() int {
a.mu.RLock()
defer a.mu.RUnlock()
return a.balance
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
}
func main() {
account := &Account{}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
account.Deposit(100)
}()
}
wg.Wait()
fmt.Println("Balance:", account.Balance())
}
Output:
Balance: 1000
Deposit takes the exclusive write lock because it mutates balance, while Balance only needs the shared read lock because it just reads. Ten goroutines each deposit 100, giving a final balance of 1000. If many goroutines called Balance() concurrently while few called Deposit(), an RWMutex lets all those readers proceed in parallel instead of queuing one-by-one behind a plain Mutex, which only ever allows one goroutine in at a time regardless of whether it’s reading or writing.
How it works step by step
- A goroutine calls
mu.Lock(). If the mutex’s internal state shows it as unlocked, an atomic compare-and-swap flips it to locked and the goroutine proceeds immediately — no blocking involved. - If the mutex is already locked, the calling goroutine is added to a wait queue and parked by the runtime’s semaphore mechanism. Parking means the goroutine is descheduled entirely; it consumes no CPU while it waits.
- The goroutine holding the lock runs its critical section — the code between
Lock()andUnlock()— and then callsUnlock(). Unlock()clears the locked bit and, if there are waiters, wakes one of them via the semaphore. That goroutine resumes and now holds the lock.- If a waiter has been queued for over 1ms, the mutex switches into starvation mode: instead of letting newly-arriving goroutines race for the lock, it hands the lock directly to the front of the queue in strict FIFO order until the queue drains, guaranteeing no goroutine waits forever.
Common Mistakes
Mistake 1: Locking without a matching, guaranteed Unlock
func (c *Counter) Increment() {
c.mu.Lock()
if c.value > 1000 {
return // BUG: returns without unlocking -- every future Lock() blocks forever
}
c.value++
c.mu.Unlock()
}
Any early return, or a panic, between Lock() and the plain Unlock() call skips the unlock entirely. Every other goroutine that later calls Lock() on this mutex will block forever — a deadlock. The fix is to call defer c.mu.Unlock() immediately after Lock(), so the unlock always runs when the function returns, no matter which path it takes:
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
if c.value > 1000 {
return
}
c.value++
}
Mistake 2: Copying a struct that contains a Mutex
type Counter struct {
mu sync.Mutex
value int
}
func printValue(c Counter) { // BUG: takes Counter BY VALUE, copying the mutex
c.mu.Lock()
defer c.mu.Unlock()
fmt.Println(c.value)
}
Passing c Counter by value copies the sync.Mutex field along with everything else. The copy starts out in whatever lock state the original happened to be in, and from then on the two mutexes are completely independent — locking the copy does nothing to protect the original’s data, defeating the whole point of the lock. go vet catches this (it reports "passes lock by value") precisely because it’s such an easy mistake to make. Always take a pointer receiver or pointer parameter for any type containing a mutex:
func printValue(c *Counter) {
c.mu.Lock()
defer c.mu.Unlock()
fmt.Println(c.value)
}
Best Practices
- Keep critical sections small: lock, do the minimum work necessary, unlock. Never do I/O, network calls, or other slow operations while holding a mutex.
- Use
defer mu.Unlock()right afterLock()so the unlock is guaranteed even on early returns or panics. - Place the mutex as a field in the struct it protects, and document (or name fields clearly) so it’s obvious which fields require the lock to be held.
- Never copy a value that contains a
sync.Mutexafter it has been used — pass structs containing mutexes by pointer, and rungo vetto catch accidental copies. - Prefer
sync.RWMutexoversync.Mutexonly when reads genuinely outnumber writes by a wide margin; for balanced or write-heavy workloads a plainMutexis simpler and often just as fast. - Don’t call
Lock()again on the same mutex from the same goroutine while already holding it (Go’sMutexis not reentrant) — this deadlocks instead of succeeding. - Run tests with the race detector (
go test -raceorgo run -race) regularly; it will catch missing or misplaced locks that manual review misses.
Practice Exercises
- Write a
SafeMapstruct wrapping amap[string]intwith async.Mutex, exposingSet(key string, value int)andGet(key string) (int, bool)methods. Launch 20 goroutines that each callSetwith a unique key, then print the map’s length afterwg.Wait()— it should always be exactly 20. - Take the
Counterfrom Example 1 and deliberately remove themu.Lock()/mu.Unlock()calls inIncrement. Run the program several times withgo run -raceand observe that the printed value is often less than 5000 and that the race detector reports a data race. - Convert the
SafeMapfrom the first exercise to usesync.RWMutexinstead, withGetusingRLock/RUnlockandSetusingLock/Unlock. Explain in a comment why this change is safe.
Summary
sync.Mutexlets only one goroutine at a time execute the code betweenLock()andUnlock(), preventing data races on shared state.- Its zero value is ready to use; never copy a mutex (or a struct containing one) after first use — pass it by pointer.
defer mu.Unlock()immediately afterLock()guarantees the lock is released even on early returns or panics.sync.RWMutexallows many concurrent readers or one exclusive writer, which helps when reads greatly outnumber writes.TryLock()attempts a non-blocking lock acquisition and reports success via its boolean return value.- Go’s mutex uses fast atomic operations when uncontended and switches to a fair, FIFO starvation mode when a goroutine has waited too long.
- Use
go vetandgo test -raceto catch lock-copying bugs and data races that are easy to miss by reading code alone.
