Introduction

Go's race detector (-race flag) identifies data races where multiple goroutines access the same memory location concurrently with at least one write. Data races cause undefined behavior that is notoriously difficult to debug because they are non-deterministic and may only manifest under specific timing conditions. Common patterns include unprotected map access, shared counters without atomic operations, and lazy initialization without synchronization. The race detector adds significant overhead (5-15x slowdown) so it is typically only enabled in tests and CI, making it critical to fix all detected races before they reach production.

Symptoms

``` WARNING: DATA RACE Read at 0x00c0000a8060 by goroutine 8: main.(*Counter).Increment() /app/main.go:23 +0x45

Previous write at 0x00c0000a8060 by goroutine 7: main.(*Counter).Increment() /app/main.go:23 +0x58

Goroutine 8 (running) created at: main.main() /app/main.go:15 +0x32 ```

Or:

``` fatal error: concurrent map writes

goroutine 5 [running]: main.processData(...) /app/main.go:34 ```

Common Causes

  • Unprotected map access: Concurrent reads and writes to the same map
  • Shared counter without atomic: count++ is not atomic (read-modify-write)
  • Lazy initialization race: sync.Once not used for singleton initialization
  • Struct field mutation: Multiple goroutines modifying same struct fields
  • Slice append race: Concurrent appends to shared slice
  • Global variable mutation: Package-level variables modified from multiple goroutines

Step-by-Step Fix

Step 1: Use sync.Mutex for shared state

```go type SafeCache struct { mu sync.RWMutex data map[string]string }

func NewSafeCache() *SafeCache { return &SafeCache{ data: make(map[string]string), } }

func (c *SafeCache) Get(key string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() val, ok := c.data[key] return val, ok }

func (c *SafeCache) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } ```

Step 2: Use atomic for simple counters

```go import "sync/atomic"

type Stats struct { requests atomic.Int64 errors atomic.Int64 latencyMs atomic.Int64 }

func (s *Stats) RecordRequest() { s.requests.Add(1) }

func (s *Stats) RecordError() { s.errors.Add(1) }

func (s *Stats) RecordLatency(ms int64) { // Running average using atomic CAS loop for { old := s.latencyMs.Load() newAvg := (old + ms) / 2 // Simplified if s.latencyMs.CompareAndSwap(old, newAvg) { break } } }

func (s *Stats) Snapshot() map[string]int64 { return map[string]int64{ "requests": s.requests.Load(), "errors": s.errors.Load(), "latencyMs": s.latencyMs.Load(), } } ```

Step 3: Use channels for goroutine communication

```go // Instead of sharing state, pass messages type WorkerPool struct { jobs chan Job results chan Result done chan struct{} }

func NewWorkerPool(size int) *WorkerPool { return &WorkerPool{ jobs: make(chan Job, 100), results: make(chan Result, 100), done: make(chan struct{}), } }

func (wp *WorkerPool) Start(workers int) { for i := 0; i < workers; i++ { go func() { for job := range wp.jobs { result := process(job) wp.results <- result } }() } }

// No shared state -- all communication through channels // No race conditions possible ```

Prevention

  • Run tests with -race flag: go test -race ./...
  • Enable race detection in CI pipelines for all test runs
  • Use sync.Map for read-heavy concurrent maps (but prefer RWMutex for most cases)
  • Use atomic package for simple counters and flags
  • Prefer channels over shared memory for goroutine communication
  • Document which fields of a struct are safe for concurrent access
  • Use go vet to catch common concurrency issues before runtime