Right, let’s talk about the sync.Mutex. This is the big one, the foundational tool for when you have shared state and multiple goroutines that want to poke at it. The core idea is simple: mutual exclusion. It means only one goroutine gets to be in the clubhouse at a time. If one’s inside with the mutex locked, everyone else has to stand outside and wait their turn. It’s the bouncer for your data, preventing a chaotic, data-corrupting free-for-all.

Think about a simple counter. You and I both try to add to it at the same time. What should happen is it goes from 0 to 2. What can happen without a mutex is a race condition: we both read the value 0, we both add 1 to it, and we both write back 1. We did two operations and lost an update. Tragic. Let’s see the wrong way first, because it’s hilarious and terrible.

package main

import (
    "fmt"
    "sync"
)

// This is a disaster waiting to happen.
func main() {
    var count int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++ // This is the danger zone.
        }()
    }

    wg.Wait()
    fmt.Println(count) // Will almost certainly not be 1000. Try it.
}

Run that a few times. See? You’ll get a different, wrong number every time. It’s a carnival of sadness. The count++ operation isn’t atomic; it’s read, increment, write. Goroutines are trampling each other.

Locking and Unlocking: The Ceremony

Now, let’s do it properly. You create a sync.Mutex and use it to guard the critical section—the part of the code that touches the shared data.

func main() {
    var count int
    var wg sync.WaitGroup
    var mu sync.Mutex // Our guardian

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()       // "The clubhouse is mine."
            count++         // Safe and sound inside the clubhouse.
            mu.Unlock()     // "I'm done, next!"
        }()
    }

    wg.Wait()
    fmt.Println(count) // 1000. Every. Single. Time.
}

Beautiful, predictable, correct. The Lock() call blocks until it can acquire the mutex. Once it does, it owns it, and all other callers to Lock() block until the owner calls Unlock(). It’s a simple, powerful contract.

Defer Your Unlocks, Save Your Sanity

Notice I used defer in that example. This is non-negotiable best practice. You must unlock your mutexes, and using defer mu.Unlock() right after mu.Lock() ensures it happens no matter what—even if you panic or return early from the function. Forgetting to unlock is a deadlock waiting to happen; your program will just freeze as all other goroutines wait forever for a key that’s already been thrown into the ocean.

// Good, safe, responsible code.
mu.Lock()
defer mu.Unlock()
// Do your thing. The unlock is handled for you.

// Bad, anxiety-inducing code.
mu.Lock()
// ... 50 lines of complex logic later ...
mu.Unlock() // Did you remember? Did a return statement skip it? Who knows!

RLock and RUnlock: Letting the Readers In

Sometimes, your shared state is read far more often than it’s written. A sync.Mutex works here, but it’s overkill. If five goroutines just want to read a value, why should they all wait in line for each other? They can all read it simultaneously without causing any problems. The problem only arises when someone needs to write.

Enter sync.RWMutex (Read-Write Mutex). This is a fantastic optimization for this exact scenario. It allows any number of readers to hold a “read lock” (RLock()/RUnlock()) concurrently, but a “write lock” (Lock()/Unlock()) is exclusive—it blocks all readers and other writers.

var config map[string]string
var configMu sync.RWMutex

// Writer (updates are infrequent)
func SetConfig(key, value string) {
    configMu.Lock()         // Exclusive lock. Blocks all readers and writers.
    defer configMu.Unlock()
    config[key] = value
}

// Reader (happens all the time)
func GetConfig(key string) string {
    configMu.RLock()        // Non-exclusive lock. Other readers can join.
    defer configMu.RUnlock()
    return config[key]
}

The rule is simple: you can RLock if there’s no writer holding the lock. You can only Lock if there are no readers or writers holding the lock. This design massively improves performance in read-heavy applications.

The Hidden Pitfall: Copying Mutexes

Here’s the thing the designers definitely should have made harder to do: a sync.Mutex must never be copied. If you copy it, you’re copying its internal state, which includes information about which goroutine is holding the lock. The copy is a completely separate mutex. This is a one-way ticket to undefined behavior and race conditions, and the go vet tool will actually yell at you for it.

This primarily bites you when you pass a struct containing a mutex by value instead of by pointer.

type SafeCounter struct {
    mu sync.Mutex
    count int
}

// This function takes the struct BY VALUE, copying the mutex. DON'T DO THIS.
func (s SafeCounter) IncrementBad() {
    s.mu.Lock()
    s.count++ // This modifies the COPY, not the original. Useless.
    s.mu.Unlock()
}

// This function takes the struct BY POINTER. This is correct.
func (s *SafeCounter) IncrementGood() {
    s.mu.Lock()
    s.count++
    s.mu.Unlock()
}

Always, always use a pointer receiver for methods that need to lock a mutex inside a struct. It’s not just a performance thing; it’s a correctness thing. The designers made the mutex’s zero value useful (an unlocked mutex), which is great, but they couldn’t make it safe to copy. That’s just a limitation of the language, so it’s on us to be vigilant.