Авторы: Дмитрий Вьюков, Эндрю Герран |Introducing the Go Race Detector
Предисловие переводчика
Третий перевод официального блога Go, в основном посвященный встроенному в Go инструменту определения состояния гонки. Это может эффективно помочь нам определить правильность параллельных программ. Его очень легко использовать, просто добавьте опцию -race к команде go.
В конце этой статьи представлены два реальных случая гонки Первый случай относительно прост. Основное внимание уделяется второму случаю, этот случай более сложный для понимания, основываясь на исходном тексте, я также вкратце добавил некоторые дополнения, не знаю, достаточно ли понятно объяснил задачу. В то же время этот случай также говорит нам о том, что мы должны обращать внимание на подсказки, данные нам детектором в любое время, потому что, если вы не будете осторожны, вы можете оставить себе большую дыру.
резюме
В процедурном миресостояние гонкиЭто глубокая и трудно обнаруживаемая ошибка, и когда такой код развертывается в сети, он часто приводит к загадочным результатам. Поддержка параллелизма в Go упрощает написание кода, поддерживающего параллелизм, но не предотвращает возникновение условий гонки.
В этой статье будет представлен инструмент, который поможет нам достичь этого.
В Go 1.1 добавлен новый инструмент, детектор гонок, который можно использовать для обнаружения условий гонок в программах Go. В настоящее время доступно для Linux, Mac или Windows, работающих на процессорах x86_64.
Реализация детектора гонок основана на C/C++.Библиотека времени выполнения ThreadSanitizer, ThreadSanitier в Google использовался в некоторых внутренних базовых библиотеках, а такжеChromium, и помогли найти много проблемного кода.
ThreadSanitier, технология, интегрированная в Go в сентябре 2012 года, помогла обнаружить 42 состояния гонки в стандартной библиотеке. Теперь он является частью процесса сборки Go и будет обнаруживать условия гонки, когда они возникают.
как работать
Детектор гонок интегрирован в тулчейн Go.При установленном флаге -race в командной строке компилятор будет фиксировать все обращения к памяти через код, когда и как к ним обращаются, а за мониторинг также будет отвечать библиотека времени выполнения общие переменные для асинхронного доступа. При обнаружении гонки будет напечатано предупреждающее сообщение. (Подробнее читайтестатья)
Такой дизайн приводит к тому, что обнаружение гонки может быть запущено только во время выполнения, что также означает, что очень важно запускать программу с поддержкой гонки в реальной среде, но потребление ЦП и памяти программой с поддержкой гонки обычно в десять раз выше. это обычная программа.Очень непрактично постоянно включать обнаружение гонки в реальной среде.
Вы почувствовали прохладное дыхание?
Вот несколько решений, которые можно попробовать. Например, мы можем запускать тесты с включенной гонкой, нагрузочные тесты и интеграционные тесты — хороший выбор, которые направлены на обнаружение возможных проблем параллелизма в коде. По-другому можно использовать балансировку нагрузки производственной среды для выбора службы для развертывания программы, запускающей обнаружение гонки.
начать использовать
Детектор гонки уже интегрирован в набор инструментов Go, и его можно включить, установив флаг -race. Пример командной строки выглядит следующим образом:
$ go test -race mypkg
$ go run -race mysrc.go
$ go build -race mycmd
$ go install -race mypkg
По опыту конкретного случая установите и запустите команду, шаги следующие:
$ go get -race golang.org/x/blog/support/racy
$ racy
Далее мы представляем 2 практических случая.
Случай 1: Таймер.Сброс
Это настоящий баг, обнаруженный детектором гонок, и вот его упрощенная версия. Мы используем таймер для печати сообщений через случайные промежутки времени (0-1 секунда), и таймер будет повторяться в течение 5 секунд.
Сначала создайте таймер через time.AfterFunc, интервал синхронизации получается из функции randomDuration, функция синхронизации печатает сообщение, а затем сбрасывает таймер с помощью метода Reset таймера для повторного использования.
func main() {
start := time.Now()
var t *time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
t.Reset(randomDuration())
})
time.Sleep(5 * time.Second)
}
func randomDuration() time.Duration {
return time.Duration(rand.Int63n(1e9))
}
С нашим кодом все в порядке. Но после многократного запуска мы обнаружим, что в некоторых конкретных случаях могут возникать следующие ошибки:
anic: runtime error: invalid memory address or nil pointer dereference
[signal 0xb code=0x1 addr=0x8 pc=0x41e38a]
goroutine 4 [running]:
time.stopTimer(0x8, 0x12fe6b35d9472d96)
src/pkg/runtime/ztime_linux_amd64.c:35 +0x25
time.(*Timer).Reset(0x0, 0x4e5904f, 0x1)
src/pkg/time/sleep.go:81 +0x42
main.func·001()
race.go:14 +0xe3
created by time.goFunc
src/pkg/time/sleep.go:122 +0x48
По какой причине? Давайте проверим это с включенным детектором гонок, и вы это поймете.
$ go run -race main.go
==================
WARNING: DATA RACE
Read by goroutine 5:
main.func·001()
race.go:14 +0x169
Previous write by goroutine 1:
main.main()
race.go:15 +0x174
Goroutine 5 (running) created at:
time.goFunc()
src/pkg/time/sleep.go:122 +0x56
timerproc()
src/pkg/runtime/ztime_linux_amd64.c:181 +0x189
==================
Результат показывает, что в программе есть 2 горутины, асинхронно читающие и записывающие переменную t. Если начальное время синхронизации очень короткое, может показаться, что основная функция еще не присвоила значение t, функция синхронизации была выполнена, а в это время t все еще равно нулю, и метод Reset не может быть вызван.
Нам просто нужно перенести чтение и запись переменной t в основную горутину для выполнения, и проблема может быть решена. следующее:
func main() {
start := time.Now()
reset := make(chan bool)
var t *time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
reset <- true
})
for time.Since(start) < 5*time.Second {
<-reset
t.Reset(randomDuration())
}
}
Основная горутина полностью отвечает за инициализацию и сброс таймера, а сигнал сброса передается по каналу.
Конечно, есть более простое и прямолинейное решение этой проблемы, просто избегайте повторного использования таймеров. Пример кода выглядит следующим образом:
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
start := time.Now()
var f func()
f = func() {
fmt.Println(time.Now().Sub(start))
time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
}
time.AfterFunc(time.Duration(rand.Int63n(1e9)), f)
time.Sleep(5 * time.Second)
}
Код очень лаконичен и понятен, минус в том, что эффективность относительно низкая.
Случай 2: ioutil.Discard
Проблема в данном случае прячется глубже.
Discard в пакете ioutil реализует интерфейс io.Writer, но отбрасывает все записанные в него данные, аналогично /dev/null. Его можно использовать в сценариях, когда нам нужно прочитать данные, но мы не готовы их сохранить. Он часто используется в сочетании с io.Copy для реализации средства чтения следующим образом:
io.Copy(ioutil.Discard, reader)
Еще в 2011 году, когда команда Go заметила, что использование Discard таким образом неэффективно, функция копирования выделяла 32 КБ кэш-буфера внутри при каждом вызове, но мы просто хотели отбросить прочитанные данные и не нуждались в этом. выделить дополнительный буфер. Мы считаем, что это идиоматическое использование не должно быть таким ресурсоемким.
Решение очень простое, если указанный Writer реализует метод ReadFrom, вызов io.Copy(writer, reader) внутренне делегирует работу по чтению для write.ReadFrom(reader) для выполнения.
Тип Discard добавляет метод ReadFrom для совместного использования буфера. В этот момент мы, естественно, думаем, что в теории будет состояние гонки, но поскольку данные, записанные в буфер, будут немедленно отброшены, мы не обращаем на это особого внимания.
После завершения работы детектора гонок этот код немедленно помечается как участвующий в гонках, см.issues/3970. Это заставило нас еще раз задуматься, а действительно ли проблема с этим кодом, но вывод все равно такой, что состояние гонки здесь никак не влияет на работу программы. Чтобы избежать этого «ложного предупреждения», мы реализуем 2 версии буфера black_hole, версию для гонок и версию без гонок. Версия без гонки будет включена только при включенном детекторе гонки.
black_hole.go, версия без гонок.
// +build race
package ioutil
// Replaces the normal fast implementation with slower but formally correct one.
func blackHole() []byte {
return make([]byte, 8192)
}
black_hole_race.go, гоночная версия.
// +build !race
package ioutil
var blackHoleBuf = make([]byte, 8192)
func blackHole() []byte {
return blackHoleBuf
}
Но через несколько месяцев,BradСтолкнулся с загадочной ошибкой. После нескольких дней отладки я, наконец, определил причину: проблема гонки, вызванная ioutil.Discard.
Фактический код выглядит следующим образом:
var blackHole [4096]byte // shared buffer
func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
readSize := 0
for {
readSize, err = r.Read(blackHole[:])
n += int64(readSize)
if err != nil {
if err == io.EOF {
return n, nil
}
return
}
}
}
Программа Брэда имеет тип trackDigestReader, который содержит поле типа io.Reader и хеш-дайджест информации в io.Reader.
type trackDigestReader struct {
r io.Reader
h hash.Hash
}
func (t trackDigestReader) Read(p []byte) (n int, err error) {
n, err = t.r.Read(p)
t.h.Write(p[:n])
return
}
Например, вычислите SHA-1 HASH файла.
tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))
В некоторых случаях, если данные для записи некуда, а вычислить хеш все равно нужно, можно использовать Discard.
io.Copy(ioutil.Discard, tdr)
Буфер blackHole на данный момент — это не просто черная дыра, это еще и связующее звено между io.Reader и hash.Hash для передачи данных. Когда несколько горутин одновременно выполняют хэш файла, все они совместно используют буфер, и данные между чтением и записью могут соответственно конфликтовать. Нет ошибок и нет паники, но результат хеширования неверен. Просто такой противный.
func (t trackDigestReader) Read(p []byte) (n int, err error) {
// the buffer p is blackHole
n, err = t.r.Read(p)
// p may be corrupted by another goroutine here,
// between the Read above and the Write below
t.h.Write(p[:n])
return
}
В конечном счете, предоставив каждому io.Discard уникальный буфер, мы решили проблему, устранив состояние гонки общего буфера. код показывает, как показано ниже:
var blackHoleBuf = make(chan []byte, 1)
func blackHole() []byte {
select {
case b := <-blackHoleBuf:
return b
default:
}
return make([]byte, 8192)
}
func blackHolePut(p []byte) {
select {
case blackHoleBuf <- p:
default:
}
}
Метод devNull ReadFrom в iouitl.go также был соответствующим образом исправлен.
func (devNull) ReadFrom(r io.Reader) (n int64, err error) {
buf := blackHole()
defer blackHolePut(buf)
readSize := 0
for {
readSize, err = r.Read(buf)
// other
}
Повторно отправить используемый буфер в blackHoleBuf через defer, потому что размер канала равен 1, и повторно использовать можно только один буфер. А с помощью оператора select мы создаем новый буфер, когда он недоступен.
в заключении
Детекторы гонок, очень мощный инструмент, играют важную роль в определении корректности параллельных программ. Он не дает ложных предупреждений, и крайне важно серьезно относиться к каждому из его предупреждений. Но это не панацея, и она все равно должна основываться на вашем правильном понимании характеристик параллелизма, чтобы по-настоящему проявить свою ценность.
Попробуй! Начните свою тестовую гонку.