What Is the Difference Between Mutex and RWMutex in Go

Mutex allows exclusive access for any operation, while RWMutex permits multiple concurrent readers but exclusive access for writers.

The cache that slows down

You are building a cache for a web service. Thousands of requests hit the endpoint every second. Most of them just need to look up a value by key. Every now and then, a background job refreshes the cache with fresh data. If you protect the cache with a plain lock, every read blocks every other read. One request holds the lock, the next request waits, the third waits for the second. Your server grinds to a halt under its own weight.

You need a way to let readers coexist while keeping writers safe. Go provides two tools for this job. sync.Mutex gives exclusive access to one goroutine at a time. sync.RWMutex allows multiple readers or a single writer. The choice depends on how your data is accessed.

Mutex versus RWMutex

A sync.Mutex is like a single-key vault. Only one goroutine holds the key at a time. Everyone else waits outside until the holder returns the key. This works fine when access is rare or when reads and writes happen equally often. The lock is simple and fast.

When reads dominate, a plain mutex becomes a bottleneck. Readers block other readers for no reason. Reading data never corrupts it, so there is no safety benefit to serializing reads. sync.RWMutex solves this by splitting the lock into two modes. Multiple goroutines can hold the read lock simultaneously. The write lock is exclusive: it blocks all readers and other writers. This matches the pattern of read-heavy workloads where many consumers inspect shared state while a few producers update it.

Readers don't corrupt data. Writers do. Lock accordingly.

The API surface

Here is the basic API. A mutex has Lock and Unlock. An RWMutex adds RLock and RUnlock for the read side. The write side uses the same Lock and Unlock methods.

package main

import (
	"fmt"
	"sync"
)

// Cache holds shared data protected by an RWMutex.
type Cache struct {
	mu   sync.RWMutex
	data map[string]int
}

// Get retrieves a value with shared read access.
func (c *Cache) Get(key string) int {
	c.mu.RLock() // Multiple goroutines can hold RLock at once
	defer c.mu.RUnlock()
	return c.data[key]
}

// Set updates a value with exclusive write access.
func (c *Cache) Set(key string, val int) {
	c.mu.Lock() // Lock blocks all readers and other writers
	defer c.mu.Unlock()
	c.data[key] = val
}

func main() {
	c := &Cache{data: map[string]int{"a": 1}}
	c.Set("b", 2)
	fmt.Println(c.Get("a"))
}

The receiver name is usually one or two letters matching the type. (c *Cache) follows the convention. Avoid (this *Cache) or (self *Cache). Go idioms prefer short, meaningful names.

How the lock works

When a goroutine calls RLock, the runtime checks if a writer is active. If not, the reader count increments and the goroutine proceeds. Other readers can do the same. The critical section runs concurrently. When the goroutine calls RUnlock, the reader count decrements.

When a goroutine calls Lock, it waits until the reader count drops to zero and no other writer holds the lock. Then it acquires exclusive access. All new readers block until the writer calls Unlock. Existing readers must finish before the writer can proceed.

RWMutex helps readers, but writers still wait for everyone to finish.

Overhead and starvation

RWMutex is not magic. It carries more overhead than a plain mutex. The read lock involves atomic operations to track the count. If you have a single reader and a single writer, RWMutex can be slower than Mutex because of the bookkeeping. Use RWMutex only when the read concurrency actually improves throughput.

Writer starvation is another risk. If readers keep arriving, a writer might wait forever for the reader count to drop. Go's implementation tries to mitigate this by prioritizing writers when contention is high, but starvation can still happen in extreme cases. If your workload has bursts of readers followed by writes, monitor the latency of write operations.

RWMutex trades write latency for read throughput. Measure before you optimize.

Real-world usage

Here is a realistic example. An HTTP handler reads configuration. A background goroutine updates the configuration periodically. The RWMutex allows the handler to serve requests without blocking on the background update.

package main

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

// Config stores application settings.
type Config struct {
	mu      sync.RWMutex
	version string
}

// GetVersion returns the current version safely.
func (c *Config) GetVersion() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.version
}

// SetVersion updates the version with exclusive access.
func (c *Config) SetVersion(v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.version = v
}
// main demonstrates concurrent reads and periodic writes.
func main() {
	cfg := &Config{version: "1.0.0"}

	// Background goroutine updates config every second.
	go func() {
		for i := 1; i <= 5; i++ {
			time.Sleep(time.Second)
			cfg.SetVersion(fmt.Sprintf("1.0.%d", i))
		}
	}()

	// Simulate concurrent readers.
	for i := 0; i < 3; i++ {
		go func(id int) {
			for j := 0; j < 10; j++ {
				fmt.Printf("Reader %d sees: %s\n", id, cfg.GetVersion())
				time.Sleep(100 * time.Millisecond)
			}
		}(i)
	}

	// Keep main alive long enough to observe output.
	time.Sleep(6 * time.Second)
}

The defer statements ensure the locks release even if a panic occurs. This is the standard pattern. Manual Unlock calls are slightly faster but error-prone. The convention is to use defer right after acquiring the lock. Trust gofmt to format the code consistently so you can focus on the logic.

Protect the data, not the function. Lock the smallest scope possible.

Pitfalls and errors

Forgetting to unlock causes a deadlock. The program hangs and eventually crashes with fatal error: all goroutines are asleep - deadlock!. The runtime detects that no goroutine can make progress and terminates the process.

Calling RLock on a mutex that is not locked panics with sync: RLock of unlocked RWMutex. The runtime checks the state and refuses to proceed. This error usually means a logic bug where the lock lifecycle is mismanaged.

Copying a mutex struct is a subtle trap. If you return a struct by value that contains a sync.Mutex or sync.RWMutex, the caller gets a copy. The copy has a zero-valued mutex. Locking the copy does nothing to protect the original data. The compiler does not catch this because the mutex is embedded in the struct. Always pass structs containing locks by pointer, or use a wrapper that prevents copying.

Deadlocks are silent killers. Use defer and keep critical sections short.

When to use what

Use sync.Mutex when reads and writes happen at similar frequencies, or when the critical section is tiny and the overhead of RWMutex isn't worth it.

Use sync.RWMutex when your workload is read-heavy and you need multiple goroutines to read shared data without blocking each other.

Use atomic operations when you only need to update a single integer, boolean, or pointer and want the lowest possible overhead without a lock.

Use channels when you want to pass ownership of data between goroutines rather than sharing memory with locks.

Locks protect shared state. Channels coordinate behavior. Pick the tool that matches your data flow.

Where to go next