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.
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)
}
}
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.
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
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.
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
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.