1. Why RWMutex is needed: a story about readers and writers
Imagine a small library. It has one rare reference book — that is our shared data. People come to read it far more often than to rewrite it or glue in new pages. If the librarian lets only one person in at a time (like a Mutex), reading becomes slow: readers will stand in line even when they do not interfere with each other.
sync.RWMutex is designed for exactly these situations: it allows multiple readers in at once, but lets in only one writer, and only when there are no readers inside. On paper it sounds like a “read accelerator”, but in practice there is a price: the code gets more complex, and the bugs get more “interesting” (in the bad sense).
API: read-lock and write-lock
The most important idea of RWMutex is that it has two types of locks: for reading and for writing. This is not “for decoration” — it is a real contract that must be followed in code. If you break it, you will either hit a race, or get a hang, or both — not exactly a great combo.
Here is a minimal “method cheat sheet” to avoid confusion:
| What we do | Which lock | Methods | Who can run in parallel |
|---|---|---|---|
| Read data only | Read lock | |
Multiple readers at the same time |
| Modify data | Write lock | |
Only one writer; readers wait |
One more important detail: the zero value of RWMutex is “ready to use”. That is, you can simply declare a field var mu sync.RWMutex and use it, without new/make.
But copying a struct that contains an RWMutex after you have started using it is still not allowed: you will end up with two copies of the lock, each “thinking” it protects the same thing, while in reality they are independent.
2. Mini app: TaskStore with RWMutex
To avoid discussing locks in a vacuum, let’s continue our “tasks” application line. Suppose we have an in-memory task store that is read often (show a list, get by id) and written less often (create a task, mark it as done). This is a typical “many reads / few writes” situation — a candidate for RWMutex.
Let’s start with the model and the store structure:
package main
import "sync"
type Task struct {
ID int
Done bool
}
type TaskStore struct {
mu sync.RWMutex
tasks map[int]Task
}
Note: tasks is a map. And a map in Go is not thread-safe. Even “just taking a quick look” can turn into a panic or a race if someone writes in parallel. So if a map is shared by multiple goroutines, it almost always lives under a lock.
Let’s make a constructor that guarantees the map is created:
package main
func NewTaskStore() *TaskStore {
return &TaskStore{tasks: make(map[int]Task)}
}
Next — read by id. This is a pure read, so we use RLock:
package main
func (s *TaskStore) Get(id int) (Task, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
return t, ok
}
Now writing: for example, create or store a task (simplified: we accept a ready-made task). Writes must happen under Lock():
package main
func (s *TaskStore) Put(t Task) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[t.ID] = t
}
And one more write: mark a task as done. There is an important nuance here: we read and then write, but for a map this is still “a write in the end”, so we take a write lock:
package main
func (s *TaskStore) MarkDone(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return false
}
t.Done = true
s.tasks[id] = t
return true
}
3. What counts as reading: aliasing and “live” collections
At first it looks like a simple rule: “read under RLock, write under Lock”. But in real life a subtle point appears: you can read a reference to internal data, and then modify it after leaving RLock. Formally your method “read”, but in fact it exposed access to internals without protection. This is the classic aliasing bug.
With a map this is less common because map is a reference type, and you can return the map itself. Then external code can start writing to it without the lock. With slices it is even more fun: you return []Task, and external code does append or changes an element, and that’s it — the “protection” is gone.
Therefore, a good design rule for a concurrency-safe type is: do not expose “live” internal collections if you do not want them to be controlled from the outside. Most of the time you return a “snapshot” — a copy.
Let’s show a correct List(): inside, under RLock, we copy tasks, and then we work with the copy without any lock.
package main
func (s *TaskStore) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
From a concurrency perspective, this is great: we hold RLock only while we read the map and fill the slice. As soon as the copy is ready, we can release the lock.
4. When RWMutex is useful
The most honest answer to the question “when should I use RWMutex?” is: when you already understand your load profile. If you truly have many concurrent reads and they are short, RWMutex lets those reads avoid bumping into each other. That is where you win thanks to reader parallelism.
A very typical example is a configuration store or a “last known state” cache that every goroutine reads, but that updates rarely. There RWMutex can be a good fit: readers proceed in parallel, updates are quick and block everyone for a short time.
It is important to emphasize one thought: RWMutex is not an “upgraded Mutex”. It is a separate tool that starts paying off when two conditions are met: there are many reads, and each read takes little time.
If reads are long (for example, under RLock you sort a large array, format JSON, or do I/O), you turn the “accelerator” into a “queueing slowdown”, because the writer will wait too long.
5. When RWMutex is harmful
It is very tempting to say: “Since RWMutex gives parallel reads, let’s always use it.” But that is like buying a sports car to haul potatoes on a dirt road. Theoretically you can. Practically, you will get more trouble than benefit.
The downside of RWMutex usually shows up in three ways.
First, it complicates the code and increases the chance of mistakes. With Mutex you have one pair of methods; here you have two, and it is easy to mix them up. Also “half-writes” appear: it seems like you are only reading, but in reality you change a struct field, modify a slice element, or append to a map — and that already violates the contract.
Second, RWMutex has overhead. If there is little concurrency or reads barely overlap in time, you may not speed up — you may slow down due to the more complex mechanism.
Third, there are “starvation” and waiting effects. In RWMutex, readers can hold RLock for a long time and thus delay a writer. And once a writer is waiting, new readers often start slowing down (this depends on the implementation and conditions), and you can get an unpleasant latency profile: “fast, then suddenly everything stalls”. Even if there is no formal deadlock, the user experiences it as “the service is hung”.
6. The forbidden trick: upgrading RLock → Lock
A common real-world situation: you want to do “check under RLock, and if the data is missing — create it.” And then your hand reaches for something like “call Lock while still inside RLock”. You must not do that: RWMutex is not re-entrant, and attempting to take a write lock while you hold a read lock (even in the same goroutine) very easily leads to a deadlock. You are waiting for readers to leave, and one of the readers is you.
The correct scheme usually looks like a “double-check”: first read under RLock, then release it, then take Lock and check again, because someone could have written in the meantime.
A mini example: “create a task if it does not exist” (simplified):
package main
func (s *TaskStore) Ensure(id int) {
s.mu.RLock()
_, ok := s.tasks[id]
s.mu.RUnlock()
if ok {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
s.tasks[id] = Task{ID: id}
}
}
Yes, this looks a bit longer than “I want to upgrade locks”. But it behaves predictably and does not require any shamanism.
7. Practice: snapshot under RLock and work outside the lock
When you use RWMutex, it helps to keep a simple thought in mind: a lock protects not a line of code, but a data invariant. If your struct has multiple related fields that must stay consistent, then a write must change them together under one Lock, and a read must observe a consistent snapshot under RLock.
The pattern “under the lock — only data; outside the lock — heavy work” is very helpful. For example, if you want to sort a list of tasks by id, you do not need to hold RLock during the sort. Under RLock you make a copy (a snapshot), release the lock, and sort the copy. You reduce lock hold time and lower writer latency.
Schematically, it can be represented like this:
flowchart TD
A[Need to return a task list] --> B[Take RLock]
B --> C[Copy data into a new slice]
C --> D[Release RLock]
D --> E[Sort/filter the copy without a lock]
E --> F[Return the result]
And yes: if you constantly have to explain to yourself why this uses RLock and that uses Lock, and you feel that “this is getting too complicated”, it is very likely that you just need a regular Mutex for now. Starting with Mutex is a completely normal strategy because it reduces the surface area for mistakes.
8. Common mistakes when working with sync.RWMutex
Mistake #1: writing under RLock (even a “small” write).
The most common trap is doing something under a read lock that is technically a write: m[k]++, slice[i] = ..., append(slice, ...), changing a struct field. RLock allows parallel reads, but it does not protect against concurrent writes, and you reintroduce a data race. If there is even the slightest chance of mutation, you need Lock.
Mistake #2: mixing up the pairs RLock/RUnlock and Lock/Unlock.
It looks funny until it happens. After that it looks like “why did it hang out of nowhere”. Discipline helps: put defer immediately after a successful Lock()/RLock(), and keep methods small so the lock is “in sight”.
Mistake #3: holding RLock for too long and doing heavy work under it.
Sometimes a developer takes RLock, reads data, and then starts sorting, formatting, building a big response, or even doing I/O while still under RLock. As a result, writers wait “forever”, and readers begin to slow down once a writer is already queued. It is better to take a snapshot under RLock, release the lock, and continue processing on the copy.
Mistake #4: attempting to upgrade RLock → Lock without releasing the read lock.
Intuitively it feels like “I am already inside, now I will just strengthen the lock”. In RWMutex it does not work the way you want. It is very easy to get a deadlock: you are waiting for readers to leave, but one of the readers is you. The correct approach is to release RLock, then take Lock, and usually re-check the condition.
Mistake #5: exposing internal map/[]T to the outside and assuming the lock still protects it.
The lock protects only what happens inside your methods. If you return an internal map or slice, external code can change them anytime and in any way, bypassing your synchronization. The fix is either “snapshots” (copies) or an API like Get/Set/List, where access goes only through the state owner’s methods.
GO TO FULL VERSION