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.
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 it | One goroutine | Many readers, or one writer |
| Methods | Lock, Unlock, TryLock | Those, plus RLock, RUnlock, TryRLock, RLocker |
| Cost per lock | One atomic operation to lock, one to unlock | RLock is one atomic add; Lock takes an internal mutex and then waits for readers to drain |
| Wins when | Critical sections are tiny, or writes are common | Reads dominate and each read takes long enough for readers to overlap |
| Waiting writer | Not applicable | Blocks new readers, so writers do not starve |
| Reentrant | No | No, 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.
package mainimport ( "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.
package cacheimport ( "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) }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.
package mainimport ( "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)}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.
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}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.
package mainimport ( "fmt" "maps" "sync" "sync/atomic")type Flags map[string]bool // never modified after it is publishedvar 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 otherfunc 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?
| Situation | Use |
|---|---|
| Default for shared state; writes are common or sections are tiny | sync.Mutex |
| Reads far outnumber writes, and each read does real work under the lock | sync.RWMutex, after a benchmark agrees |
| One counter or flag | atomic.Int64 / atomic.Bool |
| A whole value read constantly, replaced rarely | atomic.Pointer[T] with copy-on-write |
| Data or work moving between goroutines | A channel: see Mutex vs channel |
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.- Explain what
RLock,Lockand a waiting writer do to each other. - Benchmark a read-heavy cache with both locks and read the result honestly.
- Avoid the recursive
RLockdeadlock and the lock-upgrade mistake.
- RWMutex: many readers or one writer. Mutex: one goroutine.
- A waiting writer blocks new readers, so writers do not starve, and a recursive
RLockcan 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.Pointerwith copy-on-write.
- 1Run the benchmark with
-cpu 1,8. Why does the read-heavy advantage disappear at-cpu 1? - 2
func (c *Cache) Size() int { c.mu.RLock(); defer c.mu.RUnlock(); return len(c.Keys()) }, whereKeysalso takesRLock. What is wrong? - 3A rate-limit table is read on every request and reloaded from a file every 5 minutes. Which tool fits best?