1. go
  2. /concurrency
  3. /race-conditions

Understanding and Preventing Race Conditions in Go

Race conditions occur when multiple goroutines access shared resources without proper synchronization. This guide covers everything you need to know about detecting and preventing race conditions in Go.

Understanding Race Conditions

What is a Race Condition?

package main

import (
    "fmt"
    "sync"
)

func main() {
    // Example of a race condition
    counter := 0
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // Race condition: unsynchronized access
        }()
    }
    
    wg.Wait()
    fmt.Println("Counter:", counter) // Result is unpredictable
}

Types of Race Conditions

  1. Data Race

    • Multiple goroutines accessing same memory location
    • At least one goroutine is writing
    • No synchronization between accesses
  2. Check-Then-Act Race

    if value != nil {
        value.Method() // Race: value could be nil here
    }
    
  3. Read-Modify-Write Race

    x = x + 1 // Race: read and write not atomic
    

Detecting Race Conditions

Using the Race Detector

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

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

# Build with race detection
go build -race

Example Race Detection Output

package main

import (
    "fmt"
    "sync"
)

func main() {
    data := make(map[string]string)
    var wg sync.WaitGroup
    
    wg.Add(2)
    go func() {
        defer wg.Done()
        data["key"] = "value1" // Race: unsynchronized map access
    }()
    
    go func() {
        defer wg.Done()
        fmt.Println(data["key"]) // Race: concurrent read
    }()
    
    wg.Wait()
}

Preventing Race Conditions

1. Using Mutexes

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

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

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

2. Using Channels

type Counter struct {
    ch chan int
    value int
}

func NewCounter() *Counter {
    c := &Counter{
        ch: make(chan int),
    }
    go func() {
        for {
            c.value += <-c.ch
        }
    }()
    return c
}

func (c *Counter) Increment() {
    c.ch <- 1
}

3. Using sync/atomic

import "sync/atomic"

type AtomicCounter struct {
    value atomic.Int64
}

func (c *AtomicCounter) Increment() {
    c.value.Add(1)
}

func (c *AtomicCounter) Value() int64 {
    return c.value.Load()
}

Best Practices

1. Prefer Channels for Communication

func worker(jobs <-chan int, results chan<- int) {
    for job := range jobs {
        results <- process(job)
    }
}

// Usage
jobs := make(chan int, 100)
results := make(chan int, 100)
go worker(jobs, results)

2. Use sync.Once for One-time Initialization

type Config struct {
    once sync.Once
    data *Data
}

func (c *Config) LoadData() *Data {
    c.once.Do(func() {
        c.data = loadDataFromDB()
    })
    return c.data
}

3. Implement Clear Ownership

type DataStore struct {
    mu   sync.RWMutex
    data map[string]interface{}
}

func (ds *DataStore) Get(key string) interface{} {
    ds.mu.RLock()
    defer ds.mu.RUnlock()
    return ds.data[key]
}

func (ds *DataStore) Set(key string, value interface{}) {
    ds.mu.Lock()
    defer ds.mu.Unlock()
    ds.data[key] = value
}

Common Race Conditions

1. Map Access

// Wrong: Concurrent map access
func wrong() {
    m := make(map[string]string)
    go func() {
        m["key"] = "value"
    }()
    go func() {
        fmt.Println(m["key"])
    }()
}

// Right: Use sync.Map or mutex
var m sync.Map
go func() {
    m.Store("key", "value")
}()
go func() {
    value, _ := m.Load("key")
    fmt.Println(value)
}()

2. Slice Access

// Wrong: Concurrent slice access
func wrong() {
    s := make([]int, 0)
    go func() {
        s = append(s, 1)
    }()
    go func() {
        s = append(s, 2)
    }()
}

// Right: Use channels or mutex
type SafeSlice struct {
    sync.Mutex
    data []int
}

func (s *SafeSlice) Append(value int) {
    s.Lock()
    defer s.Unlock()
    s.data = append(s.data, value)
}

3. Lazy Initialization

// Wrong: Race in lazy initialization
var instance *Singleton
func GetInstance() *Singleton {
    if instance == nil {
        instance = &Singleton{}
    }
    return instance
}

// Right: Use sync.Once
var (
    instance *Singleton
    once     sync.Once
)

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

Testing for Race Conditions

1. Write Race-Sensitive Tests

func TestConcurrentAccess(t *testing.T) {
    counter := NewSafeCounter()
    var wg sync.WaitGroup
    
    // Launch multiple goroutines
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }
    
    wg.Wait()
    
    if counter.Value() != 1000 {
        t.Errorf("Expected 1000, got %d", counter.Value())
    }
}

2. Stress Testing

func TestStressCounter(t *testing.T) {
    counter := NewSafeCounter()
    done := make(chan bool)
    
    // Multiple writers
    for i := 0; i < 10; i++ {
        go func() {
            for j := 0; j < 1000; j++ {
                counter.Increment()
            }
            done <- true
        }()
    }
    
    // Wait for all writers
    for i := 0; i < 10; i++ {
        <-done
    }
    
    if counter.Value() != 10000 {
        t.Errorf("Race condition detected")
    }
}

Next Steps

  1. Learn about mutexes
  2. Explore atomic operations
  3. Study channels
  4. Practice with worker pools

Additional Resources