Гонки данных в Go: разбираем опасный пример с map

Гонки данных в Go

Перед вами код на Go, в котором скрыта серьезная, но неочевидная проблема. Попробуйте предсказать, что будет выведено в результате его выполнения:

package main

import (
    "fmt"
    "sync"
)

var globalMap = map[string][]int{
    "test": make([]int, 0), 
    "test2": make([]int, 0),
    "test3": make([]int, 0),
}

var a = 0

func main() {
    wg := sync.WaitGroup{}
    wg.Add(3)

    go func() {
        wg.Done()
        a = 10
        globalMap["test"] = append(globalMap["test"], a)
    }()

    go func() {
        wg.Done()
        a = 11
        globalMap["test2"] = append(globalMap["test2"], a)
    }()

    go func() {
        wg.Done()
        a = 12
        globalMap["test3"] = append(globalMap["test3"], a)
    }()

    wg.Wait()
    fmt.Printf("Map: %v\n", globalMap)
    fmt.Printf("Value of a: %d\n", a)
}

На первый взгляд кажется, что программа должна вывести:

Map: map[test:[10] test:[11] test3:[12]]
Value of a: 12

Но реальность оказывается сложнее. Давайте разберемся, почему этот код ведет себя непредсказуемо и как это исправить.

Почему результат непредсказуем?

Проблема 1: Неверный порядок wg.Done()

Ключевая ошибка — вызов wg.Done() происходит перед выполнением полезной работы:

go func() {
    wg.Done() // сигнализируем о завершении
    a = 10    // но работа еще не выполнена!
    globalMap["test"] = append(globalMap["test"], a)
}()

Что происходит: Функция main() получает сигнал о том, что горутина «завершилась», еще до того, как та обновила переменную а или мапу. Это может привести к тому, что fmt.Printf выполнится до того, как горутины закончат свою работу.

Проблема 2: Гонка данных за переменной а

Все 3 горутины записывают в одну и ту же глобальную переменную а. Порядок выполнения горутин не гарантирован, поэтому возможны различные сценарии:

// сценарий 1: все идет "по плану"
Горутина 1: a = 10, append(..., 10)
Горутина 2: a = 11, append(..., 11)
Горутина 3: a = 12, append(..., 12)

// сценарий 2: перезапись значений
Горутина 1: a = 10
Горутина 2: a = 11 // перезаписывает!
Горутина 1: append(..., 11) // опа! использует не то значение
Горутина 3: а = 12
Горутина 2: append(..., 12) // и здесь тоже

Проблема 3: Конкурентный доступ к map

Мапы в Go не являются потокобезопасными для одновременных операций записи. Даже если каждая горутина работает с разными ключом, внутренняя структура мапы может быть повреждена при конкурентном доступе.

Решения: от простого к сложному

Решение 1: Исправляем порядок выполнения

Самое простое исправление — использовать defer для wg.Done() и локальные переменные:

go func() {
    defer wg.Done() // гарантированно вызовется при выходе из функции
    localA := 10    // локальная переменная - нет гонки данных
    globalMap["test"] = append(globalMap["test"], localA)
}()

Преимущества:

  • Устраняет гонку за переменную a
  • Гарантирует, что wg.Done() вызовется только после выполнения всей работы.

Недостатки:

  • Не решает проблему конкурентного доступа к мапе

Решение 2: Добавляем синхронизацию

Для полной безопасности нужно синхронизировать доступ к разделяемым ресурсам:

var mu sync.Mutex

func main() {
    wg := sync.WaitGroup{}
    wg.Add(3)

    go func() {
        defer wg.Done()
        mu.Lock()
        defer mu.Unlock()
        a = 10
        globalMap["test"] = append(globalMap["test"], a)
    }()
    // ... аналогично для других горутин
}

Важно: Хотя мьютексы решают проблему гонок, они могут стать узким местом при высокой нагрузке.

Решение 3: Идиоматичный Go с каналами

Каналы — предпочтительный способ коммуникации между горутинами в Go:

    results := make(chan struct{key string; value int}, 3)

    // горутины отправляют результаты
    go func() {
        results <- struct{key string; value int}{"test", 10}
    }()

    go func() {
        results <- struct{key string; value int}{"test", 11}
    }()

    go func() {
        results <- struct{key string; value int}{"test", 12}
    }()

    // основная горутина собирает результаты
    for i := 0; i < 3; i++ {
        r := <-results
        globalMap[r.key] = append(globalmap[r.key], r.value)
    }

    fmt.Printf("Map: %v\n", globalMap) // теперь результат предсказуем

Преимущества:

  • Нет разделяемого изменяемого состояния
  • Естественная для Go идиома
  • Легко масштабируется

Практические рекомендации

  1. Всегда используйте defer wg.Done() — это защищает от ошибок порядка выполнения.
  2. Избегайте глобальных переменных — предпочитайте передачу параметров или использование каналов.
  3. Запускайте тесты с -race — добавляйте это в CI/CD пайплан.
  4. Помните про sync.Map — для высоконагруженных сценариев с частным чтениями.

Что выведет исправленный код?

После применения любого из решений программа будет стабильно выводить:

Map: map[test:[10 test2:[11] test3:[12]]
Value of a: 0 // или последнее установленное значение, в зависимости от решения

Ключевой вывод

Гонки данных — коварные ошибки, которые могут долго оставаться незамеченными проявляясь только под нагрузкой или специфических условиях. Код из примера демонстрирует 3 типичные ошибки при работе с горутинами, которые важно распознать и избегать.

Есть ли в ваших проектах код, который может содержать скрытые гонки данных? Попробуйте запустить go test -race ./... и поделитесь результатами в комментариях.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: