Go’s sync.Mutex and sync.RWMutex aren’t just about preventing race conditions; they’re about controlling the flow of concurrent operations, ensuring that shared resources are accessed in a predictable, ordered manner, even if that order isn’t strictly sequential.

Let’s see sync.Mutex in action. Imagine a simple counter that multiple goroutines are trying to increment:

package main

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

func main() {
	var counter int
	var mu sync.Mutex // The mutex protecting the counter

	var wg sync.WaitGroup
	numGoroutines := 1000

	wg.Add(numGoroutines)
	for i := 0; i < numGoroutines; i++ {
		go func() {
			defer wg.Done()
			mu.Lock()   // Acquire the lock
			counter++   // Critical section
			mu.Unlock() // Release the lock
		}()
	}

	wg.Wait() // Wait for all goroutines to finish
	fmt.Println("Final counter value:", counter)
}

When you run this, you’ll reliably get 1000. Without the mu.Lock() and mu.Unlock(), the counter++ operation (which is actually multiple low-level instructions: read, increment, write) could be interleaved, leading to lost updates and a final value less than 1000. The mutex ensures that only one goroutine can execute counter++ at a time.

Now, sync.RWMutex introduces a distinction: multiple readers can access a resource concurrently, but only one writer can access it, and no readers can access it while a writer is active. This is a game-changer for read-heavy workloads. Consider a configuration map:

package main

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

var (
	config = map[string]string{
		"api_key": "initial_key",
	}
	rwmu sync.RWMutex // The RWMutex protecting the config map
)

func getConfig(key string) string {
	rwmu.RLock() // Acquire a read lock
	defer rwmu.RUnlock()
	return config[key]
}

func setConfig(key, value string) {
	rwmu.Lock() // Acquire a write lock
	defer rwmu.Unlock()
	config[key] = value
}

func main() {
	var wg sync.WaitGroup
	numReaders := 100
	numWriters := 5

	wg.Add(numReaders + numWriters)

	// Start readers
	for i := 0; i < numReaders; i++ {
		go func(id int) {
			defer wg.Done()
			time.Sleep(time.Duration(i%10) * time.Millisecond) // Simulate some work
			value := getConfig("api_key")
			fmt.Printf("Reader %d got: %s\n", id, value)
		}(i)
	}

	// Start writers
	for i := 0; i < numWriters; i++ {
		go func(id int) {
			defer wg.Done()
			time.Sleep(time.Duration(i*50) * time.Millisecond) // Stagger writes
			newKey := fmt.Sprintf("api_key_%d", id)
			setConfig(newKey, fmt.Sprintf("value_%d", id))
			fmt.Printf("Writer %d set new config.\n", id)
		}(i)
	}

	wg.Wait()
	fmt.Println("Final config:", config)
}

In this example, getConfig uses RLock() and RUnlock(), allowing many readers to call it simultaneously. setConfig uses Lock() and Unlock(), ensuring exclusive access for writing. This is fundamentally more efficient than a plain Mutex if reads are far more frequent than writes.

The sync.WaitGroup is your tool for coordinating goroutines. It allows a main goroutine to wait until a collection of other goroutines have completed their work. You Add(n) to indicate you’re starting n tasks, Done() when a task finishes, and Wait() to block until the counter reaches zero. It’s the most common way to ensure all concurrent operations have concluded before proceeding, especially when you need to print a final result or shut down gracefully.

The core problem sync.Mutex and sync.RWMutex solve is ensuring atomicity and mutual exclusion for critical sections of code that access shared mutable state. Without them, concurrent access can lead to indeterminate states, lost updates, and corrupted data because operations that appear atomic to a human (like counter++) are actually sequences of reads, modifications, and writes that can be interrupted by other goroutines. RWMutex optimizes this by allowing concurrent reads when no write is occurring, significantly improving performance in read-dominant scenarios.

The mental model for sync.Mutex is a single key to a room. Anyone who wants to enter the room (access the shared resource) must first grab the key. While they have the key, no one else can get in. When they’re done, they return the key. For sync.RWMutex, it’s like a room with a special rule: multiple people can be in the room reading a book. However, if someone wants to write in the book, they must have exclusive access to the room, and no readers can be present while they write. Once they’re done writing, readers can enter again.

Most people understand that Lock() and Unlock() protect against concurrent writes. What they often overlook is that RLock() also prevents writes. If a goroutine holds a read lock (RLock()), and another goroutine tries to acquire a write lock (Lock()), the writer will block. This is the core of RWMutex’s safety but can be a performance bottleneck if writes are frequent and readers hold locks for a long time. Conversely, if a write lock is held, all subsequent read lock requests will also block until the writer releases its lock.

The next hurdle you’ll face is understanding how to avoid deadlocks, especially when using multiple locks or RWMutex in complex ways.

Want structured learning?

Take the full Golang course →