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
deferfor 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 lockRUnlock(): Releases a read lockLock(): Acquires a write lock, blocks until all readers and writers releaseUnlock(): 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.