RWMutex in Go and the Cases Where It Is Slower Than Mutex
Benchmark sync.RWMutex against sync.Mutex across read-write ratios and critical section lengths, and learn when the read lock helps and when it costs more.

The pitch for sync.RWMutex is attractive and simple: if most accesses only read, let the readers run together and make writers wait their turn. Many Go programmers reach for it by reflex whenever they see "read-mostly" in a design. The reflex is wrong often enough to be worth a benchmark. The read lock is not free, it is a different lock with its own costs, and whether it beats a plain mutex depends on three things: how long each critical section is, what fraction of the operations write, and how many cores are competing.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64, 10 cores)
This article measures it. It follows mutexes, and sits beside the sync.Map comparison and concurrent counters. Everything ran on Go 1.25.6 on an Apple M1 Max with ten cores, and the numbers are from that run.
How the two differ
A sync.Mutex has one state: locked or not. sync.RWMutex tracks a count of active readers, a flag for a writer, and a queue of waiting writers. RLock increments the reader count atomically and proceeds if no writer is active or waiting. Lock waits for the readers to drain, blocks new readers while it waits, and then runs alone. Two consequences of that design matter in practice. A waiting writer stops new readers from entering, which prevents writer starvation and also means one slow reader can stall everyone behind a writer. And RLock is an atomic operation on a shared word, so with many cores reading, that word is a cache line bouncing between them, which limits how well readers scale.
The benchmark
The program below runs a parallel benchmark over a single shared value. Each goroutine loops; on each iteration it either reads or writes according to a fixed ratio, holding the lock for a configurable amount of busy work. The work parameter stands for the length of the critical section: 0 is just reading a field, 100 is about a hundred additions, and 1000 is roughly a microsecond of work, the kind of thing you do when you look something up in a map and then format it. The write percentage is 0, 1, 10 or 50.
package main
import (
"fmt"
"sync"
"testing"
)
var sink int
func spin(n int) {
x := 0
for i := 0; i < n; i++ {
x += i
}
sink = x
}
type guarded struct {
mu sync.Mutex
rw sync.RWMutex
data int
}
func run(b *testing.B, useRW bool, writePct, work int) {
var g guarded
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
i++
write := i%100 < writePct
switch {
case useRW && write:
g.rw.Lock()
g.data++
spin(work)
g.rw.Unlock()
case useRW:
g.rw.RLock()
_ = g.data
spin(work)
g.rw.RUnlock()
default:
g.mu.Lock()
if write {
g.data++
} else {
_ = g.data
}
spin(work)
g.mu.Unlock()
}
}
})
}
func BenchmarkLock(b *testing.B) {
for _, work := range []int{0, 100, 1000} {
for _, w := range []int{0, 1, 10, 50} {
b.Run(fmt.Sprintf("work=%d/writes=%d%%/Mutex", work, w), func(b *testing.B) { run(b, false, w, work) })
b.Run(fmt.Sprintf("work=%d/writes=%d%%/RWMutex", work, w), func(b *testing.B) { run(b, true, w, work) })
}
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkLock/work=0/writes=0%/Mutex-10 3350823 104.0 ns/op
BenchmarkLock/work=0/writes=0%/Mutex-10 3592194 100.3 ns/op
BenchmarkLock/work=0/writes=0%/Mutex-10 3536605 100.3 ns/op
BenchmarkLock/work=0/writes=0%/RWMutex-10 2373050 130.2 ns/op
BenchmarkLock/work=0/writes=0%/RWMutex-10 2438989 138.8 ns/op
BenchmarkLock/work=0/writes=0%/RWMutex-10 2829097 136.6 ns/op
BenchmarkLock/work=0/writes=1%/Mutex-10 3530434 101.3 ns/op
BenchmarkLock/work=0/writes=1%/Mutex-10 3672408 100.2 ns/op
BenchmarkLock/work=0/writes=1%/Mutex-10 3965989 99.50 ns/op
BenchmarkLock/work=0/writes=1%/RWMutex-10 7653562 47.95 ns/op
BenchmarkLock/work=0/writes=1%/RWMutex-10 7124332 51.39 ns/op
BenchmarkLock/work=0/writes=1%/RWMutex-10 7523661 48.36 ns/op
BenchmarkLock/work=0/writes=10%/Mutex-10 3610302 102.3 ns/op
BenchmarkLock/work=0/writes=10%/Mutex-10 3489544 101.0 ns/op
BenchmarkLock/work=0/writes=10%/Mutex-10 3487546 101.3 ns/op
BenchmarkLock/work=0/writes=10%/RWMutex-10 8893563 41.79 ns/op
BenchmarkLock/work=0/writes=10%/RWMutex-10 8666223 37.97 ns/op
BenchmarkLock/work=0/writes=10%/RWMutex-10 8893334 40.46 ns/op
BenchmarkLock/work=0/writes=50%/Mutex-10 3619922 97.72 ns/op
BenchmarkLock/work=0/writes=50%/Mutex-10 3770037 101.7 ns/op
BenchmarkLock/work=0/writes=50%/Mutex-10 3619719 96.43 ns/op
BenchmarkLock/work=0/writes=50%/RWMutex-10 5315562 67.09 ns/op
BenchmarkLock/work=0/writes=50%/RWMutex-10 5426528 64.47 ns/op
BenchmarkLock/work=0/writes=50%/RWMutex-10 5241264 68.11 ns/op
BenchmarkLock/work=100/writes=0%/Mutex-10 2454266 144.1 ns/op
BenchmarkLock/work=100/writes=0%/Mutex-10 2456046 146.2 ns/op
BenchmarkLock/work=100/writes=0%/Mutex-10 2453544 147.4 ns/op
BenchmarkLock/work=100/writes=0%/RWMutex-10 2688907 131.9 ns/op
BenchmarkLock/work=100/writes=0%/RWMutex-10 2552288 138.8 ns/op
BenchmarkLock/work=100/writes=0%/RWMutex-10 2498679 137.2 ns/op
BenchmarkLock/work=100/writes=1%/Mutex-10 2459805 147.8 ns/op
BenchmarkLock/work=100/writes=1%/Mutex-10 2478427 146.5 ns/op
BenchmarkLock/work=100/writes=1%/Mutex-10 2544075 147.5 ns/op
BenchmarkLock/work=100/writes=1%/RWMutex-10 4552147 83.63 ns/op
BenchmarkLock/work=100/writes=1%/RWMutex-10 4501441 89.40 ns/op
BenchmarkLock/work=100/writes=1%/RWMutex-10 3908546 84.21 ns/op
BenchmarkLock/work=100/writes=10%/Mutex-10 2455227 147.6 ns/op
BenchmarkLock/work=100/writes=10%/Mutex-10 2461396 147.0 ns/op
BenchmarkLock/work=100/writes=10%/Mutex-10 2456796 145.6 ns/op
BenchmarkLock/work=100/writes=10%/RWMutex-10 3515287 93.80 ns/op
BenchmarkLock/work=100/writes=10%/RWMutex-10 3922866 91.00 ns/op
BenchmarkLock/work=100/writes=10%/RWMutex-10 3963291 92.30 ns/op
BenchmarkLock/work=100/writes=50%/Mutex-10 2437028 146.4 ns/op
BenchmarkLock/work=100/writes=50%/Mutex-10 2380910 148.7 ns/op
BenchmarkLock/work=100/writes=50%/Mutex-10 2441961 146.5 ns/op
BenchmarkLock/work=100/writes=50%/RWMutex-10 2901010 124.3 ns/op
BenchmarkLock/work=100/writes=50%/RWMutex-10 2850410 126.1 ns/op
BenchmarkLock/work=100/writes=50%/RWMutex-10 2934193 125.4 ns/op
BenchmarkLock/work=1000/writes=0%/Mutex-10 826461 482.3 ns/op
BenchmarkLock/work=1000/writes=0%/Mutex-10 662464 540.0 ns/op
BenchmarkLock/work=1000/writes=0%/Mutex-10 843955 433.7 ns/op
BenchmarkLock/work=1000/writes=0%/RWMutex-10 3074677 137.8 ns/op
BenchmarkLock/work=1000/writes=0%/RWMutex-10 2604010 130.0 ns/op
BenchmarkLock/work=1000/writes=0%/RWMutex-10 2974503 129.5 ns/op
BenchmarkLock/work=1000/writes=1%/Mutex-10 817971 439.1 ns/op
BenchmarkLock/work=1000/writes=1%/Mutex-10 784426 443.5 ns/op
BenchmarkLock/work=1000/writes=1%/Mutex-10 812426 440.3 ns/op
BenchmarkLock/work=1000/writes=1%/RWMutex-10 1340028 267.0 ns/op
BenchmarkLock/work=1000/writes=1%/RWMutex-10 1433828 246.7 ns/op
BenchmarkLock/work=1000/writes=1%/RWMutex-10 1444650 253.0 ns/op
BenchmarkLock/work=1000/writes=10%/Mutex-10 833654 447.3 ns/op
BenchmarkLock/work=1000/writes=10%/Mutex-10 827443 437.1 ns/op
BenchmarkLock/work=1000/writes=10%/Mutex-10 837817 436.5 ns/op
BenchmarkLock/work=1000/writes=10%/RWMutex-10 1000000 342.2 ns/op
BenchmarkLock/work=1000/writes=10%/RWMutex-10 1000000 340.1 ns/op
BenchmarkLock/work=1000/writes=10%/RWMutex-10 1000000 333.0 ns/op
BenchmarkLock/work=1000/writes=50%/Mutex-10 834370 445.9 ns/op
BenchmarkLock/work=1000/writes=50%/Mutex-10 820558 444.0 ns/op
BenchmarkLock/work=1000/writes=50%/Mutex-10 813412 459.6 ns/op
BenchmarkLock/work=1000/writes=50%/RWMutex-10 655081 576.2 ns/op
BenchmarkLock/work=1000/writes=50%/RWMutex-10 603486 578.1 ns/op
BenchmarkLock/work=1000/writes=50%/RWMutex-10 632586 578.1 ns/op
PASS
ok example.test/lab 32.854sThe benchmark has its limits, and they are worth stating. It uses one lock for one value, with the same mix on every goroutine, which is a harsh test of the lock itself and says nothing about a lock that guards a large structure with varied access. Busy work stands in for a real critical section. The machine is a laptop-class chip with ten cores: on a 64-core server contention is worse and the gap between the two locks changes. Treat the figures as evidence of shape, and measure your own workload.
Results
These are the medians of three runs, in nanoseconds per operation (lower is better), summarised from the output above.
| Critical section | Writes | Mutex | RWMutex | Faster |
|---|---|---|---|---|
tiny (work=0) | 0% | 100 | 137 | Mutex |
| tiny | 1% | 100 | 48 | RWMutex |
| tiny | 10% | 101 | 40 | RWMutex |
| tiny | 50% | 98 | 67 | RWMutex |
short (work=100) | 0% | 146 | 137 | about equal |
| short | 1% | 148 | 84 | RWMutex |
| short | 10% | 147 | 92 | RWMutex |
| short | 50% | 147 | 125 | RWMutex |
long (work=1000) | 0% | 482 | 130 | RWMutex, 3.7x |
| long | 1% | 440 | 253 | RWMutex |
| long | 10% | 437 | 340 | RWMutex |
| long | 50% | 446 | 578 | Mutex |
What the pattern says
Long read sections with rare writes: RWMutex wins clearly. With about a microsecond of work under the lock and no writes, readers run in parallel under RWMutex and serialise under Mutex, so the gap is large: 130 ns against 482 ns per operation, a factor of 3.7 on ten cores. This is the case the type was designed for: a read-mostly structure where each read does real work, such as a routing table, a configuration snapshot, or a parsed template.
Tiny read sections with no writes: RWMutex loses. When the work under the lock is a field load, the lock operation is the entire cost: 137 ns for RWMutex against 100 ns for Mutex. The extra bookkeeping shows up as slower, and parallelism buys nothing because there is no work to parallelise. If your read is "return a field", a mutex is faster, and an atomic value is faster still (atomics).
Writes cost more with RWMutex. A writer has to wait for active readers to leave and blocks new ones, so a write-heavy mix pays for the machinery and gets little back. At 50% writes with a long critical section, RWMutex was the slower of the two, 578 ns against 446 ns, the only row of the table where a mixed workload went that way.
The middle is murky. At 1% and 10% writes with tiny sections, RWMutex was twice as fast as Mutex (40 to 48 ns against 100 ns), which I did not expect and did not isolate. A plausible reading is that under heavy contention the plain mutex spends its time parking and waking goroutines, which the read lock mostly avoids, but that is a guess from the numbers and not a measurement of the cause. What the numbers do say is that results in this zone are sensitive to the machine and the Go version, which is why a benchmark of your own is the only trustworthy guide.
Rules for using it
- Default to
Mutex. It is simpler, and it is never the worst choice by much. - Switch to
RWMutexonly with evidence: reads far outnumber writes, each read holds the lock for a non-trivial time, and a benchmark or a mutex profile shows contention (mutex and block profiles). - Never take
RLockand then call something that takesRLockon the same lock when a writer might be waiting. A pendingLockblocks new readers, so a recursive read lock can deadlock: reader 1 holdsRLock, a writer queues forLock, reader 1 calls a function that wantsRLockagain and waits behind the writer, who waits for reader 1. - Don't try to upgrade. There is no way to turn a read lock into a write lock. Release the read lock, take the write lock, and recheck the condition, because the world may have changed.
- Mind what readers do. Readers may not mutate shared state, including lazily initialised fields and counters that "only" count reads; those need their own atomics or a write lock.
- Prefer other structures when they fit. A snapshot that is replaced atomically (
atomic.Pointer) gives readers no locks at all;sync.Mapsuits write-once-read-many keys; sharding spreads writes (sharded maps).
A pattern that beats both: copy on write
For read-mostly configuration, the fastest approach has no lock on the read path. Keep the data immutable in a pointer, replace the pointer to update, and let readers load it atomically:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Name string
Workers int
}
var current atomic.Pointer[Config]
func main() {
current.Store(&Config{Name: "v1", Workers: 4})
var wg sync.WaitGroup
var reads atomic.Int64
for range 8 {
wg.Go(func() {
for range 1000 {
cfg := current.Load() // no lock
_ = cfg.Workers
reads.Add(1)
}
})
}
wg.Go(func() {
current.Store(&Config{Name: "v2", Workers: 8}) // publish a whole new value
})
wg.Wait()
fmt.Println("reads:", reads.Load(), "final config:", current.Load().Name)
}reads: 8000 final config: v2Readers never block and never see a half-updated value, because each load returns a complete, immutable Config. Writers build a new value and store it; concurrent writers need their own coordination if updates depend on the old value (use a mutex around the writers or CompareAndSwap). This costs an allocation per update and is ideal when updates are rare. Few things in Go are faster to read than an atomic.Pointer (atomic pointers).
How to benchmark your own case
Reproduce the structure that matters: the real critical section (call the real function), the real read-write mix, and the real number of goroutines (b.SetParallelism scales it). Run with -cpu 1,2,4,8 to see how each lock scales, because a lock that wins at 2 cores can lose at 16. Run each case several times (-count), and compare with benchstat rather than by eye. And check the result against a production profile if you can: a mutex that does not show up in the mutex profile does not need replacing. The details of the type are in the sync.RWMutex documentation, which states the rule about recursive read locking, and benchstat is documented at pkg.go.dev/golang.org/x/perf/cmd/benchstat.


