Гонки данных (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++ -- это три шага:
- Прочитать значение counter (READ)
- Увеличить на 1 (MODIFY)
- Записать обратно (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или выше для ловли нестабильных гонок.