>_
GolangStepByStep
Software Engineer

Sync Primitives

Mutex, RWMutex, atomic, Once, Cond — low-level synchronization

# What are Sync Primitives?

Imagine you have a single bank account and two people trying to withdraw money at the exact same millisecond. If the system doesn't coordinate them, they might both check the balance, see $100, and both withdraw $100, leaving the bank short $100.

This is a Race Condition. To prevent this, we need a way to say: "Hey, one person at a time." This coordination is called Synchronization.

In Go, we have two main ways to synchronize:

  • Channels: Pass the money (or data) from one person to another. (Communicating by sharing).
  • Sync Primitives: Share the same bank account (memory) but use locks to make sure only one person accesses it at a time. (Sharing by communicating).

Sync primitives (like sync.Mutex, sync.WaitGroup, etc.) are low-level tools in the sync package. They are incredibly fast but require you to be careful. You use them when you absolutely need to share state (like a cache, a counter, or a configuration) across thousands of concurrent goroutines.

# Mutex (The "Do Not Disturb" Sign)

sync.Mutex stands for "Mutual Exclusion". Think of it as a "Do Not Disturb" sign on a single bathroom door. If the door is locked, anyone else who wants to enter must wait in line until the door is unlocked.

You use it to protect a critical section of code—the lines where you read or write the shared data. Keep this section as small as possible so you don't keep others waiting for too long.

type Counter struct {
    mu sync.Mutex // The lock
    n  int        // The shared data
}

func (c *Counter) Inc() {
    c.mu.Lock()   // 🚪 Lock the door
    c.n++         // ✏️  Safely update the data
    c.mu.Unlock() // 🔓 Unlock the door
}

# RWMutex (The "Reading Room")

Sometimes, you have data that is read hundreds of times a second, but only updated once an hour (like a configuration map). A standard sync.Mutex would lock out readers even if another reader is currently reading. That's slow.

sync.RWMutex (Read/Write Mutex) solves this:

  • RLock() / RUnlock(): Allows infinite readers at the exact same time.
  • Lock() / Unlock(): Exclusive write access. If someone is writing, no one can read or write until they finish.
// Multiple goroutines can do this simultaneously
mu.RLock()
value := cache[key] 
mu.RUnlock()

// Only ONE goroutine can do this. All readers must wait.
mu.Lock()
cache[key] = value
mu.Unlock()

# WaitGroup (The "Roll Call")

Imagine you are a teacher on a field trip with 3 students. Before leaving the museum, you do a roll call and wait until all 3 students are back on the bus.

sync.WaitGroup does exactly this for goroutines:

  • Add(n): "I am waiting for n students."
  • Done(): "One student just got back." (Decrements counter by 1)
  • Wait(): "Don't leave until the counter is 0."
var wg sync.WaitGroup

for i := 0; i < 3; i++ {
    wg.Add(1) // 🙋 Add 1 before starting the goroutine
    go func() {
        defer wg.Done() // ✅ Tell WaitGroup we are done when exiting
        work()
    }()
}

wg.Wait() // 🛑 Block here until all 3 are Done()

# Once (The "First Time Only")

Sometimes you want to initialize a heavy database connection, but only exactly once, even if 100 goroutines try to call the initialization function at the exact same time.

sync.Once guarantees the function inside Do() is executed only one time. Behind the scenes, it uses atomic counters and a mutex to ensure safety and speed.

var once sync.Once // Zero value is ready to use

// Even if 100 goroutines call this, initClient() runs exactly once
once.Do(func() {
    initClient() 
})

# Cond (The "Paging System")

Imagine 10 workers in a restaurant waiting for the kitchen to announce "More food is ready!" You don't want the workers constantly peeking into the kitchen (busy waiting).

sync.Cond is a condition variable. It allows goroutines to go to sleep and wait until they get a signal that some shared condition has changed.

  • Wait(): Go to sleep until signaled.
  • Signal(): Wake up exactly one waiting goroutine.
  • Broadcast(): Wake up ALL waiting goroutines.
// Worker side (Wait for food to be ready)
cond.L.Lock()
for !foodReady {          // ⚠️ ALWAYS check in a loop
    cond.Wait()           // 😴 Sleep until signaled
}
cond.L.Unlock()

// Kitchen side (Announce food is ready)
cond.L.Lock()
foodReady = true
cond.Broadcast()          // 📢 WAKE UP EVERYONE!
cond.L.Unlock()

# Atomics (The "Low Level Counters")

Mutexes require asking the Go scheduler for a lock. For a simple integer counter, that overhead is too high.

The sync/atomic package provides processor-level, hardware instructions to safely increment, load, or store simple values (like an int64) without a lock.

Warning: Only use Atomics for exactly one thing: simple counters or flags. Never use them to coordinate complex data structures.

// ⚡ Increment safely without a lock
atomic.AddInt64(&count, 1)

// ⚡ Read safely without a lock
if atomic.LoadInt64(&count) > 10 { /* ... */ }

# Common Pitfalls

  • Holding locks while calling external code.
  • Lock ordering inconsistencies causing deadlocks.
  • Using RWMutex when writes are frequent (it can be slower).
  • Using atomics for multi-field invariants.
  • Forgetting to unlock in error paths.

⚡ Key Takeaways

  • Mutex for compound shared state.
  • RWMutex when reads dominate.
  • WaitGroup for completion, not coordination.
  • Once for safe initialization.
  • Atomics for simple counters or flags.
practice & review