Go sync.RWMutex: Read-Write Locks

Most concurrent data structures face a common challenge: reads vastly outnumber writes. Think about a configuration store that's read thousands of times per second but updated once per hour, or a...

Key Insights

  • RWMutex allows multiple concurrent readers but only one writer, making it ideal for read-heavy workloads where reads outnumber writes by at least 3:1
  • Always use defer for unlocking and never attempt lock upgrades (read lock to write lock) as they cause deadlocks
  • RWMutex has higher overhead than Mutex; benchmark your specific use case before choosing it over simpler synchronization primitives

Understanding the Read-Write Lock Problem

Most concurrent data structures face a common challenge: reads vastly outnumber writes. Think about a configuration store that’s read thousands of times per second but updated once per hour, or a cache that serves millions of lookups with occasional invalidations. Using a standard sync.Mutex for these scenarios forces all operations—reads and writes—to serialize completely, leaving performance on the table.

The sync.RWMutex solves this by distinguishing between readers and writers. Multiple goroutines can hold read locks simultaneously since reading doesn’t modify state. Write locks, however, remain exclusive—only one writer can proceed, and it must wait for all readers to finish first.

This distinction matters. In read-heavy workloads, RWMutex can improve throughput by orders of magnitude. But this power comes with complexity and overhead that makes it slower than regular mutexes in write-heavy scenarios.

Core RWMutex Operations

The sync.RWMutex provides four methods:

  • RLock(): Acquires a read lock, blocks if a writer holds the lock
  • RUnlock(): Releases a read lock
  • Lock(): Acquires a write lock, blocks until all readers and writers release
  • Unlock(): Releases a write lock

The locking rules are straightforward but critical:

  • Multiple goroutines can hold read locks concurrently
  • Write locks are exclusive—no readers or writers can proceed
  • Writers wait for all active readers to finish
  • New readers block if a writer is waiting (prevents writer starvation)

Here’s a simple cache implementation demonstrating these operations:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func NewCache() *Cache {
    return &Cache{
        items: make(map[string]string),
    }
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    val, ok := c.items[key]
    return val, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    c.items[key] = value
}

func main() {
    cache := NewCache()
    
    // Writer goroutine
    go func() {
        for i := 0; i < 5; i++ {
            cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
            fmt.Printf("Wrote key%d\n", i)
            time.Sleep(100 * time.Millisecond)
        }
    }()
    
    // Multiple reader goroutines
    for i := 0; i < 3; i++ {
        go func(id int) {
            for j := 0; j < 10; j++ {
                if val, ok := cache.Get(fmt.Sprintf("key%d", j%5)); ok {
                    fmt.Printf("Reader %d: %s\n", id, val)
                }
                time.Sleep(50 * time.Millisecond)
            }
        }(i)
    }
    
    time.Sleep(2 * time.Second)
}

Notice the defer statements immediately after lock acquisition. This pattern is non-negotiable—it ensures locks release even if the function panics.

Practical Implementation Patterns

The configuration manager is a textbook RWMutex use case. Configuration gets read constantly but updates are rare:

package config

import (
    "encoding/json"
    "os"
    "sync"
    "time"
)

type Config struct {
    DatabaseURL string        `json:"database_url"`
    APITimeout  time.Duration `json:"api_timeout"`
    MaxRetries  int           `json:"max_retries"`
}

type ConfigManager struct {
    mu     sync.RWMutex
    config Config
}

func NewConfigManager(initialConfig Config) *ConfigManager {
    return &ConfigManager{
        config: initialConfig,
    }
}

func (cm *ConfigManager) Get() Config {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    
    // Return a copy to prevent external modifications
    return cm.config
}

func (cm *ConfigManager) GetDatabaseURL() string {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    
    return cm.config.DatabaseURL
}

func (cm *ConfigManager) Update(newConfig Config) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    
    cm.config = newConfig
}

func (cm *ConfigManager) Reload(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return err
    }
    
    var newConfig Config
    if err := json.Unmarshal(data, &newConfig); err != nil {
        return err
    }
    
    cm.mu.Lock()
    defer cm.mu.Unlock()
    cm.config = newConfig
    
    return nil
}

Critical pitfall: Never attempt lock upgrades. This code will deadlock:

// DEADLOCK - Never do this!
func (cm *ConfigManager) BadUpdate(key string) {
    cm.mu.RLock()
    current := cm.config
    cm.mu.RUnlock()
    
    // Race condition here - config might change
    
    cm.mu.Lock()  // Too late, not atomic
    cm.config = current
    cm.mu.Unlock()
}

// Also DEADLOCK
func (cm *ConfigManager) WorseTryUpgrade() {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    
    // Can't upgrade while holding read lock
    cm.mu.Lock()  // Deadlock!
    defer cm.mu.Unlock()
}

If you need to read-then-write atomically, acquire the write lock from the start.

Performance Comparison

RWMutex shines in read-heavy workloads but adds overhead. Here’s a benchmark comparing both:

package main

import (
    "sync"
    "testing"
)

type MutexCounter struct {
    mu    sync.Mutex
    count int
}

func (c *MutexCounter) Inc() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *MutexCounter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

type RWMutexCounter struct {
    mu    sync.RWMutex
    count int
}

func (c *RWMutexCounter) Inc() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *RWMutexCounter) Get() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.count
}

func BenchmarkMutexReadHeavy(b *testing.B) {
    c := &MutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            for i := 0; i < 100; i++ {
                c.Get()
            }
            c.Inc()
        }
    })
}

func BenchmarkRWMutexReadHeavy(b *testing.B) {
    c := &RWMutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            for i := 0; i < 100; i++ {
                c.Get()
            }
            c.Inc()
        }
    })
}

func BenchmarkMutexWriteHeavy(b *testing.B) {
    c := &MutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.Inc()
            c.Get()
        }
    })
}

func BenchmarkRWMutexWriteHeavy(b *testing.B) {
    c := &RWMutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.Inc()
            c.Get()
        }
    })
}

Run these with go test -bench=. -cpu=1,2,4,8. On my machine with a 100:1 read-to-write ratio, RWMutex is 3-4x faster. At 1:1, regular Mutex wins by 20-30%.

The break-even point is typically around 3:1 reads-to-writes, but this varies by workload. Always benchmark your actual use case.

Advanced Patterns and Best Practices

Embed RWMutex directly in your structs for cleaner APIs:

type MetricsStore struct {
    mu sync.RWMutex
    
    requests    map[string]int64
    errors      map[string]int64
    lastUpdated time.Time
}

func (m *MetricsStore) RecordRequest(endpoint string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    
    m.requests[endpoint]++
    m.lastUpdated = time.Now()
}

func (m *MetricsStore) GetStats(endpoint string) (requests, errors int64) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    
    return m.requests[endpoint], m.errors[endpoint]
}

Here’s a production-ready cache with proper error handling and context support:

package cache

import (
    "context"
    "errors"
    "sync"
    "time"
)

var ErrNotFound = errors.New("key not found")

type item struct {
    value      interface{}
    expiration time.Time
}

type TTLCache struct {
    mu    sync.RWMutex
    items map[string]item
}

func NewTTLCache() *TTLCache {
    c := &TTLCache{
        items: make(map[string]item),
    }
    go c.cleanup()
    return c
}

func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    c.items[key] = item{
        value:      value,
        expiration: time.Now().Add(ttl),
    }
}

func (c *TTLCache) Get(ctx context.Context, key string) (interface{}, error) {
    // Check context before acquiring lock
    if err := ctx.Err(); err != nil {
        return nil, err
    }
    
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    item, ok := c.items[key]
    if !ok {
        return nil, ErrNotFound
    }
    
    if time.Now().After(item.expiration) {
        return nil, ErrNotFound
    }
    
    return item.value, nil
}

func (c *TTLCache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    delete(c.items, key)
}

func (c *TTLCache) cleanup() {
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()
    
    for range ticker.C {
        c.mu.Lock()
        now := time.Now()
        for key, item := range c.items {
            if now.After(item.expiration) {
                delete(c.items, key)
            }
        }
        c.mu.Unlock()
    }
}

Always run your concurrent code with the race detector during testing: go test -race. It catches data races that RWMutex should protect against.

Consider alternatives before reaching for RWMutex. For simple counters, sync/atomic is faster. For concurrent maps, sync.Map might be better if you have extremely high contention or unknown key sets.

RWMutex is a powerful tool for read-heavy concurrent access patterns. Use it when you’ve measured that reads significantly outnumber writes, always pair locks with defers, and never attempt lock upgrades. Profile before optimizing, and remember that simpler synchronization primitives are often sufficient.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.