6.6 Sync Package

The sync package provides low-level synchronization primitives for coordinating goroutines. While channels are Go's preferred communication mechanism, sometimes you need shared memory synchronization, which is where the sync package comes in.

WaitGroup

sync.WaitGroup waits for a collection of goroutines to finish executing.

WaitGroup is needed because the main goroutine doesn't wait for other goroutines by default. If main exits, the program terminates. WaitGroup provides a way to wait for goroutines to complete.

WaitGroup works by maintaining an internal counter. Add(n) increments the counter by n, Done() decrements the counter by 1, and Wait() blocks until the counter reaches zero.

Basic example

package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // Decrement counter when done

    fmt.Printf("Worker %d: Starting\n", id)
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)
    fmt.Printf("Worker %d: Done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)  // Increment counter before starting goroutine
        go worker(i, &wg)
    }

    fmt.Println("Main: Waiting for workers...")
    wg.Wait()  // Block until counter is zero
    fmt.Println("Main: All workers finished")
}

WaitGroup should be passed by pointer because the counter is modified by multiple goroutines. Passing by value would create a copy, and the counter wouldn't be shared.

Common pattern with defer

Always use defer wg.Done() to ensure the counter is decremented even if the function panics:

func worker(wg *sync.WaitGroup) {
    defer wg.Done()  // Always executed, even on panic

    // Work that might panic
    doWork()
}

WaitGroup with error collection

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, results chan<- int) {
    defer wg.Done()
    results <- id * 2
}

func main() {
    var wg sync.WaitGroup
    results := make(chan int, 5)

    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go worker(i, &wg, results)
    }

    // Close results when all workers done
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Result: %d\n", result)
    }
}

Mutex and RWMutex

Mutexes provide mutual exclusion, ensuring only one goroutine accesses shared data at a time.

Mutexes are needed when multiple goroutines access shared memory, requiring synchronization to prevent race conditions:

// ❌ BAD: Race condition
var counter int
for i := 0; i < 1000; i++ {
    go func() {
        counter++  // Race condition!
    }()
}

A Mutex (mutual exclusion) has two operations: Lock() acquires the lock (blocks if already locked), and Unlock() releases the lock. Only one goroutine can hold the lock at a time.

Basic Mutex example

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    var counter int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()         // Acquire lock
            counter++         // Safe: only one goroutine here
            mu.Unlock()       // Release lock
        }()
    }

    wg.Wait()
    fmt.Printf("Counter: %d\n", counter)  // Always 1000
}

Always use defer with Mutex because if you forget to unlock, other goroutines will block forever. Using defer ensures unlock always happens:

// ✅ GOOD: Always unlocks
func safeIncrement(mu *sync.Mutex, counter *int) {
    mu.Lock()
    defer mu.Unlock()  // Always executed
    *counter++
}

// ❌ BAD: Might forget to unlock
func unsafeIncrement(mu *sync.Mutex, counter *int) {
    mu.Lock()
    *counter++
    // Forgot to unlock!
}

RWMutex (Read-Write Mutex)

RWMutex allows multiple readers or one writer, but not both simultaneously.

RWMutex is useful when you have data that's read frequently but written rarely, as it can improve performance. Multiple readers can read concurrently without blocking, while a single writer gets exclusive access when writing.

RWMutex works by providing RLock() and RUnlock() for readers (multiple readers allowed) and Lock() and Unlock() for writers (exclusive access).

Example

package main

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

type DataStore struct {
    mu   sync.RWMutex
    data map[string]int
}

func NewDataStore() *DataStore {
    return &DataStore{
        data: make(map[string]int),
    }
}

func (ds *DataStore) Read(key string) int {
    ds.mu.RLock()         // Multiple readers allowed
    defer ds.mu.RUnlock()
    return ds.data[key]
}

func (ds *DataStore) Write(key string, value int) {
    ds.mu.Lock()          // Exclusive access
    defer ds.mu.Unlock()
    ds.data[key] = value
}

func main() {
    store := NewDataStore()
    var wg sync.WaitGroup

    // Writer
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 5; i++ {
            store.Write("key", i)
            time.Sleep(100 * time.Millisecond)
        }
    }()

    // Multiple readers
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < 5; j++ {
                value := store.Read("key")
                fmt.Printf("Reader %d: %d\n", id, value)
                time.Sleep(50 * time.Millisecond)
            }
        }(i)
    }

    wg.Wait()
}

When to use RWMutex vs Mutex

Performance consideration

RWMutex has more overhead than Mutex. Only use it if you have many concurrent readers and the performance benefit is measurable.

Once

sync.Once ensures a function is executed exactly once, even if called from multiple goroutines.

Once is needed when you need to initialize something only once, even if multiple goroutines try to initialize it:

// ❌ BAD: Might initialize multiple times
var initialized bool
var mu sync.Mutex

func init() {
    mu.Lock()
    if !initialized {
        doExpensiveInit()
        initialized = true
    }
    mu.Unlock()
}

Once works by providing a single method Do(f func()) that executes the function exactly once, even if Do is called multiple times from different goroutines.

Example

package main

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

var once sync.Once
var config map[string]string

func loadConfig() {
    fmt.Println("Loading configuration...")
    time.Sleep(100 * time.Millisecond)  // Simulate expensive operation
    config = map[string]string{
        "host": "localhost",
        "port": "8080",
    }
    fmt.Println("Configuration loaded")
}

func getConfig() map[string]string {
    once.Do(loadConfig)  // Executes loadConfig exactly once
    return config
}

func main() {
    var wg sync.WaitGroup

    // Multiple goroutines try to get config
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            cfg := getConfig()
            fmt.Printf("Goroutine %d: Config = %v\n", id, cfg)
        }(i)
    }

    wg.Wait()
}

Output

Loading configuration...
Configuration loaded
Goroutine 0: Config = map[host:localhost port:8080]
Goroutine 1: Config = map[host:localhost port:8080]
Goroutine 2: Config = map[host:localhost port:8080]
Goroutine 3: Config = map[host:localhost port:8080]
Goroutine 4: Config = map[host:localhost port:8080]

Notice that "Loading configuration..." appears only once, even though 5 goroutines called getConfig().

Once is better than manual initialization because it's thread-safe and handles concurrent calls correctly. It's efficient since there's no need for mutexes and boolean flags, and it provides a simple, clean API.

Common use cases

Cond

sync.Cond (condition variable) allows goroutines to wait for or announce events that affect shared state.

Cond is needed when you need goroutines to wait for a condition to become true, not just wait for a fixed time. Cond provides a way to signal when conditions change.

Cond works through three methods: Wait() releases the lock and waits for a signal, Signal() wakes one waiting goroutine, and Broadcast() wakes all waiting goroutines.

Example: Producer-Consumer with Cond

package main

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

type Queue struct {
    items []int
    mu    sync.Mutex
    cond  *sync.Cond
}

func NewQueue() *Queue {
    q := &Queue{
        items: make([]int, 0),
    }
    q.cond = sync.NewCond(&q.mu)
    return q
}

func (q *Queue) Put(item int) {
    q.mu.Lock()
    defer q.mu.Unlock()

    q.items = append(q.items, item)
    q.cond.Signal()  // Notify one waiting consumer
}

func (q *Queue) Get() int {
    q.mu.Lock()
    defer q.mu.Unlock()

    // Wait until queue has items
    for len(q.items) == 0 {
        q.cond.Wait()  // Releases lock, waits for signal, reacquires lock
    }

    item := q.items[0]
    q.items = q.items[1:]
    return item
}

func main() {
    queue := NewQueue()
    var wg sync.WaitGroup

    // Consumer
    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < 5; i++ {
            item := queue.Get()
            fmt.Printf("Consumed: %d\n", item)
        }
    }()

    // Producer
    time.Sleep(100 * time.Millisecond)  // Let consumer start first
    for i := 0; i < 5; i++ {
        queue.Put(i)
        fmt.Printf("Produced: %d\n", i)
        time.Sleep(100 * time.Millisecond)
    }

    wg.Wait()
}

Cond requires a mutex because the condition (shared state) must be protected. Wait() atomically releases the lock and waits, and when signaled, it reacquires the lock before returning.

When to use Cond

Note In many cases, channels are simpler and more idiomatic than Cond. Consider channels first, use Cond when channels don't fit the pattern.

Atomic Operations

The sync/atomic package provides low-level atomic operations for synchronizing access to primitive types.

Atomic operations exist because for simple operations like incrementing a counter, mutexes can be overkill. Atomic operations are faster with no lock overhead, lock-free with no blocking, and lower-level using direct CPU atomic instructions.

Atomic operations work by using CPU-level instructions that guarantee operations complete atomically (without interruption).

Example

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var counter int64
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1)
        }()
    }

    wg.Wait()
    fmt.Printf("Counter: %d\n", atomic.LoadInt64(&counter))
}

Common atomic operations

// For int64
atomic.AddInt64(&var, delta)    // Add delta to var
atomic.LoadInt64(&var)           // Read value
atomic.StoreInt64(&var, value)   // Write value
atomic.SwapInt64(&var, new)      // Swap and return old value
atomic.CompareAndSwapInt64(&var, old, new)  // CAS operation

// Similar operations for int32, uint64, uint32, uintptr
// Also: atomic.Value for arbitrary types

Atomic vs Mutex

// Using mutex (more overhead)
var mu sync.Mutex
var counter int

mu.Lock()
counter++
mu.Unlock()

// Using atomic (less overhead)
var counter int64
atomic.AddInt64(&counter, 1)

When to use atomic

When NOT to use atomic

Atomic.Value for arbitrary types

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

type Config struct {
    Host string
    Port int
}

func main() {
    var config atomic.Value

    // Store config
    config.Store(Config{Host: "localhost", Port: 8080})

    // Load config
    cfg := config.Load().(Config)
    fmt.Printf("Config: %+v\n", cfg)

    // Update config atomically
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(port int) {
            defer wg.Done()
            config.Store(Config{Host: "localhost", Port: port})
        }(8080 + i)
    }
    wg.Wait()

    cfg = config.Load().(Config)
    fmt.Printf("Final config: %+v\n", cfg)
}

Best practices

  1. Prefer channels: Use channels for communication between goroutines
  2. Use mutexes for shared state: When you need to protect multiple operations
  3. Use atomic for simple counters/flags: When performance matters and operations are simple
  4. Measure before optimizing: Atomic operations are faster, but the difference may not matter

The sync package provides essential tools for synchronization when channels aren't the right fit. Understanding when to use each primitive is key to writing efficient concurrent Go programs.