skip to content

sync.RWMutex

When reads vastly outnumber writes, RWMutex lets readers run in parallel while writes stay exclusive. The follow-up is when it actually pays off — with short critical sections a plain Mutex usually wins — and why you cannot upgrade an RLock into a Lock.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In Go's sync.RWMutex, what is the difference between RLock and Lock?

level: juniorimportance: must knowfreq 72%

answer

  1. two modes, not one
  2. many at once, or one alone
  3. readers share; a writer needs the room empty
  4. the R-prefixed pair takes the shared mode

basics

~20 s

RLock takes a shared read lock: any number of goroutines can hold it at once, but only while no writer holds the lock. Lock takes an exclusive write lock, so it waits until every reader and every other writer has released.

solid answer

~50 s

`sync.RWMutex` has two acquisition modes. `RLock`/`RUnlock` take the shared mode: many goroutines can read the guarded data at the same time, and they are blocked only while a writer holds the lock or is waiting for it. `Lock`/`Unlock` take the exclusive mode: one writer inside, no readers. You pair the calls exactly — a section entered with `RLock` leaves with `RUnlock`, normally `defer mu.RUnlock()` — and calling `Unlock` on a lock that was taken for reading is a fatal runtime error, not a recoverable panic. The point of the type is throughput on read-mostly state: a routing table consulted on every inbound request and rewritten occasionally is the textbook fit. It is not free, though. `RWMutex` carries more bookkeeping than `sync.Mutex`, so when the guarded work is a single map lookup the plain mutex is usually just as fast.

code

go · 17 lines
go
type Router struct {
	mu     sync.RWMutex
	routes map[string]string
}

func (r *Router) Lookup(path string) (string, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	h, ok := r.routes[path]
	return h, ok
}

func (r *Router) Replace(routes map[string]string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.routes = routes
}

go deeper

for a junior

Be ready to name the four calls and say which mode admits many goroutines and which admits one. Show the pairing habit: RLock with defer RUnlock, Lock with defer Unlock.

for a middle

Explain what the read lock excludes rather than reciting the API. Writers wait for the last reader to leave, and the read lock guarantees nothing at all once RUnlock has returned.

for a senior

Expect to justify the choice with evidence. Say when you would keep a plain Mutex because the guarded section is one lookup, and how you would measure the difference before switching a hot path.

for a principal

Own the guidance the team follows: which shared types are documented as safe for concurrent use, and whether a component should hide its locking behind a narrow API rather than exporting a lock other packages must remember to take.

## Two modes instead of one A `sync.Mutex` admits exactly one goroutine at a time, whether it is going to read the guarded data or change it. A `sync.RWMutex` (a readers-writer lock) splits that into two modes: - **Shared, or read, mode** — `mu.RLock()` … `mu.RUnlock()`. Any number of goroutines may hold the read lock simultaneously. Holding it guarantees that no writer is inside the critical section and that none can get in until you release. - **Exclusive, or write, mode** — `mu.Lock()` … `mu.Unlock()`. Exactly one goroutine is inside, and no readers are. The two modes never overlap. Readers exclude writers, writers exclude everyone. What readers do **not** exclude is each other, and that is the entire reason the type exists: if a piece of state is read on every request and rewritten a few times an hour, serialising the reads behind one mutex wastes the machine. ## The contract you have to honour **Pair the calls exactly.** `RLock` with `RUnlock`, `Lock` with `Unlock`. Crossing them is not a compile error and not a recoverable panic — the runtime kills the process with a fatal error such as `sync: Unlock of unlocked RWMutex`, which `recover` cannot catch. Using `defer mu.RUnlock()` on the line after `mu.RLock()` is the habit that makes this a non-issue, including on early returns and on a panic. **The lock is not owned by a goroutine.** The implementation counts readers; it does not record who they are. A different goroutine may legally call `RUnlock` for a read lock another goroutine took. It is legal and almost always a mistake in design terms, because nothing then keeps acquire and release in the same place. **The zero value is usable.** `var mu sync.RWMutex` is an unlocked lock, so an `RWMutex` embedded as the first field of a struct works with no constructor. Like `sync.Mutex`, it must not be copied once it has been used — pass a pointer to the struct that contains it, never the struct by value. **`RLocker`** returns a `sync.Locker` whose `Lock` and `Unlock` methods call `RLock` and `RUnlock`, for code that wants to hand the shared mode to an API that only accepts a `Locker`. ## What the read lock actually promises It promises mutual exclusion against writers **for as long as you hold it**, and nothing after that. This is the part juniors most often get wrong. If you take `RLock`, grab a pointer, a slice or a map out of the guarded struct, `RUnlock`, and then read through what you grabbed, you are reading shared memory with no lock held. Values that carry no interior reference — an `int`, a `bool`, a `string` — are safe to carry out, because copying them really copies them. References are not. ## Why not use it everywhere An `RWMutex` is internally a mutex plus reader counters and semaphores. An uncontended `sync.Mutex` acquisition is roughly one atomic compare-and-swap; `RLock` is at minimum an atomic add on a counter that **every reader on every core touches**, plus the matching subtract in `RUnlock`. That shared counter is a cache line bouncing between cores even when no writer ever appears. So the shared mode buys you concurrency but charges you for coordination, and the trade only pays when: - reads genuinely dominate writes, and - the read critical section is long enough to amortise the bookkeeping — a scan, a decode, several dependent lookups — rather than one map read, and - there are enough cores actually running readers concurrently to have something to gain. Start with `sync.Mutex`, and change only with a measurement in hand. ## The shape in practice A router keeps a `map[string]string` of path to handler name. `Lookup` takes `RLock`, reads, and returns; thousands of requests a second run through it in parallel. `Replace` takes `Lock` and swaps the map. Because the map is replaced rather than edited, the write section is a single assignment and readers stall for almost no time. ## Mistakes to avoid - Believing `RLock` allows concurrent *writes*. It allows concurrent *reads*, and mutating anything under a read lock is a data race. - Assuming `RWMutex` is a free upgrade over `Mutex`. - Doing I/O, logging or an expensive allocation while holding the write lock, which stalls every reader. - Treating a pointer or slice carried out of the read section as private.

  • If no goroutine ever calls Lock, does RLock still cost anything?
    Yes. `RLock` and `RUnlock` are atomic operations on a reader counter that every reader on every core touches, so the cache line holding it bounces between cores even with no writer in sight. For a very short critical section that coordination can cost about what an uncontended `sync.Mutex` costs, which is why the swap sometimes buys nothing.
  • Can one goroutine call RUnlock for a read lock that a different goroutine took with RLock?
    Yes — the implementation counts readers rather than recording which goroutine holds what, so it is legal. It is still a bad idea: nothing keeps acquisition and release together, and a missed release leaks the reader count and blocks every future writer forever. Keep `RLock` and `defer mu.RUnlock()` in the same function.
  • What happens if you call Unlock on a lock you took with RLock?
    The runtime sees an unlock of a lock that is not write-held and terminates the program with a fatal error such as `sync: Unlock of unlocked RWMutex`. It is not an ordinary panic, so `recover` cannot catch it and no deferred cleanup saves you. The same applies to `RUnlock` without a matching `RLock`.

A reading room with one notice board: any number of people can read the notice at the same time, but the person replacing it needs everyone out of the room first.

saying these in an interview costs you the question

  • Says RLock lets several goroutines write at once
  • Claims RWMutex is always faster than a plain Mutex
  • Releases a read lock with Unlock instead of RUnlock
  • Thinks a reader and a writer can be inside together
  • Assumes guarded data stays protected after RUnlock returns
open as a page

Why can a goroutine that takes sync.RWMutex.RLock twice deadlock in Go?

level: middleimportance: should knowfreq 46%

basics

~20 s

Once a goroutine blocks in Lock, sync.RWMutex admits no new readers, so the waiting writer cannot be starved. A nested RLock then queues behind that writer while the writer waits for the outer read lock to be released, and all three block forever.

open as a page

Your service swapped sync.Mutex for sync.RWMutex on a hot read path and throughput did not improve. How do you find out why?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Measure first: a block profile shows whether goroutines wait on the lock at all and on which side. Usually the read section is too short for the extra bookkeeping to pay, the write rate keeps shutting readers out, or the lock was never the bottleneck.

open as a page

Under a sync.RWMutex read lock you copy a slice field into a local, then RUnlock — is reading it afterwards safe?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Only if writers replace the whole slice instead of writing into it. Assigning a slice copies the header — pointer, length and capacity — not the elements, so after RUnlock you are still reading a backing array the writer can reach.

open as a page