Skip to content
gohacks.dev
Concurrency

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.

9 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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.

rw_test.goGo
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) })
		}
	}
}
Text
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.854s

The 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 sectionWritesMutexRWMutexFaster
tiny (work=0)0%100137Mutex
tiny1%10048RWMutex
tiny10%10140RWMutex
tiny50%9867RWMutex
short (work=100)0%146137about equal
short1%14884RWMutex
short10%14792RWMutex
short50%147125RWMutex
long (work=1000)0%482130RWMutex, 3.7x
long1%440253RWMutex
long10%437340RWMutex
long50%446578Mutex

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

  1. Default to Mutex. It is simpler, and it is never the worst choice by much.
  2. Switch to RWMutex only 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).
  3. Never take RLock and then call something that takes RLock on the same lock when a writer might be waiting. A pending Lock blocks new readers, so a recursive read lock can deadlock: reader 1 holds RLock, a writer queues for Lock, reader 1 calls a function that wants RLock again and waits behind the writer, who waits for reader 1.
  4. 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.
  5. 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.
  6. Prefer other structures when they fit. A snapshot that is replaced atomically (atomic.Pointer) gives readers no locks at all; sync.Map suits 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:

main.goGo
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)
}
Text
reads: 8000 final config: v2

Readers 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.

Taggedbenchmark
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.