HardТеория6 min

Гонки данных

Data race vs race condition, детектор гонок, типичные ошибки и способы их исправления

Гонки данных (Race Conditions)

Data Race vs Race Condition

Это два разных понятия, которые часто путают:

Data Race -- одновременный доступ к переменной из нескольких горутин, где хотя бы одна операция -- запись, без синхронизации. Это ошибка программы -- неопределённое поведение.

Race Condition -- логическая ошибка, когда результат зависит от непредсказуемого порядка выполнения горутин. Программа может не иметь data race, но всё равно быть некорректной.

Data Race:          Два потока одновременно пишут/читают переменную без синхронизации
Race Condition:     Логика программы зависит от порядка выполнения потоков

Data Race ⊂ Race Condition:  Data race — частный случай race condition

Пример Data Race

package main

import (
    "fmt"
    "sync"
)

func main() {
    counter := 0
    var wg sync.WaitGroup

    for range 1000 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // DATA RACE: unsynchronized read-modify-write
        }()
    }

    wg.Wait()
    fmt.Println("Counter:", counter) // Unpredictable: could be 980, 995, 1000, etc.
}

Операция counter++ -- это три шага:

  1. Прочитать значение counter (READ)
  2. Увеличить на 1 (MODIFY)
  3. Записать обратно (WRITE)
Горутина A:  READ(0)  ─────────────────►  MODIFY(1)  ─►  WRITE(1)
Горутина B:       READ(0)  ─►  MODIFY(1)  ─►  WRITE(1)
                                                          ↑
                                                    Потеряно: должно быть 2

Пример Race Condition (без Data Race)

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    balance := 100

    // Check-then-act pattern — classic race condition
    withdraw := func(amount int) bool {
        mu.Lock()
        hasEnough := balance >= amount
        mu.Unlock()
        // WINDOW OF VULNERABILITY: another goroutine can change balance here

        if hasEnough {
            mu.Lock()
            balance -= amount // This may overdraw!
            mu.Unlock()
            return true
        }
        return false
    }

    var wg sync.WaitGroup
    // Two goroutines try to withdraw 80 from balance of 100
    for range 2 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if withdraw(80) {
                fmt.Println("Withdrawn 80")
            } else {
                fmt.Println("Insufficient funds")
            }
        }()
    }

    wg.Wait()
    fmt.Println("Final balance:", balance) // Could be -60!
}

Исправление: Проверка и действие должны быть в одной критической секции:

func withdrawSafe(mu *sync.Mutex, balance *int, amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    if *balance >= amount {
        *balance -= amount
        return true
    }
    return false
}

Детектор гонок (Race Detector)

Go имеет встроенный детектор гонок, основанный на ThreadSanitizer (TSan) от Google. Он обнаруживает data race во время выполнения программы.

Использование

# Run program with race detector
go run -race main.go

# Run tests with race detector
go test -race ./...

# Build with race detector (for integration testing)
go build -race -o myapp .

# Run benchmarks with race detector
go test -race -bench=. ./...

Как работает детектор

Детектор гонок инструментирует каждый доступ к памяти и отслеживает happens-before отношения. Если два доступа к одной переменной происходят без happens-before связи и хотя бы один -- запись, фиксируется data race.

Издержки детектора гонок:
┌──────────────────────────────────────┐
│  Память:     +5-10x                  │
│  Скорость:   +2-20x медленнее        │
│  Размер:     Бинарник значительно    │
│              больше                   │
└──────────────────────────────────────┘

Вывод детектора гонок

==================
WARNING: DATA RACE
Read at 0x00c000014088 by goroutine 7:
  main.main.func1()
      /path/to/main.go:15 +0x38

Previous write at 0x00c000014088 by goroutine 6:
  main.main.func1()
      /path/to/main.go:15 +0x50

Goroutine 7 (running) created at:
  main.main()
      /path/to/main.go:13 +0x5c

Goroutine 6 (finished) created at:
  main.main()
      /path/to/main.go:13 +0x5c
==================

Детектор показывает:

  • Какие операции конфликтуют (Read/Write)
  • Где в коде (файл:строка)
  • Какие горутины участвуют
  • Где горутины были созданы

Классические гонки в Go

1. Гонка в замыканиях циклов (до Go 1.22)

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    // Go < 1.22: classic race — all goroutines see i == 5
    // Go >= 1.22: FIXED — each iteration has its own i
    for i := range 5 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i) // Go 1.22+: each goroutine gets its own copy
        }()
    }

    wg.Wait()
}

Важно: Начиная с Go 1.22, переменная цикла for создаётся заново на каждой итерации. В Go < 1.22 все горутины захватывали одну и ту же переменную, и нужно было передавать её как аргумент.

// Fix for Go < 1.22:
for i := range 5 {
    wg.Add(1)
    go func(i int) { // pass i as argument — each goroutine gets its own copy
        defer wg.Done()
        fmt.Println(i)
    }(i)
}

2. Гонка с map

Конкурентные чтение и запись в map -- это фатальная ошибка в Go. Рантайм обнаруживает это и паникует.

package main

import "sync"

func main() {
    m := make(map[string]int)
    var wg sync.WaitGroup

    // FATAL: concurrent map writes
    for i := range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            key := "key"
            m[key] = i // PANIC: concurrent map writes
        }()
    }

    wg.Wait()
}
// fatal error: concurrent map writes

Исправление через Mutex:

package main

import (
    "fmt"
    "sync"
)

func main() {
    m := make(map[string]int)
    var mu sync.Mutex
    var wg sync.WaitGroup

    for i := range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            m["key"] = i
            mu.Unlock()
        }()
    }

    wg.Wait()

    mu.Lock()
    fmt.Println("Final value:", m["key"])
    mu.Unlock()
}

3. Гонка со слайсами

package main

import (
    "fmt"
    "sync"
)

func main() {
    // DATA RACE: concurrent append to same slice
    var results []int
    var wg sync.WaitGroup

    for i := range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // append is NOT safe for concurrent use:
            // it reads len, cap, may reallocate
            results = append(results, i) // DATA RACE
        }()
    }

    wg.Wait()
    fmt.Println("Length:", len(results)) // Unpredictable
}

Исправления:

// Fix 1: Mutex
var mu sync.Mutex
mu.Lock()
results = append(results, i)
mu.Unlock()

// Fix 2: Pre-allocate (each goroutine writes to its own index)
results := make([]int, 100)
for i := range 100 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        results[i] = i * i // SAFE: unique index per goroutine
    }()
}

// Fix 3: Channel
ch := make(chan int, 100)
// goroutines send to ch
// main collects from ch

4. Гонка с интерфейсами

package main

import (
    "fmt"
    "sync"
)

type Config struct {
    Host string
    Port int
}

func main() {
    var current any // interface{} — two words: type + pointer
    var wg sync.WaitGroup

    // DATA RACE: interface value is two machine words,
    // cannot be atomically read/written
    wg.Add(2)
    go func() {
        defer wg.Done()
        for range 10000 {
            current = &Config{Host: "localhost", Port: 8080}
        }
    }()
    go func() {
        defer wg.Done()
        for range 10000 {
            current = "string value"
        }
    }()

    wg.Wait()
    fmt.Println(current)
}

Объяснение: Значение интерфейса в Go -- это два машинных слова (указатель на тип + указатель на данные). Запись двух слов не атомарна. При конкурентной записи горутина-читатель может увидеть указатель на тип от одного значения и указатель на данные от другого -- memory corruption.

5. Гонка с time.After в горячем цикле

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 100)
    go func() {
        for i := range 100 {
            ch <- i
        }
        close(ch)
    }()

    // BAD: time.After creates a new timer on EACH iteration
    // These timers are not GC'd until they fire → memory leak
    for {
        select {
        case val, ok := <-ch:
            if !ok {
                return
            }
            fmt.Println(val)
        case <-time.After(5 * time.Second): // MEMORY LEAK in hot loop
            fmt.Println("Timeout")
            return
        }
    }
}

Исправление:

// Use time.NewTimer and Reset
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
    select {
    case val, ok := <-ch:
        if !ok {
            return
        }
        fmt.Println(val)
        // Reset timer
        if !timer.Stop() {
            select {
            case <-timer.C:
            default:
            }
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        fmt.Println("Timeout")
        return
    }
}

Способы исправления гонок

┌────────────────────────────────────────────────────────────────┐
│                 Способы исправления гонок                      │
├─────────────────┬──────────────────────────────────────────────┤
│  sync.Mutex     │  Самый простой, для защиты любого состояния  │
│  sync.RWMutex   │  Когда чтений >> записей                    │
│  sync/atomic    │  Для простых типов (счётчики, флаги)         │
│  Каналы         │  Для передачи данных между горутинами        │
│  Иммутабельность│  Не изменяй — не будет гонки                │
│  Confinement    │  Данные доступны только одной горутине        │
└─────────────────┴──────────────────────────────────────────────┘

Confinement (Ограничение доступа)

Самый безопасный способ -- сделать так, чтобы данные были доступны только одной горутине:

package main

import (
    "fmt"
    "sync"
)

func main() {
    data := make([]int, 100)
    var wg sync.WaitGroup

    // Each goroutine owns its slice segment — no sharing, no race
    chunkSize := 25
    for i := range 4 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            start := i * chunkSize
            end := start + chunkSize
            // Each goroutine works on its own segment
            for j := start; j < end; j++ {
                data[j] = j * j // SAFE: exclusive ownership
            }
        }()
    }

    wg.Wait()
    fmt.Println("First 10:", data[:10])
}

Иммутабельность

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// Config is immutable — fields are read-only after creation
type Config struct {
    Host    string
    Port    int
    Debug   bool
}

func main() {
    // Use atomic.Pointer for immutable config replacement
    var configPtr atomic.Pointer[Config]
    configPtr.Store(&Config{Host: "localhost", Port: 8080, Debug: false})

    var wg sync.WaitGroup

    // Writers create NEW config, store atomically
    wg.Add(1)
    go func() {
        defer wg.Done()
        newCfg := &Config{Host: "production.com", Port: 443, Debug: false}
        configPtr.Store(newCfg) // atomic pointer swap
    }()

    // Readers load atomically — always see consistent config
    for range 10 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            cfg := configPtr.Load() // atomic pointer load
            fmt.Printf("Host: %s, Port: %d\n", cfg.Host, cfg.Port)
        }()
    }

    wg.Wait()
}

Тестирование на гонки

Флаг -count для повторного запуска

# Run tests 100 times with race detector to catch intermittent races
go test -race -count=100 ./...

# Run specific test function
go test -race -run TestConcurrentAccess -count=50 ./...

Параллельные тесты

package main

import (
    "sync"
    "testing"
)

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

func NewSafeMap() *SafeMap {
    return &SafeMap{items: make(map[string]int)}
}

func (m *SafeMap) Set(key string, val int) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.items[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    v, ok := m.items[key]
    return v, ok
}

func TestSafeMapConcurrent(t *testing.T) {
    m := NewSafeMap()
    var wg sync.WaitGroup

    // Concurrent writers
    for i := range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            m.Set("key", i)
        }()
    }

    // Concurrent readers
    for range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            m.Get("key")
        }()
    }

    wg.Wait()
}

Ограничения детектора гонок

┌──────────────────────────────────────────────────────────────────┐
│  Ограничения Race Detector                                      │
├──────────────────────────────────────────────────────────────────┤
│  1. Обнаруживает гонки только в ВЫПОЛНЕННОМ коде                │
│  2. Не может доказать ОТСУТСТВИЕ гонок                          │
│  3. Значительные накладные расходы (5-10x память, 2-20x CPU)    │
│  4. Не обнаруживает гонки на уровне бизнес-логики               │
│     (race condition без data race)                              │
│  5. Не работает с CGO (ограниченно)                             │
│  6. Не обнаруживает гонки, если код не выполнялся               │
│     (нужна хорошая тестовая покрытость)                          │
└──────────────────────────────────────────────────────────────────┘

Рекомендация: Включайте -race во все тесты в CI/CD. Это должен быть обязательный шаг. Также полезно запускать тесты с -count=10 или выше для ловли нестабильных гонок.

Проверь себя

Какая проблема с переменными цикла for была исправлена в Go 1.22?

Что произойдёт при конкурентной записи в map в Go?

Какой способ предотвращения гонок самый безопасный?

На каком инструменте основан детектор гонок Go?

Чем data race отличается от race condition?

Связанные темы