The Go sync Package: Mutex, RWMutex, WaitGroup & errgroup 🔒
While channels handle communication between goroutines, the sync package provides low-level synchronization primitives for managing shared memory, waiting for concurrent tasks, and coordinating single-time initialization.
1. sync.Mutex: Mutual Exclusion
A sync.Mutex ensures that only one goroutine can access a critical section of code at any given time:
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // Always unlock with defer!
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
// Launch 1,000 goroutines to increment concurrently
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println("Final Count:", counter.Value()) // Guaranteed 1000 with zero data races!
}2. sync.RWMutex: Reader/Writer Lock
When you have a data structure that is read frequently but written to rarely (like an in-memory cache), a standard Mutex creates unnecessary contention.
A sync.RWMutex allows unlimited concurrent readers (RLock()), but grants exclusive access to a single writer (Lock()):
package main
import (
"sync"
)
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // Multiple goroutines can read simultaneously!
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // Exclusive lock: no other readers or writers allowed
defer c.mu.Unlock()
c.items[key] = val
}3. sync.WaitGroup: Waiting for Concurrent Tasks
A sync.WaitGroup waits for a collection of goroutines to finish:
wg.Add(n): Increments the counter by .wg.Done(): Decrements the counter by 1 (typically called viadefer wg.Done()).wg.Wait(): Blocks until the counter reaches 0.
package main
import (
"fmt"
"net/http"
"sync"
)
func checkURL(url string, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when finished
resp, err := http.Get(url)
if err != nil {
fmt.Printf("❌ %s is down: %v\n", url, err)
return
}
defer resp.Body.Close()
fmt.Printf("✅ %s returned %d\n", url, resp.StatusCode)
}
func main() {
urls := []string{
"https://golang.org",
"https://google.com",
"https://github.com",
}
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go checkURL(u, &wg)
}
wg.Wait() // Blocks until all 3 URLs have responded!
fmt.Println("All health checks complete.")
}4. sync.Once: Thread-Safe Singleton Initialization
sync.Once guarantees that a piece of code is executed exactly once, even if thousands of goroutines call it concurrently:
package main
import (
"fmt"
"sync"
)
type DatabaseConnection struct{}
var (
dbInstance *DatabaseConnection
once sync.Once
)
func GetDatabase() *DatabaseConnection {
once.Do(func() {
fmt.Println("Initializing database connection pool...")
dbInstance = &DatabaseConnection{}
})
return dbInstance
}5. errgroup.Group: Concurrent Tasks with Error Propagation
In real-world backend services, tasks running in goroutines can return errors. The extended standard library package golang.org/x/sync/errgroup manages a group of goroutines and captures the first error:
package main
import (
"context"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
// Group cancels the context as soon as ANY sub-task returns an error!
g, ctx := errgroup.WithContext(context.Background())
urls := []string{"https://golang.org", "https://invalid-domain-404-xyz.com"}
for _, url := range urls {
u := url // capture loop variable
g.Go(func() error {
// If ctx is canceled by another task failing, we can abort early
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
_, err := http.DefaultClient.Do(req)
return err
})
}
// Wait blocks until all goroutines finish or the first error occurs
if err := g.Wait(); err != nil {
fmt.Printf("At least one request failed: %v\n", err)
}
}6. sync/atomic: Lock-Free Atomic Counters
For low-level counters, sequence generators, and flags, sync/atomic uses CPU-level atomic instructions instead of OS mutex locks, delivering unmatched performance.
Go 1.19+ provides typed atomic types like atomic.Uint64, atomic.Int64, atomic.Bool, and atomic.Pointer[T]:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
// Typed atomic counter (zero value is ready to use)
var ops atomic.Uint64
var wg sync.WaitGroup
// Launch 50 goroutines incrementing the counter 1,000 times each
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for c := 0; c < 1000; c++ {
ops.Add(1)
}
}()
}
wg.Wait()
// Safe atomic read
fmt.Println("Total atomic operations:", ops.Load()) // exactly 50,000
// Compare-and-Swap (CAS)
swapped := ops.CompareAndSwap(50000, 0)
fmt.Printf("Reset counter via CAS: %t (new value: %d)\n", swapped, ops.Load())
}Across the Wiki
- Go Channels — Communicating by message passing vs sharing memory.
- Go Concurrency Philosophy — Channels vs Mutexes decision matrix.
- Go Context Package — Combining
contextwitherrgroup.