sync.Mutex / WaitGroup
The sync package gives you the standard concurrency primitives: Mutex, RWMutex, WaitGroup, Once, Pool, Map, Cond. Use them when channels feel like the wrong shape; the rule of thumb is “share memory by communicating” for orchestration, locks for state.
Mutex, WaitGroup, Once, Pool, atomic
EXAMPLE
package main
import (
"sync"
"sync/atomic"
"fmt"
"time"
)
// 1) Mutex — protect shared state
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() { c.mu.Lock(); defer c.mu.Unlock(); c.n++ }
func (c *Counter) Value() int { c.mu.Lock(); defer c.mu.Unlock(); return c.n }
// Always pair Lock() with defer Unlock(). Panic-safe.
// Avoid copying a Mutex; use *Counter or embed by pointer.
// 2) RWMutex — many readers OR one writer
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock(); defer c.mu.RUnlock()
v, ok := c.data[k]
return v, ok
}
func (c *Cache) Set(k, v string) {
c.mu.Lock(); defer c.mu.Unlock()
c.data[k] = v
}
// RWMutex shines when reads dominate. Don't use for write-heavy work — overhead vs Mutex.
// 3) WaitGroup — wait for many goroutines to finish
func fetchAll(urls []string) {
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func(url string) {
defer wg.Done()
// fetch url
}(u)
}
wg.Wait()
}
// Add(n) BEFORE spawning, not inside the goroutine — race-free start.
// Done() guaranteed via defer.
// 4) Once — run code exactly once (thread-safe init)
var (
cfg *Config
cfgOnce sync.Once
)
func getConfig() *Config {
cfgOnce.Do(func() {
cfg = loadConfig()
})
return cfg
}
// Even with N concurrent callers, loadConfig runs once and they all block until it's done.
// 5) Pool — reuse expensive objects
var bufPool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}
func process(r io.Reader) {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf)
// use buf without allocating
_, _ = io.ReadFull(r, buf)
}
// Great for GC pressure (buffers, JSON encoders, etc.).
// Pool may evict items between uses; don't rely on stored state.
// 6) Map — concurrent map for write-once / read-many
var cache sync.Map
cache.Store("key", "value")
v, ok := cache.Load("key")
cache.Delete("key")
cache.Range(func(k, v any) bool { fmt.Println(k, v); return true })
// sync.Map is OPTIMIZED for: stable keys with infrequent writes, or many goroutines reading/writing
// disjoint keys. For the typical write-heavy concurrent map, a plain map + RWMutex is faster.
// 7) Cond — wait for a state change (rare; channels usually cleaner)
var (
mu sync.Mutex
cond = sync.NewCond(&mu)
ready bool
)
func consumer() {
mu.Lock()
for !ready {
cond.Wait() // releases mu while sleeping
}
mu.Unlock()
}
func producer() {
mu.Lock()
ready = true
cond.Broadcast() // or Signal() for one
mu.Unlock()
}
// 8) sync/atomic — lock-free counters + flags
var hits atomic.Int64 // Go 1.19+
hits.Add(1)
hits.Load()
hits.Store(0)
hits.CompareAndSwap(0, 1)
// Use atomics for primitives where a Mutex would be overkill.
// Pre-1.19: atomic.AddInt64(&hits, 1), atomic.LoadInt64(&hits)
// atomic.Value — for any type, with type-stable Store/Load
var config atomic.Value
config.Store(&Config{Host: "localhost"})
cfg := config.Load().(*Config)
// 9) errgroup — wait + first-error cancellation (golang.org/x/sync/errgroup)
import "golang.org/x/sync/errgroup"
func fetchAllSafe(ctx context.Context, urls []string) error {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, u := range urls {
u := u
g.Go(func() error {
return fetch(gctx, u)
})
}
return g.Wait() // first error cancels gctx + returned here
}
// errgroup is what most production code uses instead of bare WaitGroup.
// 10) Semaphore — bounded concurrency
import "golang.org/x/sync/semaphore"
sem := semaphore.NewWeighted(8)
for _, item := range items {
item := item
sem.Acquire(ctx, 1)
go func() {
defer sem.Release(1)
process(item)
}()
}
// 11) Channels vs sync primitives — when to use which
// Channels:
// • Communication between goroutines (pipeline, fan-out / fan-in)
// • Cancellation (close + select)
// • Synchronous handoff (send/receive)
//
// sync primitives:
// • Protecting shared mutable state
// • Wait for completion (WaitGroup / errgroup)
// • Lazy initialisation (Once)
// • Buffer reuse (Pool)
// • Lock-free counters (atomic)
// 12) Debugging concurrent code
// go run -race main.go // race detector — enable in tests + dev
// go test -race ./... // CI standard
// runtime/pprof + 'go tool pprof' // profiling
// 13) Race detector example finding a real bug
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // RACE — no synchronisation
}()
}
wg.Wait()
fmt.Println(count) // < 1000 due to lost increments
}
// go run -race main.go → reports the race.
// Fix:
// atomic.Int64 or Mutex around count.
// 14) Common bugs
// • Copying a Mutex — gives DIFFERENT locks; embed via pointer
// • Releasing a Mutex you don't hold — panic
// • Forgetting wg.Add BEFORE go — wg.Wait may return before all workers register
// • Recursive locking — Mutex isn't reentrant; deadlock
// • Holding a Mutex across an unrelated channel send/receive — deadlock under contention
// • RWMutex starvation — long writer queue blocks new readers; use RWMutex only when needed
// • Pool storing stateful objects — reset state on Get
// • sync.Map for write-heavy maps — slower than RWMutex + map
// • Atomic on int64 fields not 8-byte aligned (32-bit ARM) — use atomic.Int64 wrapper (Go 1.19+)
// • Forgetting to call Done in branching code — defer it
// • Cond.Wait without a for loop checking condition — spurious wakeups
Why it matters
Use channels for orchestration, the sync package for state: Mutex/RWMutex for shared mutable data, WaitGroup (or better, errgroup) for completion, Once for lazy init, Pool for buffer reuse, atomic for lock-free counters. Always run tests with -race to catch the bugs concurrent code is famous for.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) { defer wg.Done(); work(i) }(i)
}
wg.Wait()
Try it Yourself »
Discussion
Loading…