Lesson 6: sync, atomic & the Race Detector
Channels Aren't Always the Answer
Lesson 0005 taught you "share memory by communicating." But Go's philosophy is pragmatic, not dogmatic. Sometimes a mutex is simpler:
// Channel approach: over-engineered for a simple counter
type Counter struct {
inc chan struct{}
read chan chan int
}
// ... need a goroutine to manage the channel loop ...
// Mutex approach: clear, simple, idiomatic for this use case
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
The Kubernetes codebase uses both — channels for controller work queues, mutexes for shared caches. Knowing when to use each is the mark of an experienced Go developer.
Use sync.Mutex, sync.WaitGroup, sync.Once, and atomic operations correctly. Run the race detector and interpret its output.
sync.Mutex — Mutual Exclusion
A mutex ensures only one goroutine accesses a critical section at a time:
type SafeMap struct {
mu sync.Mutex
data map[string]int
}
func (m *SafeMap) Set(key string, val int) {
m.mu.Lock()
defer m.mu.Unlock() // always unlock with defer
m.data[key] = val
}
func (m *SafeMap) Get(key string) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.data[key]
return val, ok
}
Always use defer with Unlock(). Even if the function panics or has early returns, defer ensures the mutex is released. An unreleased mutex is a deadlock waiting to happen.
sync.RWMutex — Read/Write Lock
When reads vastly outnumber writes, use RWMutex. Multiple readers can hold the lock simultaneously, but writers get exclusive access:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // multiple goroutines can RLock
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
}
Don't Copy a Mutex
A mutex must never be copied. If you pass a struct containing a mutex by value, the copy gets its own mutex — the lock doesn't protect anything. go vet catches this. Always pass by pointer when a struct contains a mutex.
sync.WaitGroup — Wait for Goroutines
A WaitGroup waits for a collection of goroutines to finish:
func main() {
urls := []string{"url1", "url2", "url3"}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1) // increment counter BEFORE launching goroutine
go func(u string) {
defer wg.Done() // decrement counter when goroutine exits
fetch(u)
}(url)
}
wg.Wait() // block until counter reaches zero
fmt.Println("all done")
}
Always call wg.Add() before go. If you call Add inside the goroutine, Wait might return before the goroutine starts. The counter must be incremented synchronously, before launch.
WaitGroup with Error Collection
Real pattern from cloud-native code — wait for goroutines and collect their errors:
func fetchAll(urls []string) []error {
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
if err := fetch(u); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(url)
}
wg.Wait()
return errs
}
sync.Once — Run Exactly Once
Guarantees a function runs exactly once, even when called from multiple goroutines:
type DB struct {
conn *sql.DB
once sync.Once
}
func (d *DB) Conn() *sql.DB {
d.once.Do(func() {
// This runs exactly once, even if 100 goroutines call Conn()
var err error
d.conn, err = sql.Open("postgres", dsn)
if err != nil {
panic(err) // or: log.Fatal — can't return error from Do
}
})
return d.conn
}
sync.Once is the standard pattern for lazy initialization. If the initialization function panics, Do considers it "not done" and will retry on the next call. If you need to return an error, use a separate init step or store the error.
sync/atomic — Lock-Free Operations
For simple counters and flags, atomic operations avoid mutex overhead entirely:
var counter int64
// Concurrent-safe increment without a mutex
atomic.AddInt64(&counter, 1)
// Read the value atomically
val := atomic.LoadInt64(&counter)
// Store atomically
atomic.StoreInt64(&counter, 100)
// Compare-and-swap: set to 200 only if current value is 100
swapped := atomic.CompareAndSwapInt64(&counter, 100, 200)
Atomic Bool for State Flags
var isReady atomic.Bool
isReady.Store(true)
if isReady.Load() {
// ...
}
Mutex vs Atomic: When to Use Which
Use MutexUse Atomic Protecting a section of code (multiple statements)Single integer/boolean read/write Complex data structure updatesHigh-frequency counters Read-write access patterns (RWMutex)Flags and state transitions When correctness > micro-optimizationWhen perf matters and operation is simple
Start with a mutex. Switch to atomic only when profiling shows the mutex is a bottleneck. Premature optimization is still the root of all evil, even in Go.
The Race Detector — Go's Superpower
The race detector instruments your binary to detect concurrent access to shared memory where at least one access is a write — and no synchronization is present.
Run it with -race:
go test -race ./...
go run -race main.go
go build -race -o myapp
What constitutes a data race:
func main() {
counter := 0
go func() {
counter++ // write without synchronization — RACE!
}()
fmt.Println(counter) // read without synchronization — RACE!
}
Output from go run -race:
==================
WARNING: DATA RACE
Read at 0x00c00001a0f8 by main goroutine:
main.main()
/tmp/race.go:12 +0x4e
Previous write at 0x00c00001a0f8 by goroutine 7:
main.main.func1()
/tmp/race.go:8 +0x38
Goroutine 7 (finished) created at:
main.main()
/tmp/race.go:7 +0x47
==================
What the Race Detector Does
- Instruments every memory access — tracks which goroutine read/wrote which address
- Detects concurrent access where at least one is a write AND no synchronization (lock, channel, atomic) happened between them
- Reports the exact lines where the race occurred
Limitations
- Only detects races that actually happen during execution — it can't find races in code paths that don't run
- Race-enabled binaries use ~10x more memory and run slower
- Don't deploy race-enabled binaries to production
Every cloud-native CI pipeline runs -race. Kubernetes, Docker, Prometheus — all gate on clean race detector results. If you submit a PR with a data race, it won't merge. Run go test -race before every commit.
When to Use What
ProblemTool Two goroutines need to coordinate (handoff)Unbuffered channel Producer-consumer data flowChannel (buffered for throughput) Multiple goroutines read/write a shared mapsync.Mutex or sync.RWMutex Wait for N goroutines to finishsync.WaitGroup One-time initializationsync.Once Simple counter, high frequencyatomic.AddInt64 State flag shared across goroutinesatomic.Bool Signal cancellation to many goroutinesclose(chan struct{}) Multiplex multiple channelsselect
Work queues use channels (Lesson 0005). Shared caches use sync.RWMutex. Controller readiness uses sync.Once + atomic.Bool. All tested with -race.
Practice
Write a SafeCounter struct with Inc() and Value() int methods, protected by a sync.Mutex. In main(), launch 100 goroutines that each call Inc() 100 times, wait for all of them, then print Value(). You should get exactly 10,000.
Run with go run -race to verify no races. Then remove the mutex and run again with -race to see the detector work.
Write a function ParallelFetch(urls []string) ([]string, []error) that fetches each URL concurrently. Use a WaitGroup to wait for all goroutines, and collect both results and errors. Each fetch should simulate work with a random time.Sleep and sometimes return an error (use rand.Intn(3) == 0 as the error condition).
Write a Config struct with a sync.Once field and a Load() *Settings method. The first call to Load() should read a simulated config (just return a hardcoded Settings). Subsequent calls return the cached result. Launch 10 goroutines that all call Load() concurrently — verify with a print statement that the initialization runs exactly once.
Quick Check
What's Next?
You now have the full concurrency toolkit — channels for communication, sync primitives for shared state, and the race detector to verify correctness. Next: Context — cancellation, deadlines, and passing request-scoped values through call chains. Every Kubernetes controller method takes a ctx context.Context.
Ask Questions
Not sure when to use a channel vs a mutex? Paste your scenario and I'll help you decide.
1. Q1: Why should you use defer mu.Unlock() instead of calling it at the end?
2. Q2: What's wrong with calling wg.Add(1) inside the goroutine?
3. Q3: The race detector finds: