AlgoMaster Logo

sync.Mutex & sync.RWMutex

High Priority19 min readUpdated June 6, 2026

When two goroutines write to the same variable without coordination, the result is a data race: a bug whose output changes between runs and whose root cause is invisible in the source code. sync.Mutex is the standard tool for making a critical section run one goroutine at a time, and sync.RWMutex is its read-heavy cousin. This lesson covers both, the discipline around using them, and the bugs that result when that discipline is absent.

The Data Race Problem

Imagine a tiny stock counter on an e-commerce backend. Two goroutines each decrement the stock as customers check out. The expected outcome is obvious: if stock starts at 100 and 1000 checkouts happen, stock should end at -900 (or, in a real system, the second-to-last checkout should fail; for the bug demo we'll allow it to go negative).

Here's the naive version, no locks:

Run it a few more times and you'll get -891, -847, -912, never the expected -900. The reason is that i.stock-- isn't one CPU instruction. It's at least three: read the current value, subtract one, write it back. Two goroutines can read the same value, both subtract one, and both write back the same result. One decrement is lost.

This is a data race. The Go race detector will tell you exactly where it lives. For now, the fix is to make the read-modify-write happen one goroutine at a time. That's what sync.Mutex does.

Every run prints -900. Lock blocks until the mutex is free, then claims it. Whatever code runs between Lock and Unlock is the critical section, and only one goroutine can be inside it at a time. Everyone else waits in line.

A mutex's zero value is an unlocked mutex ready to use, so var mu sync.Mutex (or embedding it in a struct) needs no further setup.

How Lock and Unlock Work

The diagram shows two goroutines trying to update the same counter. Goroutine A wins the race for the lock, runs the critical section, and unlocks. While A holds the lock, goroutine B parks on the mutex (the red path). When A unlocks, B wakes up, acquires the lock, and runs its own critical section. The mutex serializes everything that runs between Lock and Unlock.

A few properties worth being precise about:

  • Lock blocks the caller until the mutex is available. It doesn't poll or spin in a way you can observe.
  • Unlock is callable from any goroutine, but in practice the same goroutine that called Lock calls Unlock. Unlocking a mutex that isn't locked panics with sync: unlock of unlocked mutex.
  • The mutex is not reentrant. A goroutine that already holds the lock and tries to Lock it again deadlocks (we'll show this below).
  • Code outside the lock is unsynchronized. Two goroutines can be reading and writing different fields concurrently with no protection, which is its own bug.

The defer mu.Unlock() Pattern

A function that locks then runs more than one line should usually defer the unlock. The pattern is Lock first, then defer Unlock immediately, then the actual work.

Why defer? Because AddItem has two return paths: the error case and the success case. Without defer, you'd need to remember to Unlock on both. Worse, if the function ever panics inside the critical section, a manual Unlock at the bottom never runs, and the next goroutine that tries to Lock blocks forever. defer makes the unlock unconditional. It runs on normal return, on early return, and during panic unwinding.

This is the same reasoning as defer wg.Done() and defer file.Close(). Claim the cleanup at the same point you claim the resource.

When defer Hurts: Hot Loops

defer is cheap in Go (the compiler optimizes the common cases) but it isn't free. In a tight loop that locks millions of times per second with a critical section of two or three instructions, the deferred call adds measurable overhead. A manual Unlock at the end of the function can be 20-30% faster in that narrow case.

This only matters when the critical section has no early returns, no possibility of panic, and the function is short enough that you can see every return path at a glance. For anything else, the safety of defer is worth the few nanoseconds. The rule: default to defer mu.Unlock(). Skip it only when you've measured a hot path that demands it.

Critical Section Discipline

A critical section is the code between Lock and Unlock. Three rules govern what should live inside one.

Keep critical sections small. Anything inside the lock is something other goroutines can't do. Long critical sections turn a concurrent program into a serial one. If you can do the computation outside the lock and only the read or write inside, do that.

The "good" version drops the lock during the expensive arithmetic. Other goroutines can read and write other prices while the computation runs. There's a trade-off: between the unlock and the second lock, another goroutine could update values[name] and your write would clobber it. Whether that's acceptable depends on the semantics. For "last writer wins" cases like price recomputation, it's fine. For "increment if equal" cases, you'd want compare-and-swap with sync/atomic.

Never hold a lock across a channel send or any blocking call. A channel send can block. A network call can block. A time.Sleep definitely blocks. If your goroutine is parked on something while holding a lock, every other goroutine that needs that lock is also parked. The system grinds to a halt for as long as the blocking operation takes.

The "good" version finishes the protected work, drops the lock, then sends. Any goroutine blocked on the channel can't also block goroutines waiting on the mutex. Mixing locks and blocking sends is a top source of "the service mysteriously locks up under load" bugs.

Don't acquire a second lock from inside a first without a consistent order. If goroutine A locks mu1 then mu2, and goroutine B locks mu2 then mu1, they can deadlock. The full pattern is covered in the deadlock section below.

sync.RWMutex: Reader-Writer Locks

sync.Mutex treats every caller the same. Even if a hundred goroutines only want to read the value, they each have to wait their turn. sync.RWMutex distinguishes readers from writers: any number of readers can hold the lock at once, but a writer is exclusive.

The API has four methods:

MethodWhat It Does
Lock()Acquire the write lock. Blocks until no readers or writers hold it.
Unlock()Release the write lock.
RLock()Acquire a read lock. Blocks only if a writer holds or is waiting.
RUnlock()Release a read lock.

The zero value is an unlocked RWMutex ready to use.

Here's a product catalog that's read constantly (every page view) and written rarely (only on admin updates).

Multiple Get calls can run their RLock sections simultaneously. Only Set is serialized against everything else. On a workload that's mostly reads, this is a meaningful win.

The reverse holds too: when a writer is waiting, new readers block behind it. Go's RWMutex is "write-preferring" in the sense that a pending writer prevents new readers from acquiring the lock, which avoids writer starvation but means readers can't pile up indefinitely while a writer waits.

When RWMutex Isn't Worth It

RWMutex is more expensive per operation than plain Mutex. The bookkeeping required to track multiple readers adds overhead. If the critical section is short (a single map read, an int load) and contention is low (few goroutines), the extra cost can wipe out the benefit.

WorkloadWhat to Use
Mostly writes, or balanced reads and writesMutex
Mostly reads, long critical sections, many concurrent readersRWMutex
Mostly reads, short critical sections (e.g., reading one int)Mutex or sync/atomic
Single-goroutine access onlyNo lock needed

A rule of thumb: if reads outnumber writes by 10x or more and the read critical section takes long enough that holding the lock matters (microseconds or more, not a single map lookup), prefer RWMutex. Below that ratio, Mutex is simpler and usually faster overall.

The Go standard library itself sometimes prefers Mutex over RWMutex for short critical sections. The choice isn't about how the code looks; it's about how the workload runs.

What's Wrong: Copying a Mutex

sync.Mutex must never be copied after first use. Once a mutex has been locked, it contains state about who owns it; a copy has the same state but a different memory address, and the two halves go out of sync.

Running this prints count: 0 because every call to Inc operates on its own copy of Counter. The original c.n is never touched. But before you even run it, go vet flags both the receiver and the call site:

The fix is a pointer receiver, so the mutex stays in one place and every caller operates on the same struct.

Now count: 1000 every run. The same rule applies to passing structs containing mutexes by value to functions, returning them by value from constructors, or appending them to a slice (which copies). If a struct has a sync.Mutex (or anything embedding one, like sync.RWMutex, sync.Cond, sync.WaitGroup), pass it by pointer.

The noCopy marker built into sync.Mutex is what go vet checks. The compiler still accepts the code (Go has no "non-copyable types" feature), but vet is your line of defense. Wire it into CI; it catches the entire class of bug before it ships.

What's Wrong: Forgetting to Unlock

This one we've already seen, but it's worth a dedicated example because it's the most common mutex bug. A lock acquired and never released wedges every future caller.

The first Charge locks, hits the error path, returns without unlocking. The second Charge tries to Lock and parks forever. Since there are no other goroutines making progress, the runtime detects the deadlock and panics.

The fix is defer w.mu.Unlock() immediately after w.mu.Lock(). Every return path then unlocks automatically.

The runtime's "all goroutines are asleep" detection only fires when every goroutine is parked. In a real server with thousands of goroutines doing other things, this leak is silent: just one queue of goroutines wedged behind a forgotten unlock, while everything else keeps running. That's worse than a panic; you'll see it as a growing latency tail or a thread-pool exhaustion, not as a clear error.

What's Wrong: Re-Entrant Locking

Go mutexes are not reentrant. A goroutine that already holds the lock and tries to Lock it again blocks itself forever. The runtime can't tell that the would-be locker is the same goroutine that's currently holding it; mutexes don't track owners.

Transfer holds a.mu. The call other.Deposit(amount) is fine because other.mu is a different mutex. But a.Deposit(0) tries to lock a.mu again from the same goroutine that already holds it. The second Lock call blocks. Nobody else is going to unlock it, because the goroutine that owns the lock is now parked waiting for it. Deadlock.

Two fixes are common:

Fix 1: separate the public and private versions. The public method locks, the private (lowercase) version assumes the lock is held.

Fix 2: hold the lock only while you need it. Release before calling something that might re-enter.

Both work. The first is more common in larger codebases because it makes the lock contract explicit: methods starting with uppercase lock, lowercase don't, and you can audit the boundary.

The deeper lesson: a method that holds the mutex should not call another method on the same receiver that also tries to acquire that mutex. Either factor out a lock-free helper, or document which methods are "lock-held" vs "lock-free".

What's Wrong: Inconsistent Lock Ordering

The classic deadlock with two locks: goroutine A acquires lock 1 then tries to acquire lock 2, while goroutine B acquires lock 2 then tries to acquire lock 1. Each is holding what the other wants, and neither can proceed.

Goroutine 1 holds a.mu and waits on b.mu. Goroutine 2 holds b.mu and waits on a.mu. Neither will ever unblock. The time.Sleep is only there to make the bug fire every run; without it, the race is real but timing-dependent.

The fix is to impose a consistent lock order across all callers. The standard trick is to pick an ordering key on the objects and always lock the lower-keyed one first.

Now both goroutines lock account 1 first, then account 2, regardless of the transfer direction. One of them wins the first lock and runs to completion; the other waits and then runs. No deadlock.

The general rule: if your code ever holds two locks at once, pick a global ordering and stick to it. Document the order. The moment two pieces of code disagree about the order, you have a latent deadlock that surfaces under contention.

Note one edge case: if from.id == to.id (transferring to yourself), the function tries to lock the same mutex twice, which is a re-entrant deadlock since sync.Mutex is not reentrant. Most production code adds a check like if from == to { return } to handle that.

Comparison: Mutex vs Atomic vs Channels

Locks aren't the only synchronization primitive in Go. Three options come up most often.

ToolBest ForCost
sync.MutexProtecting a block of state (struct fields, maps, slices)Low
sync/atomicSingle int, bool, or pointer updatesVery low
ChannelsCommunicating results or signals between goroutinesHigher per op

A practical guide: use sync/atomic for a single int or bool. Use sync.Mutex (or RWMutex) for a struct, map, or slice. Use channels for passing ownership of work between goroutines, the Go proverb "share memory by communicating" applies here. Mixing them is normal in a single service; the choice is per-data-structure.

A Note on the Race Detector

The bugs in this lesson, the unprotected stock counter, the copied mutex, the inconsistent lock ordering, are exactly what Go's race detector is designed to catch. Run any of the broken examples with go run -race main.go and you get a precise report: which goroutine read, which goroutine wrote, where the race happened, and the call stacks of both sides.

For now: when you suspect a data race, the race detector is the tool. It's slow (2-10x runtime cost), so it's not for production, but it's standard equipment for tests and CI.

Quiz

Mutex Quiz

10 quizzes