Dev Skills

Mutex vs RWMutex in Go: when RLock actually helps

RWMutex lets readers share the lock. That only pays off when reads dominate and take long enough to overlap.

Topic 91 of 101 · 15 min to read · 20 min to build

By devskills editorial team · Last updated

A sync.Mutex lets one goroutine in at a time. A sync.RWMutex has two modes: any number of goroutines can hold it for reading (RLock) at the same time, or exactly one can hold it for writing (Lock), with nobody reading. It is the natural fit for data that is read constantly and changed rarely: a config, a routing table, a cache.

`sync.Mutex``sync.RWMutex`
Who can hold itOne goroutineMany readers, or one writer
MethodsLock, Unlock, TryLockThose, plus RLock, RUnlock, TryRLock, RLocker
Cost per lockOne atomic operation to lock, one to unlockRLock is one atomic add; Lock takes an internal mutex and then waits for readers to drain
Wins whenCritical sections are tiny, or writes are commonReads dominate and each read takes long enough for readers to overlap
Waiting writerNot applicableBlocks new readers, so writers do not starve
ReentrantNoNo, and a second RLock in the same goroutine can deadlock

What do RLock and Lock do?

RLock succeeds immediately unless a writer holds the lock or is waiting for it. Lock waits until every current reader has called RUnlock, then holds the lock alone. That second rule, a waiting writer blocking new readers, is what stops a steady stream of readers from starving writers forever. It is also the cause of the deadlock below.

Use them in pairs, each with defer: RLock/RUnlock in read paths, Lock/Unlock in write paths. Calling Unlock after RLock, or the reverse, is a fatal error.

config.go
package main
import (
"fmt"
"sync"
)
type Config struct {
mu sync.RWMutex
flags map[string]bool
}
func (c *Config) Enabled(name string) bool {
c.mu.RLock() // many readers at once
defer c.mu.RUnlock()
return c.flags[name]
}
func (c *Config) Set(name string, on bool) {
c.mu.Lock() // waits for readers to leave, then holds it alone
defer c.mu.Unlock()
c.flags[name] = on
}
func main() {
cfg := &Config{flags: map[string]bool{"new-checkout": false}}
var wg sync.WaitGroup
var mu sync.Mutex
on := 0
for i := range 100 {
if i == 50 {
cfg.Set("new-checkout", true)
}
wg.Go(func() {
if cfg.Enabled("new-checkout") {
mu.Lock()
on++
mu.Unlock()
}
})
}
wg.Wait()
fmt.Println("readers that saw the flag on:", on >= 50)
}

Is RWMutex faster than Mutex?

For reads that hold the lock a while and rarely meet a writer, yes, by a lot. For tiny reads the gap shrinks, and when writes are frequent a plain Mutex is faster. The reason is in the mechanics: every RLock and RUnlock updates one shared reader count, and on many cores that word bounces between CPU caches. When the read is a single map lookup, that traffic is most of the cost, so readers overlapping saves little. A Lock on an RWMutex also does more work than a Mutex Lock, because it has to wait for every reader to leave.

We ran the benchmark below with Go 1.26 on an 8-thread laptop. Read-heavy, with 100 map reads under the lock: RWMutex took about 320 ns per operation at -cpu 4 against about 1,250 ns for Mutex, roughly 4 times faster. Tiny reads (one lookup): RWMutex was about 25% faster at -cpu 8 (87 ns against 116 ns). Write-heavy, one write per two operations: Mutex was about 15% faster (1,180 ns against 1,390 ns). At -cpu 1 the read-heavy pair was level, because nothing can overlap.

Treat those as one machine's numbers. Core count, the read/write ratio and how long each read holds the lock all move the result, so benchmark your own access pattern. The benchmark runs on all cores with b.RunParallel, and the ratio and the work inside the lock are parameters.

cache_test.go — run with go test -bench . -cpu 1,4,8
package cache
import (
"sync"
"testing"
)
type locker interface {
Lock()
Unlock()
RLock()
RUnlock()
}
// mutexRW makes a plain Mutex fit the same interface: RLock is just Lock.
type mutexRW struct{ sync.Mutex }
func (m *mutexRW) RLock() { m.Lock() }
func (m *mutexRW) RUnlock() { m.Unlock() }
var data = func() map[int]int {
m := make(map[int]int, 1000)
for i := range 1000 {
m[i] = i
}
return m
}()
// bench does writeEvery-1 reads for every write; each read sums 'work' keys.
func bench(b *testing.B, l locker, writeEvery, work int) {
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
i++
if i%writeEvery == 0 {
l.Lock()
data[i%1000] = i
l.Unlock()
continue
}
l.RLock()
sum := 0
for k := range work {
sum += data[k]
}
l.RUnlock()
_ = sum
}
})
}
func BenchmarkMutexReadHeavy(b *testing.B) { bench(b, &mutexRW{}, 1000, 100) }
func BenchmarkRWMutexReadHeavy(b *testing.B) { bench(b, &sync.RWMutex{}, 1000, 100) }
func BenchmarkMutexTinyRead(b *testing.B) { bench(b, &mutexRW{}, 1000, 1) }
func BenchmarkRWMutexTinyRead(b *testing.B) { bench(b, &sync.RWMutex{}, 1000, 1) }
func BenchmarkMutexWriteHeavy(b *testing.B) { bench(b, &mutexRW{}, 2, 100) }
func BenchmarkRWMutexWriteHeavy(b *testing.B) { bench(b, &sync.RWMutex{}, 2, 100) }
Change `writeEvery` and `work` to match your real access pattern before drawing a conclusion. If your numbers differ from ours, believe yours.

Why can a second RLock deadlock?

Because a waiting writer blocks new readers. Suppose goroutine A holds a read lock and, deeper in the same call, calls a helper that takes RLock again. If a writer B called Lock in between, B is waiting for A to release, and A's second RLock is waiting for B to finish. Neither can move. The sync documentation spells this out: recursive read locking is prohibited.

It passes every test that has no concurrent writer, and then hangs in production. The fix is structural: lock in the exported method only, and have it call unexported helpers that assume the lock is already held (a common convention is to name them with a Locked suffix).

The program below shows the rule without hanging: once a writer is waiting, TryRLock reports that a second read lock cannot be taken.

rlock-twice.go
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.RWMutex
mu.RLock() // A holds a read lock
fmt.Println("first RLock: ok")
go func() { // B wants to write; it must wait for A
mu.Lock()
fmt.Println("writer: got the lock after the reader left")
mu.Unlock()
}()
time.Sleep(20 * time.Millisecond) // let B start waiting
// A plain mu.RLock() here would block forever: B waits for A, A waits for B.
fmt.Println("second RLock while a writer waits:", mu.TryRLock())
mu.RUnlock() // A releases; B can now run
time.Sleep(20 * time.Millisecond)
}
Prints false for the second read lock. Remove the writer goroutine and it prints true, which is why this bug hides in tests.

Can you upgrade a read lock to a write lock?

No. There is no upgrade: calling Lock while holding RLock deadlocks, since Lock waits for all readers, including you. The pattern is to release the read lock, take the write lock, and check again, because another goroutine may have done the work in the gap.

get-or-load.go
func (c *Cache) Get(key string) string {
c.mu.RLock()
v, ok := c.m[key]
c.mu.RUnlock()
if ok {
return v
}
c.mu.Lock()
defer c.mu.Unlock()
if v, ok := c.m[key]; ok { // someone filled it while we were unlocked
return v
}
v = load(key) // slow; while it runs, every reader is blocked
c.m[key] = v
return v
}
Loading under the write lock blocks all readers for the duration of `load`. For slow loads, use per-key loading (the singleflight package in golang.org/x/sync) instead of one big lock.

When does sync/atomic beat both?

When readers only need a consistent snapshot of a value that is replaced as a whole. Store the current config in an atomic.Pointer[Config]. Readers call Load and get a pointer to an immutable snapshot, with no lock at all. A writer builds a new config and calls Store. This is copy-on-write: reads are as cheap as a pointer load, and writes copy the whole value, so it suits data that changes rarely.

For a single counter, atomic.Int64 beats any lock. As soon as two fields must change together, go back to a mutex.

snapshot.go
package main
import (
"fmt"
"maps"
"sync"
"sync/atomic"
)
type Flags map[string]bool // never modified after it is published
var current atomic.Pointer[Flags]
func enabled(name string) bool {
return (*current.Load())[name] // no lock: readers see one whole snapshot
}
var writeMu sync.Mutex // writers still take turns with each other
func set(name string, on bool) {
writeMu.Lock()
defer writeMu.Unlock()
next := maps.Clone(*current.Load()) // copy, change the copy, publish it
next[name] = on
current.Store(&next)
}
func main() {
current.Store(&Flags{"dark-mode": false})
fmt.Println("before:", enabled("dark-mode"))
set("dark-mode", true)
fmt.Println("after:", enabled("dark-mode"))
}

Mutex, RWMutex or atomic: which should you use?

SituationUse
Default for shared state; writes are common or sections are tinysync.Mutex
Reads far outnumber writes, and each read does real work under the locksync.RWMutex, after a benchmark agrees
One counter or flagatomic.Int64 / atomic.Bool
A whole value read constantly, replaced rarelyatomic.Pointer[T] with copy-on-write
Data or work moving between goroutinesA channel: see Mutex vs channel
Worth knowing: Start with sync.Mutex. Switch to RWMutex when a mutex profile (go test -mutexprofile, or runtime.SetMutexProfileFraction in a service) shows contention on that lock and reads dominate.
Check you can now
  • Explain what RLock, Lock and a waiting writer do to each other.
  • Benchmark a read-heavy cache with both locks and read the result honestly.
  • Avoid the recursive RLock deadlock and the lock-upgrade mistake.
The short version
  • RWMutex: many readers or one writer. Mutex: one goroutine.
  • A waiting writer blocks new readers, so writers do not starve, and a recursive RLock can deadlock.
  • RWMutex wins big when reads dominate and hold the lock long enough to overlap; with frequent writes a Mutex is faster. Benchmark it.
  • There is no lock upgrade: unlock, lock, and check again.
  • For one value, atomics; for a rarely replaced snapshot, atomic.Pointer with copy-on-write.
Remember: Default to Mutex. Earn RWMutex with a benchmark. Never RLock twice in one goroutine.
Then try these in your own editor
  1. 1Run the benchmark with -cpu 1,8. Why does the read-heavy advantage disappear at -cpu 1?
  2. 2func (c *Cache) Size() int { c.mu.RLock(); defer c.mu.RUnlock(); return len(c.Keys()) }, where Keys also takes RLock. What is wrong?
  3. 3A rate-limit table is read on every request and reloaded from a file every 5 minutes. Which tool fits best?