Детектор гонок данных
Введение
Гонки данных являются одним из самых распространённых и трудных для отладки типов ошибок в параллельных системах. Гонка данных происходит, когда две горутины одновременно обращаются к одной и той же переменной, при этом хотя бы одно из обращений является записью. Подробнее смотрите в Модели памяти Go.
Вот пример гонки данных, которая может привести к сбоям и повреждению памяти:
func main() {
c := make(chan bool)
m := make(map[string]string)
go func() {
m["1"] = "a" // Первое конфликтующее обращение.
c <- true
}()
m["2"] = "b" // Второе конфликтующее обращение.
<-c
for k, v := range m {
fmt.Println(k, v)
}
}
Использование
Для использования детектора гонок данных необходимо включить флаг -race при сборке программы:
go run -race main.go
Или при сборке:
go build -race main.go
Случайно разделяемая переменная
// ParallelWrite записывает данные в file1 и file2, возвращает ошибки.
func ParallelWrite(data []byte) chan error {
res := make(chan error, 2)
f1, err := os.Create("file1")
if err != nil {
res <- err
} else {
go func() {
// Эта err разделяется с главной горутиной,
// поэтому записьRace с записью ниже.
_, err = f1.Write(data)
res <- err
f1.Close()
}()
}
f2, err := os.Create("file2") // Вторая конфликтующая запись в err.
if err != nil {
res <- err
} else {
go func() {
_, err = f2.Write(data)
res <- err
f2.Close()
}()
}
return res
}
Исправление состоит в том, чтобы ввести новые переменные внутри горутин (обратите внимание на использование :=):
... _, err := f1.Write(data) ... _, err := f2.Write(data) ...
Незащищенная глобальная переменная
Если следующий код вызывается из нескольких горутин, то это приводит к гонкам на карте service.
Одновременные чтения и записи одной и той же карты не являются безопасными:
var service map[string]net.Addr
func RegisterService(name string, addr net.Addr) {
service[name] = addr
}
func LookupService(name string) net.Addr {
return service[name]
}
Чтобы сделать код безопасным, защитите обращения с помощью мьютекса:
var (
service map[string]net.Addr
serviceMu sync.Mutex
)
func RegisterService(name string, addr net.Addr) {
serviceMu.Lock()
defer serviceMu.Unlock()
service[name] = addr
}
func LookupService(name string) net.Addr {
serviceMu.Lock()
defer serviceMu.Unlock()
return service[name]
}
Незащищенная примитивная переменная
Гонки данных также могут происходить на переменных примитивных типов (bool, int, int64 и т.д.), как в этом примере:
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
w.last = time.Now().UnixNano() // Первое конфликтующее обращение.
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
// Второе конфликтующее обращение.
if w.last < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
Даже такие «безобидные» гонки могут привести к трудноотлаживаемым проблемам, вызванным небезопасностью операций с памятью, влиянием оптимизаций компилятора, или проблемами переупорядочивания обращений к памяти процессора.
Типичное исправление для этой гонки — использовать канал или мьютекс.
Чтобы сохранить поведение без блокировок, можно также использовать пакет
sync/atomic.
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
atomic.StoreInt64(&w.last, time.Now().UnixNano())
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
Несинхронизированные операции отправки и закрытия
Как показывает этот пример, несинхронизированные операции отправки и закрытия на одном и том же канале также могут быть условием гонки:
c := make(chan struct{}) // или буферизованный канал
// Детектор гонок не может вывести отношение "происходит до"
// для следующих операций отправки и закрытия. Эти две операции
// не синхронизированы и происходят одновременно.
go func() { c <- struct{}{} }()
close(c)
Согласно модели памяти Go, отправка в канал происходит до соответствующего получения из этого канала завершено. Чтобы синхронизировать операции отправки и закрытия, используйте операцию получения, которая гарантирует, что отправка завершена до закрытия:
c := make(chan struct{}) // или буферизованный канал
go func() { c <- struct{}{} }()
<-c
close(c)
Требования
Детектор гонок требует включения cgo, а на системах, отличных от Darwin,
требуется установленный компилятор C.
Детектор гонок поддерживает
linux/amd64, linux/ppc64le,
linux/arm64, linux/s390x,
linux/loong64, freebsd/amd64,
netbsd/amd64, darwin/amd64,
darwin/arm64, и windows/amd64.
В Windows, среда выполнения детектора гонок чувствительна к
версии установленного компилятора C; начиная с Go 1.21, сборка
программы с флагом -race требует компилятор C,
включающий версию 8 или более позднюю библиотек mingw-w64.
Вы можете проверить ваш компилятор C, вызвав его
с аргументами
--print-file-name libsynchronization.a. Более новая
совместимая версия компилятора C напечатает полный путь к этой библиотеке,
в то время как старые компиляторы C просто выведут аргумент.
Накладные расходы времени выполнения
Стоимость обнаружения гонок зависит от программы, но для типичной программы использование памяти может увеличиться в 5-10 раз, а время выполнения — в 2-20 раз.
Детектор гонок в настоящее время выделяет дополнительные 8 байт на каждую инструкцию defer
и recover. Эти дополнительные выделения не освобождаются до завершения горутины.
Это означает, что если у вас есть долгоживущая горутина, которая периодически
выполняет вызовы defer и recover,
использование памяти программы может расти без ограничений. Эти выделения памяти
не будут отображаться в выводе runtime.ReadMemStats или
runtime/pprof.