Day 14 — sync Package
Learning Objectives
- Use
sync.Mutexvssync.RWMutexcorrectly for shared state - Coordinate goroutine completion with
sync.WaitGroup - Run the race detector:
go test -race
Key Concepts
// sync.RWMutex — multiple concurrent readers OR one writer
// Use when reads are much more frequent than writes (e.g., config, IP blocklist)
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock() // multiple goroutines can hold RLock simultaneously
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap) Set(k string, v int) {
s.mu.Lock() // exclusive: blocks all readers AND writers
defer s.mu.Unlock()
s.m[k] = v
}
// sync.Once — exactly one initialization, thread-safe
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() { instance = loadConfig() })
return instance
}
Exercise: Thread-Safe Request Counter
package main
import (
"fmt"
"sync"
"testing"
)
// RequestCounter tracks request counts per endpoint.
// Reads (metrics scraping) are frequent; resets are rare.
type RequestCounter struct {
mu sync.RWMutex
counts map[string]int64
total int64
}
func NewRequestCounter() *RequestCounter {
return &RequestCounter{counts: make(map[string]int64)}
}
func (rc *RequestCounter) Inc(path string) {
rc.mu.Lock()
defer rc.mu.Unlock()
rc.counts[path]++
rc.total++
}
func (rc *RequestCounter) Get(path string) int64 {
rc.mu.RLock()
defer rc.mu.RUnlock()
return rc.counts[path]
}
func (rc *RequestCounter) Snapshot() map[string]int64 {
rc.mu.RLock()
defer rc.mu.RUnlock()
copy := make(map[string]int64, len(rc.counts))
for k, v := range rc.counts {
copy[k] = v
}
return copy
}
func (rc *RequestCounter) Reset() {
rc.mu.Lock()
defer rc.mu.Unlock()
rc.counts = make(map[string]int64)
rc.total = 0
}
// Test with race detector: go test -race -v
func TestRequestCounter_Concurrent(t *testing.T) {
rc := NewRequestCounter()
var wg sync.WaitGroup
paths := []string{"/api/v1/users", "/api/v1/auth", "/health"}
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
path := paths[id%len(paths)]
for j := 0; j < 100; j++ {
rc.Inc(path)
}
}(i)
}
wg.Wait()
snap := rc.Snapshot()
var total int64
for _, count := range snap {
total += count
}
if total != 10000 {
t.Errorf("expected 10000 total, got %d", total)
}
}
func main() {
rc := NewRequestCounter()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
rc.Inc("/api/resource")
rc.Inc("/health")
}(i)
}
wg.Wait()
for path, count := range rc.Snapshot() {
fmt.Printf("%s: %d requests\n", path, count)
}
}
Run the test with race detector:
go test -race -v ./...
Real-World Context
Your rate limiter (Day 76) uses this exact pattern — a sync.RWMutex-protected map from API key → token bucket. Without RWMutex, the write lock serializes all requests. With it, 1000 concurrent reads go through simultaneously, blocked only when a new bucket is created.