Перед вами код на 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()
- Проблема 2: Гонка данных за переменной а
- Проблема 3: Конкурентный доступ к map
- Решения: от простого к сложному
- Решение 1: Исправляем порядок выполнения
- Решение 2: Добавляем синхронизацию
- Решение 3: Идиоматичный Go с каналами
- Практические рекомендации
- Что выведет исправленный код?
- Ключевой вывод
Почему результат непредсказуем?
Проблема 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 идиома
- Легко масштабируется
Практические рекомендации
- Всегда используйте
defer wg.Done()— это защищает от ошибок порядка выполнения. - Избегайте глобальных переменных — предпочитайте передачу параметров или использование каналов.
- Запускайте тесты с
-race— добавляйте это в CI/CD пайплан. - Помните про
sync.Map— для высоконагруженных сценариев с частным чтениями.
Что выведет исправленный код?
После применения любого из решений программа будет стабильно выводить:
Map: map[test:[10 test2:[11] test3:[12]] Value of a: 0 // или последнее установленное значение, в зависимости от решения
Ключевой вывод
Гонки данных — коварные ошибки, которые могут долго оставаться незамеченными проявляясь только под нагрузкой или специфических условиях. Код из примера демонстрирует 3 типичные ошибки при работе с горутинами, которые важно распознать и избегать.
Есть ли в ваших проектах код, который может содержать скрытые гонки данных? Попробуйте запустить
go test -race ./...и поделитесь результатами в комментариях.