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:

CombinationAllowed Concurrently?Reason
Read ↔ ReadYesReading does not change data — safe to parallelize
Read ↔ WriteNoA writer changes data — readers could see an inconsistent state
Write ↔ WriteNoTwo 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| Data

The 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 again

Several 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 release RLock() before calling Lock() — 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.

Criterionsync.Mutexsync.RWMutexsync.Map
All operationsExclusiveParallel reads, exclusive writesOptimized for concurrent access
Best forWrite-heavy or balancedRead-heavy (>70% reads)Stable keys, many goroutines
OverheadLowModerate (atomic reader counter)Higher (internal sharding)
APIManual lock/unlockManual lock/unlockStore/Load/Delete methods
Type safetyYes (generics available)Yes (generics available)No (interface{})
Lock granularityPer-structPer-structPer-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 and Lock()/Unlock() for all write operations — choosing wrongly causes data races that often only appear in production.
  • Always use defer for 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.RWMutex is 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.Map is 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 -race and benchmark with a realistic read:write ratio — the decision to choose RWMutex must be based on data, not assumptions.

← Previous: Fork–Join   Next: Double-Checked Locking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact