Read–Write Lock Pattern #
Most systems we build read data far more often than they write data. A config store is updated once at startup, but read thousands of times by every request. An in-memory cache is refilled every few minutes, but queried every millisecond. If we protect all access with a plain sync.Mutex — which is exclusive for all operations — we force thousands of readers to queue up one by one just because a single writer is working. This is a huge waste: two readers that change nothing should not block each other. The Read–Write Lock Pattern solves this by fundamentally distinguishing access: many readers may run concurrently, but a writer is always exclusive. The result is far higher throughput for read-heavy workloads, without sacrificing data consistency at all.
What Is a Read–Write Lock? #
A Read–Write Lock is a synchronization mechanism that enforces two simple but powerful rules:
| Combination | Allowed Concurrently? | Reason |
|---|---|---|
| Read ↔ Read | Yes | Reading does not change data — safe to parallelize |
| Read ↔ Write | No | A writer changes data — readers could see an inconsistent state |
| Write ↔ Write | No | Two concurrent writes cause a race condition |
In Go, the implementation is already available through sync.RWMutex — a struct providing two pairs of methods:
flowchart LR
subgraph READ["Read Operations"]
RL[RLock] --> RU[RUnlock]
end
subgraph WRITE["Write Operations"]
L[Lock] --> U[Unlock]
end
READ -->|many goroutines can enter simultaneously| Data[(Shared Data)]
WRITE -->|only one goroutine, blocks all others| DataThe basic rule: use RLock()/RUnlock() for all read-only operations, and Lock()/Unlock() for all operations that modify data. If you choose wrongly — for example using RLock() for a write operation — you will get data races that often only appear in production.
The Problem It Solves: Plain Mutex Is Too Strict #
Before diving into the implementation, it is important to see concretely why a plain sync.Mutex is not enough for read-heavy workloads.
// ANTI-PATTERN: plain Mutex for all operations — readers block each other
type SlowCache struct {
mu sync.Mutex // ✗ exclusive for all operations
data map[string]string
}
func (c *SlowCache) Get(key string) (string, bool) {
c.mu.Lock() // ✗ even reads must queue exclusively
defer c.mu.Unlock()
val, ok := c.data[key]
return val, ok
}
func (c *SlowCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
// CORRECT: RWMutex — many readers may enter simultaneously
type FastCache struct {
mu sync.RWMutex // ✓ distinguishes read and write
data map[string]string
}
func (c *FastCache) Get(key string) (string, bool) {
c.mu.RLock() // ✓ many goroutines can RLock simultaneously
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *FastCache) Set(key, value string) {
c.mu.Lock() // ✓ writes stay exclusive
defer c.mu.Unlock()
c.data[key] = value
}
The performance difference on a 95% read, 5% write workload can be very significant — RWMutex lets all reader goroutines run in parallel, while a plain Mutex forces them to queue one at a time.
In-Memory Cache: The Most Common Real-World Case #
The in-memory cache is the most classic use case for a Read–Write Lock. Caches are read very often but updated rarely — the read:write ratio can be 1000:1 or more.
package main
import (
"fmt"
"sync"
"time"
)
// Cache is a thread-safe in-memory key-value store
type Cache struct {
mu sync.RWMutex
data map[string]cacheEntry
}
type cacheEntry struct {
value string
expiresAt time.Time
}
func NewCache() *Cache {
return &Cache{
data: make(map[string]cacheEntry),
}
}
// Get reads a value from the cache — RLock lets many goroutines read in parallel
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.data[key]
if !ok {
return "", false
}
// Check whether the entry has expired
if time.Now().After(entry.expiresAt) {
return "", false // entry expired, but do not delete here (needs a write lock)
}
return entry.value, true
}
// Set writes a value to the cache — an exclusive Lock is required
func (c *Cache) Set(key, value string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cacheEntry{
value: value,
expiresAt: time.Now().Add(ttl),
}
}
// Delete removes an entry from the cache — an exclusive Lock is required
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
// Evict removes all expired entries
// This is a heavier write operation — an exclusive Lock is required
func (c *Cache) Evict() int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
count := 0
for key, entry := range c.data {
if now.After(entry.expiresAt) {
delete(c.data, key)
count++
}
}
return count
}
// Size returns the number of entries — RLock is enough because it only reads
func (c *Cache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.data)
}
func main() {
cache := NewCache()
// Write some values
cache.Set("user:1", `{"name":"Alice"}`, 5*time.Minute)
cache.Set("user:2", `{"name":"Bob"}`, 5*time.Minute)
cache.Set("config:timeout", "30s", 1*time.Hour)
// Simulate many concurrent readers
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if val, ok := cache.Get("user:1"); ok {
fmt.Printf("[Reader %d] user:1 = %s\n", id, val)
}
}(i)
}
wg.Wait()
fmt.Printf("Cache size: %d\n", cache.Size())
}
Config Store: A Frequently Overlooked Pattern #
A config store is another example perfectly suited to RWMutex. Configuration is read by every request handler but only updated occasionally — during reload or dynamic updates.
package main
import (
"fmt"
"sync"
)
type Config struct {
mu sync.RWMutex
values map[string]interface{}
}
func NewConfig(initial map[string]interface{}) *Config {
c := &Config{
values: make(map[string]interface{}),
}
for k, v := range initial {
c.values[k] = v
}
return c
}
// Get reads a single configuration value
func (c *Config) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.values[key]
return val, ok
}
// GetString is a helper for string values
func (c *Config) GetString(key, defaultVal string) string {
c.mu.RLock()
defer c.mu.RUnlock()
if val, ok := c.values[key]; ok {
if s, ok := val.(string); ok {
return s
}
}
return defaultVal
}
// GetInt is a helper for integer values
func (c *Config) GetInt(key string, defaultVal int) int {
c.mu.RLock()
defer c.mu.RUnlock()
if val, ok := c.values[key]; ok {
if i, ok := val.(int); ok {
return i
}
}
return defaultVal
}
// Reload replaces the entire configuration at once — an exclusive write operation
// Replacing the whole map at once (atomic swap) is safer than per-key updates
func (c *Config) Reload(newValues map[string]interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.values = newValues // ✓ replace the whole map at once
}
// Update changes a single value
func (c *Config) Update(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key] = value
}
func main() {
cfg := NewConfig(map[string]interface{}{
"max_connections": 100,
"timeout_seconds": 30,
"environment": "production",
})
// Many goroutines read the config simultaneously
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
env := cfg.GetString("environment", "development")
maxConn := cfg.GetInt("max_connections", 10)
fmt.Printf("[Worker %d] env=%s, max_conn=%d\n", id, env, maxConn)
}(i)
}
// Simulate a dynamic config reload in a separate goroutine
wg.Add(1)
go func() {
defer wg.Done()
cfg.Reload(map[string]interface{}{
"max_connections": 200,
"timeout_seconds": 60,
"environment": "production",
})
fmt.Println("[Config] Reloaded")
}()
wg.Wait()
}
Understanding RWMutex Internals #
sync.RWMutex works in a more complex way than a plain sync.Mutex. Understanding its mechanics helps avoid non-intuitive traps.
sequenceDiagram
participant R1 as Reader 1
participant R2 as Reader 2
participant W as Writer
participant RWM as RWMutex
R1->>RWM: RLock() — enters (reader count: 1)
R2->>RWM: RLock() — enters (reader count: 2)
W->>RWM: Lock() — waits for all readers to finish
Note over W,RWM: The writer blocks NEW readers
R1->>RWM: RUnlock() (reader count: 1)
R2->>RWM: RUnlock() (reader count: 0)
RWM-->>W: Writer gets the exclusive lock
W->>RWM: Unlock()
Note over RWM: New readers may enter againSeveral important behaviors to understand:
First, a writer does not enter immediately after all readers finish if new readers keep arriving. While a writer is waiting, the Go runtime prevents new readers from entering to avoid writer starvation — the writer is guaranteed to eventually get its turn.
Second, sync.RWMutex is not re-entrant. A goroutine already holding RLock() cannot call Lock() without releasing the RLock() first — this would cause a deadlock.
Third, RLock() is not free. There is an atomic counter overhead to track the number of active readers. For very short critical sections, this overhead can make RWMutex slower than a plain Mutex.
The Lock Upgrade Problem and How to Avoid It #
Lock upgrade is a classic mistake: trying to turn an RLock into a Lock without releasing it first. This always causes a deadlock.
// ANTI-PATTERN: lock upgrade — causes a deadlock
func (c *Cache) GetOrSet(key, value string) string {
c.mu.RLock()
if val, ok := c.data[key]; ok {
c.mu.RUnlock()
return val
}
// ✗ DON'T: still holding RLock, trying to Lock
c.mu.Lock() // DEADLOCK — Lock waits for all RLocks to finish,
// including the RLock held by this goroutine itself
defer c.mu.Unlock()
c.data[key] = value
return value
}
// CORRECT: release the RLock before taking the Lock
func (c *Cache) GetOrSet(key, value string) string {
// Try reading first with RLock
c.mu.RLock()
if val, ok := c.data[key]; ok {
c.mu.RUnlock() // ✓ release RLock before returning
return val
}
c.mu.RUnlock() // ✓ release RLock before taking the Lock
// Take the write lock to write
c.mu.Lock()
defer c.mu.Unlock()
// Double-check after getting the write lock
// (another goroutine may have written while we waited for the Lock)
if val, ok := c.data[key]; ok {
return val // ✓ another goroutine already filled it, use its value
}
c.data[key] = value
return value
}
Always releaseRLock()before callingLock()— there is no way to atomically “upgrade” a read lock to a write lock in Go. Trying to do so without releasing the RLock first causes a deadlock that often only appears under high load and is very hard to debug. Also note the double-check pattern after acquiring the write lock — another goroutine may have already filled the data while you were waiting.
RWMutex vs Mutex vs sync.Map #
Not every situation needs an RWMutex. Choosing the right primitive is an important decision.
| Criterion | sync.Mutex | sync.RWMutex | sync.Map |
|---|---|---|---|
| All operations | Exclusive | Parallel reads, exclusive writes | Optimized for concurrent access |
| Best for | Write-heavy or balanced | Read-heavy (>70% reads) | Stable keys, many goroutines |
| Overhead | Low | Moderate (atomic reader counter) | Higher (internal sharding) |
| API | Manual lock/unlock | Manual lock/unlock | Store/Load/Delete methods |
| Type safety | Yes (generics available) | Yes (generics available) | No (interface{}) |
| Lock granularity | Per-struct | Per-struct | Per-key (internal) |
flowchart TD
Q{Access to\\nshared data?} --> Q1{How often do\\nwrites happen?}
Q1 -->|Often / balanced\\nwith reads| MU[sync.Mutex]
Q1 -->|Rarely\\nread-heavy > 70%| Q2{Many different\\ngoroutines?}
Q2 -->|Stable key set\\nmany goroutines| SM[sync.Map]
Q2 -->|One struct\\nwith many fields| RW[sync.RWMutex]
MU --> Note1[Simpler,\\nmore predictable]
SM --> Note2[No need for\\nmanual locking]
RW --> Note3[High throughput\\nfor parallel reads]When Not to Use RWMutex #
Keep using sync.RWMutex if:
✓ Reads dominate — read:write ratio of 70:30 or higher
✓ The critical section is long enough that the RLock overhead pays off
✓ Many reader goroutines that are independent of each other
✓ The data is a struct or map updated periodically
Consider sync.Mutex if:
✗ Writes are as frequent as reads — RWMutex offers no advantage
✗ The critical section is very short (< a few instructions) — the RLock overhead costs more
✗ You are unsure about the read:write ratio — Mutex is safer and easier to reason about
Consider sync.Map if:
✗ The key set is relatively stable and accessed by many different goroutines
✗ You do not need type safety — the sync.Map API uses interface{}
✗ Read load is very high with a very large number of goroutines
Anti-Patterns to Avoid #
// ✗ Using RLock for a write operation — data race
func (c *Cache) BuggySet(key, value string) {
c.mu.RLock() // ✗ this is a read lock, not a write lock
defer c.mu.RUnlock()
c.data[key] = value // ✗ writing with a read lock = data race
}
// ✓ Always use Lock() for write operations
func (c *Cache) SafeSet(key, value string) {
c.mu.Lock() // ✓ write lock
defer c.mu.Unlock()
c.data[key] = value
}
// ✗ Operations outside the lock — data not protected
func (c *Cache) BuggyGet(key string) string {
c.mu.RLock()
val := c.data[key]
c.mu.RUnlock()
return val + "-suffix" // ✓ this is safe — transformation after unlock is fine
// ✗ what is unsafe is ACCESSING c.data after RUnlock
}
// ✗ Storing a reference to protected data outside the lock
func (c *Cache) BuggyRef(key string) *string {
c.mu.RLock()
defer c.mu.RUnlock()
val := c.data[key]
return &val // ✗ if val is a pointer to internal data, it can be raced after unlock
}
// ✓ Return a copy of the value, not a reference to internal data
func (c *Cache) SafeGet(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok // ✓ a string is a value type — this is a safe copy
}
// ✗ Forgetting to unlock on all paths — other goroutines will deadlock
func (c *Cache) ForgetfulGet(key string) string {
c.mu.RLock()
val, ok := c.data[key]
if !ok {
return "" // ✗ forgot c.mu.RUnlock() before returning
}
c.mu.RUnlock()
return val
}
// ✓ Always use defer to ensure the unlock happens
func (c *Cache) DeferredGet(key string) string {
c.mu.RLock()
defer c.mu.RUnlock() // ✓ defer ensures unlock on all return paths
val := c.data[key]
return val
}
// ✗ Heavy operations inside the critical section — blocks all readers/writers
func (c *Cache) HeavyCompute(key string) string {
c.mu.Lock()
defer c.mu.Unlock()
val := c.data[key]
result := expensiveTransformation(val) // ✗ heavy computation inside the lock
c.data[key] = result
return result
}
// ✓ Take the data, compute outside the lock, then store the result
func (c *Cache) LightLock(key string) string {
c.mu.RLock()
val := c.data[key]
c.mu.RUnlock() // ✓ release the lock before heavy computation
result := expensiveTransformation(val) // computation outside the lock
c.mu.Lock()
c.data[key] = result // ✓ lock only for the fast write operation
c.mu.Unlock()
return result
}
Read–Write Lock Review Checklist #
LOCK SELECTION:
□ Read operations use RLock()/RUnlock()
□ Write operations use Lock()/Unlock()
□ The RWMutex choice is based on a measured read:write ratio
□ sync.Map or sync.Mutex is considered for non-read-heavy cases
LOCK USAGE:
□ defer is always used to ensure unlock on all paths
□ The critical section is as small as possible — no heavy operations inside the lock
□ No lock upgrades (RLock → Lock without releasing the RLock first)
□ Double-check after acquiring the write lock in check-then-act patterns
DATA SAFETY:
□ All access to shared data goes through the same lock mechanism
□ Values are returned as copies (value types), not references to internal data
□ No references to protected data are carried out of the lock
TESTING:
□ Tested with go test -race to detect data races
□ Load testing with a representative read:write ratio
□ Benchmarks compared with sync.Mutex to verify the RWMutex advantage
Summary #
- A Read–Write Lock lets many readers run in parallel — only writers need exclusive access; this is the source of its performance advantage over a plain Mutex.
- Three main rules — read + read may run concurrently; read + write may not; write + write may not.
- Use
RLock()/RUnlock()for all read operations andLock()/Unlock()for all write operations — choosing wrongly causes data races that often only appear in production.- Always use
deferfor unlocking — this ensures the unlock happens on every code path, including mid-function returns.- Keep critical sections as small as possible — heavy computation should happen outside the lock; take the data, release the lock, process, then lock again to store the result.
- A lock upgrade is a deadlock — if you need to go from RLock to Lock, release the RLock first and double-check after acquiring the Lock.
sync.RWMutexis not re-entrant — a goroutine already holding an RLock cannot call Lock without deadlocking.- RWMutex is only better than Mutex when reads dominate — if writes are frequent, the atomic counter overhead in RWMutex can make it perform worse than a plain Mutex.
sync.Mapis an alternative for stable key sets accessed by many different goroutines — but it is not type-safe and has higher overhead.- Verify with
go test -raceand benchmark with a realistic read:write ratio — the decision to choose RWMutex must be based on data, not assumptions.