19 — sync Package
Channels are for "communicate by sharing"; sync provides lower-level primitives when you need mutexes, coordination, and object reuse.
sync.Mutex and sync.RWMutex — Internals
go
// Mutex is a binary semaphore — only one goroutine in the critical section.
// Internally it's an atomic int with a waiter queue (futex-based on Linux).
// Go's Mutex is NOT reentrant — locking twice from the same goroutine deadlocks.
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // ✅ always defer Unlock — runs even on panic
c.count++
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
// ─── RWMutex — multiple readers OR one writer ───
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // ✅ multiple readers can hold RLock simultaneously
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // ✅ exclusive — blocks all readers and writers
defer c.mu.Unlock()
c.data[key] = val
}
// ┌──────────────────────────────────────────────────────────────────────┐
// │ When to use RWMutex vs Mutex: │
// │ RWMutex: reads vastly outnumber writes AND the critical section │
// │ is non-trivial (the RLock overhead is higher than Mutex) │
// │ Mutex: balanced read/write or short critical sections │
// │ RWMutex overhead: 2 atomic ops per RLock (vs 1 for Lock) │
// │ For write-heavy or short sections, Mutex is faster. │
// └──────────────────────────────────────────────────────────────────────┘
sync.WaitGroup — The Race-Free Pattern
go
// ❌ ANTI-PATTERN: Add inside the goroutine — races with Wait
func badWait() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // ❌ Wait may see counter=0 before Add runs
defer wg.Done()
work(i)
}()
}
wg.Wait() // may return prematurely
}
// ✅ CORRECT: Add BEFORE starting the goroutine
func goodWait() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // ✅ Add before go — Wait won't see 0 prematurely
go func(i int) {
defer wg.Done()
work(i)
}(i)
}
wg.Wait() // blocks until all 5 call Done
}
// ✅ Batch Add — add all at once, then start goroutines
func batchAdd() {
var wg sync.WaitGroup
items := make([]int, 100)
wg.Add(len(items)) // add all upfront
for i := range items {
go func(i int) {
defer wg.Done()
work(i)
}(i)
}
wg.Wait()
}
sync.Once — Lazy Initialization
go
// Once.Do runs the function exactly once across ALL goroutines.
// Subsequent calls (even concurrent) skip it. The canonical lazy singleton.
type Database struct {
conn *sql.DB
}
var (
dbOnce sync.Once
db *Database
)
func GetDB() *Database {
dbOnce.Do(func() {
// This runs exactly once, even if 100 goroutines call GetDB simultaneously.
conn, err := sql.Open("postgres", os.Getenv("DSN"))
if err != nil {
panic(fmt.Sprintf("database init: %v", err))
}
db = &Database{conn: conn}
})
return db
}
// ─── Once with error handling ───
// Once.Do doesn't support return values. For init that can fail, use a
// separate variable:
var (
initOnce sync.Once
initErr error
)
func InitConfig() error {
initOnce.Do(func() {
initErr = loadConfig()
})
return initErr // returns the error from the first call (all callers see same)
}
sync.Cond — Condition Variables
go
// Cond is for "wait until a condition is true." Less common than channels
// but useful for bounded-buffer / producer-consumer patterns.
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
}
func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu) // Cond is bound to a Mutex
return q
}
func (q *Queue) Put(item int) {
q.mu.Lock()
q.items = append(q.items, item)
q.cond.Signal() // wake ONE waiting consumer
q.mu.Unlock()
}
func (q *Queue) Get() int {
q.mu.Lock()
defer q.mu.Unlock()
// ⚠️ MUST loop — spurious wakeups can occur
for len(q.items) == 0 {
q.cond.Wait() // atomically: unlock mu, block, re-lock on wake
}
item := q.items[0]
q.items = q.items[1:]
return item
}
// ─── Broadcast vs Signal ───
// Signal: wake ONE waiter (use when one item is added)
// Broadcast: wake ALL waiters (use when the condition changes for everyone)
sync.Pool — Object Reuse to Reduce GC Pressure
go
// Pool reuses objects to reduce allocations. Get returns a pooled object
// (or calls New if pool is empty); Put returns it for reuse.
// ⚠️ Pooled objects can be reclaimed at ANY time (between GC cycles) —
// don't store state you need to persist. Reset objects before Put.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // ⚠️ reset before returning to pool
bufPool.Put(buf)
}()
buf.Write(data)
return buf.String()
}
// ─── When sync.Pool helps ───
// - Short-lived, allocation-heavy objects (buffers, temp structs)
// - High-frequency allocation (thousands per second)
// - The object is the same size/shape each time
//
// ─── When it doesn't ───
// - Long-lived objects (pool reclaims them between GCs)
// - Objects that grow (a buffer that grew to 1MB stays 1MB — memory leak)
// - Low-frequency allocation (pool overhead > allocation cost)
sync.Map — Concurrent Map (Use Sparingly)
go
// sync.Map is safe for concurrent use without a mutex.
// Optimized for write-rarely/read-many (caches with few updates).
// Uses `any` (no generics — must type-assert on Load).
func syncMapDemo() {
var m sync.Map
m.Store("key", "value")
v, ok := m.Load("key") // v is any — must assert: v.(string)
if ok {
fmt.Println(v.(string))
}
// LoadOrStore — atomic "get or set":
actual, loaded := m.LoadOrStore("key", "default")
// loaded=false if newly stored, true if existing value returned
m.Delete("key")
m.Range(func(k, v any) bool {
fmt.Println(k, v)
return true // continue iteration
})
}
// ─── Generic concurrent map (usually better than sync.Map) ───
type ConcurrentMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func (c *ConcurrentMap[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[key]
return v, ok
}
func (c *ConcurrentMap[K, V]) Set(key K, val V) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = val
}
// Type-safe, often faster than sync.Map for balanced read/write workloads.
💡 Tips & Tricks
- Idiom: always pair
Lock/Unlockwithdefer—mu.Lock(); defer mu.Unlock()guarantees the unlock runs even on panic. A missingUnlock(early return) deadlocks all other goroutines. - Idiom: use
RWMutexonly when reads vastly outnumber writes and the critical section is non-trivial — the RWMutex has higher overhead than a Mutex (two atomic ops per RLock). For short sections or write-heavy workloads, a plain Mutex is faster. - Idiom:
wg.Add(n)beforego f(), not insidef— there's a race whereWaitsees counter 0 beforeAddruns.defer wg.Done()insidefensures it runs even on panic. - Idiom: use
sync.Oncefor lazy initialization — runs exactly once across all goroutines, without double-checked locking boilerplate. The canonical singleton pattern. - Idiom: prefer a
map+RWMutexoversync.Mapfor general concurrent maps —sync.Mapis optimized for write-rarely/read-many (caches) and usesany(no type safety). A generic map + RWMutex is type-safe and often faster. - Performance:
sync.Poolfor short-lived allocation-heavy objects (buffers) — reset before Put, don't store long-lived state. Objects can be reclaimed between GC cycles.
⚠️ Edge Cases & Gotchas
- Copying a Mutex/WaitGroup/Once is a bug: these have internal state.
go vetcatches this ("assignment copies lock value"). Pass by pointer, never by value. - Mutex is not reentrant: locking twice from the same goroutine deadlocks (Go's Mutex has no concept of "owner").
- Forgetting
Unlock: aLockwithoutUnlock(early return) deadlocks. Usedefer. RWMutexcan starve writers: if readers continuously holdRLock, a writer may wait indefinitely (Go has anti-starvation, but be aware). For write-heavy, use Mutex.WaitGroup.Addinside the goroutine is a race:go func() { wg.Add(1); ... }()—Waitmay see counter 0.Addbeforego.Once.Doblocks concurrent callers: the first call runsf; other callers block until it completes. Iffis slow, all concurrentDocalls wait.sync.Poolobjects can be reclaimed: between GC cycles, pooled objects may be freed. Don't store state you need to persist. Reset before Put.sync.Mapis not generic: usesany— type-assert onLoad. For type safety, usemap+RWMutex.Cond.Waitmust be called with the mutex locked:Waitunlocks, waits, re-locks. Calling without the lock is a runtime error.Condspurious wakeups:Waitcan wake withoutSignal/Broadcast. Always loop:for !condition { cond.Wait() }.
🧠 Quick Quiz
go
type S struct {
mu sync.Mutex
v int
}
func f(s S) { // ⚠️ s is passed BY VALUE
s.mu.Lock()
s.v++
s.mu.Unlock()
}
func main() {
s := S{}
f(s)
}
What does go vet report?
Answer
go vet reports:
f passes lock by value: S contains sync.Mutex
Passing S by value copies the sync.Mutex — the copied mutex has its own (uninitialized) internal state. Locking the copy doesn't protect the original. This is a bug that go vet catches.
The fix — pass by pointer:
func f(s *S) {
s.mu.Lock()
defer s.mu.Unlock()
s.v++
}
The lesson: types containing sync.Mutex, sync.WaitGroup, sync.Once, etc. must be passed by pointer, never by value. go vet catches this automatically.
📚 What's Next
→ 20 — Context — cancellation trees, deadlines, request-scoped values, and the cancel leak.