Race Detection in Golang

Race Detection in Golang

Race conditions occur when multiple goroutines access shared data concurrently without proper synchronization, leading to unpredictable behavior. Go’s race detector is a powerful tool for identifying these issues. This tutorial covers how to use the race detector and fix race conditions.

What is a Race Condition?

A race condition happens when the behavior of a program depends on the relative timing of operations in concurrent goroutines.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var wg sync.WaitGroup
    
    // Start 1000 goroutines that increment counter
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // Race condition!
        }()
    }
    
    wg.Wait()
    fmt.Printf("Final counter: %d (expected: 1000)\n", counter)
}

Run with race detection:

go run -race main.go

Output:

==================
WARNING: DATA RACE
Write at 0x00c0000a4010 by goroutine 8:
  main.main.func1()
      /path/to/main.go:13 +0x3a

Previous read at 0x00c0000a4010 by goroutine 7:
  main.main.func1()
      /path/to/main.go:13 +0x2e

Goroutine 8 (running) created at:
  main.main()
      /path/to/main.go:11 +0x88

Goroutine 7 (finished) created at:
  main.main()
      /path/to/main.go:9 +0x68
==================
Final counter: 987 (expected: 1000)

Using the Race Detector

Enabling Race Detection

# Run with race detection
go run -race program.go

# Test with race detection
go test -race ./...

# Build with race detection
go build -race -o program

# Run built binary (race detection included)
./program

Race Detector Options

# Set race detector log path
go run -race -log_path=/tmp/race.log main.go

# Set race detector timeout (default 1 second)
go run -race -race_timeout=10s main.go

Common Race Condition Patterns

1. Shared Variables Without Synchronization

type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++ // Race condition!
}

func (c *Counter) Get() int {
    return c.value // Race condition!
}

Fixed version:

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

2. Slice and Map Access

var data []int

func addToSlice(value int) {
    data = append(data, value) // Race condition!
}

func readSlice() []int {
    return data // Race condition!
}

Fixed version:

var (
    data []int
    mu   sync.RWMutex
)

func addToSlice(value int) {
    mu.Lock()
    defer mu.Unlock()
    data = append(data, value)
}

func readSlice() []int {
    mu.RLock()
    defer mu.RUnlock()
    // Return a copy to prevent external modification
    result := make([]int, len(data))
    copy(result, data)
    return result
}

3. Global Variables

var globalConfig map[string]string

func setConfig(key, value string) {
    if globalConfig == nil {
        globalConfig = make(map[string]string)
    }
    globalConfig[key] = value // Race condition!
}

Fixed version:

var (
    globalConfig map[string]string
    configMu     sync.RWMutex
)

func setConfig(key, value string) {
    configMu.Lock()
    defer configMu.Unlock()
    
    if globalConfig == nil {
        globalConfig = make(map[string]string)
    }
    globalConfig[key] = value
}

func getConfig(key string) string {
    configMu.RLock()
    defer configMu.RUnlock()
    
    return globalConfig[key]
}

Synchronization Primitives

Mutex (Mutual Exclusion)

type SafeMap struct {
    mu   sync.Mutex
    data map[string]int
}

func (sm *SafeMap) Set(key string, value int) {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    sm.data[key] = value
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    value, exists := sm.data[key]
    return value, exists
}

RWMutex (Read-Write Mutex)

type SafeMap struct {
    mu   sync.RWMutex
    data map[string]int
}

func (sm *SafeMap) Set(key string, value int) {
    sm.mu.Lock() // Exclusive lock for writing
    defer sm.mu.Unlock()
    sm.data[key] = value
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.mu.RLock() // Shared lock for reading
    defer sm.mu.RUnlock()
    value, exists := sm.data[key]
    return value, exists
}

Atomic Operations

For simple operations, use atomic package:

import "sync/atomic"

type Counter struct {
    value int64
}

func (c *Counter) Increment() {
    atomic.AddInt64(&c.value, 1)
}

func (c *Counter) Get() int64 {
    return atomic.LoadInt64(&c.value)
}

Channels for Synchronization

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        // Process job
        result := job * 2
        results <- result
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)
    
    // Start workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }
    
    // Send jobs
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)
    
    // Collect results
    for r := 1; r <= 9; r++ {
        <-results
    }
}

Advanced Race Detection

Detecting Race Conditions in Tests

func TestConcurrentAccess(t *testing.T) {
    var counter int
    var wg sync.WaitGroup
    
    // Run test with race detector
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // Race condition will be detected
        }()
    }
    
    wg.Wait()
}

Run test:

go test -race -v

Race Detection in Benchmarks

func BenchmarkConcurrent(b *testing.B) {
    var counter int64
    
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            atomic.AddInt64(&counter, 1) // Safe
            // counter++ would cause race condition
        }
    })
}

Race Detection with HTTP Servers

type Server struct {
    mu      sync.RWMutex
    data    map[string]string
    counter int
}

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    s.counter++ // Protected by mutex
    s.mu.Unlock()
    
    s.mu.RLock()
    value := s.data[r.URL.Query().Get("key")]
    s.mu.RUnlock()
    
    fmt.Fprintf(w, "Counter: %d, Value: %s\n", s.counter, value)
}

Common Race Condition Fixes

1. Interface Compliance

// Race condition in interface implementation
type Writer interface {
    Write(data []byte)
}

type FileWriter struct {
    file *os.File
}

func (fw *FileWriter) Write(data []byte) {
    fw.file.Write(data) // Race condition if file is shared
}

// Fixed with mutex
type SafeFileWriter struct {
    mu   sync.Mutex
    file *os.File
}

func (sfw *SafeFileWriter) Write(data []byte) {
    sfw.mu.Lock()
    defer sfw.mu.Unlock()
    sfw.file.Write(data)
}

2. Singleton Pattern

// Race condition in singleton
var instance *Config
var initialized bool

func GetInstance() *Config {
    if !initialized {
        instance = &Config{}
        initialized = true // Race condition!
    }
    return instance
}

// Fixed with sync.Once
var instance *Config
var once sync.Once

func GetInstance() *Config {
    once.Do(func() {
        instance = &Config{}
    })
    return instance
}

3. Lazy Initialization

type LazyStruct struct {
    mu     sync.Mutex
    data   map[string]string
    loaded bool
}

func (ls *LazyStruct) GetData() map[string]string {
    ls.mu.Lock()
    defer ls.mu.Unlock()
    
    if !ls.loaded {
        ls.data = loadData() // Expensive operation
        ls.loaded = true
    }
    
    // Return a copy to prevent external modification
    result := make(map[string]string)
    for k, v := range ls.data {
        result[k] = v
    }
    return result
}

Race Detection Best Practices

1. Always Use Race Detector in Tests

# GitHub Actions workflow
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: '1.21'
    - run: go test -race -v ./...

2. Test with Multiple Goroutines

func TestConcurrentOperations(t *testing.T) {
    const numGoroutines = 100
    const numOperations = 1000
    
    var wg sync.WaitGroup
    safeMap := &SafeMap{data: make(map[string]int)}
    
    // Start multiple goroutines
    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < numOperations; j++ {
                key := fmt.Sprintf("key-%d-%d", id, j)
                safeMap.Set(key, j)
                safeMap.Get(key)
            }
        }(i)
    }
    
    wg.Wait()
}

3. Use Stress Testing

func TestStress(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping stress test in short mode")
    }
    
    var counter int64
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 10000; j++ {
                atomic.AddInt64(&counter, 1)
            }
        }()
    }
    
    wg.Wait()
    
    expected := int64(1000 * 10000)
    if counter != expected {
        t.Errorf("Expected %d, got %d", expected, counter)
    }
}

4. Profile Race Conditions

// Add race detector to benchmarks
func BenchmarkWithRace(b *testing.B) {
    // This will detect races during benchmarking
    b.RunParallel(func(pb *testing.PB) {
        // Benchmark code
    })
}

Performance Considerations

Race Detector Overhead

The race detector adds significant overhead:

  • 2-20x slower execution
  • 5-10x more memory usage
  • Only use in development/testing

When to Disable Race Detection

// In performance-critical code, disable race detector
//go:build !race

package main

// Code that runs only when race detector is disabled

Selective Race Detection

func init() {
    // Skip race detection for this package if needed
    if race.Enabled {
        // Alternative implementation
    }
}

Troubleshooting Race Conditions

1. False Positives

Sometimes the race detector reports false positives:

var initialized bool
var mu sync.Mutex

func initOnce() {
    mu.Lock()
    defer mu.Unlock()
    
    if !initialized {
        // Initialization code
        initialized = true
    }
}

The race detector might flag initialized even with mutex protection. Use sync.Once:

var once sync.Once

func initOnce() {
    once.Do(func() {
        // Initialization code
    })
}

2. Race Detector Limitations

  • Not all races are detected (especially complex scenarios)
  • False negatives possible
  • Only detects races that occur during execution

3. Common Pitfalls

// Pitfall: Lock in wrong order (deadlock potential)
type AB struct {
    muA, muB sync.Mutex
}

func (ab *AB) Method1() {
    ab.muA.Lock()
    ab.muB.Lock() // Lock A then B
    // ...
    ab.muB.Unlock()
    ab.muA.Unlock()
}

func (ab *AB) Method2() {
    ab.muB.Lock()
    ab.muA.Lock() // Lock B then A - potential deadlock!
    // ...
    ab.muA.Unlock()
    ab.muB.Unlock()
}

Fix: Always lock in the same order

func (ab *AB) Method1() {
    ab.muA.Lock()
    ab.muB.Lock()
    // ...
    ab.muB.Unlock()
    ab.muA.Unlock()
}

func (ab *AB) Method2() {
    ab.muA.Lock() // Always lock A first
    ab.muB.Lock()
    // ...
    ab.muB.Unlock()
    ab.muA.Unlock()
}

Advanced Topics

Context and Cancellation

func worker(ctx context.Context, jobs <-chan int) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return
            }
            // Process job
        case <-ctx.Done():
            // Context cancelled
            return
        }
    }
}

Race-Free Patterns

Worker Pool Pattern

type WorkerPool struct {
    workers int
    jobs    chan func()
}

func NewWorkerPool(workers int) *WorkerPool {
    wp := &WorkerPool{
        workers: workers,
        jobs:    make(chan func(), workers*2),
    }
    
    for i := 0; i < workers; i++ {
        go wp.worker()
    }
    
    return wp
}

func (wp *WorkerPool) worker() {
    for job := range wp.jobs {
        job() // Execute job
    }
}

func (wp *WorkerPool) Submit(job func()) {
    wp.jobs <- job
}

func (wp *WorkerPool) Shutdown() {
    close(wp.jobs)
}

Publish-Subscribe Pattern

type Publisher struct {
    mu      sync.RWMutex
    subs    map[string][]chan string
}

func (p *Publisher) Subscribe(topic string) <-chan string {
    p.mu.Lock()
    defer p.mu.Unlock()
    
    ch := make(chan string, 10)
    p.subs[topic] = append(p.subs[topic], ch)
    return ch
}

func (p *Publisher) Publish(topic, message string) {
    p.mu.RLock()
    subs := make([]chan string, len(p.subs[topic]))
    copy(subs, p.subs[topic])
    p.mu.RUnlock()
    
    // Send to subscribers without holding lock
    for _, ch := range subs {
        select {
        case ch <- message:
        default:
            // Subscriber slow, skip
        }
    }
}

External Resources:

Related Tutorials:

Last updated on