The Go Blog

Введение в детектор гонок в Go

Дмитрий Вьюков и Эндрю Герранд
26 июня 2013

Введение

Гонки являются одними из самых подвохов и непредсказуемых ошибок программирования. Они обычно вызывают нестабильную и загадочную работу программы, часто даже после того, как код был раз deployed в продакшн. Хотя механизмы параллелизма в Go позволяют легко писать чистый конкурентный код, они не предотвращают гонки. Для этого требуется внимание, тщательность и тестирование. И инструменты могут помочь.

Мы рады объявить о том, что Go 1.1 включает в себя детектор гонок, новый инструмент для выявления гонок в коде на Go. Он доступен на Linux, OS X и Windows системах с 64-битными процессорами x86.

Детектор гонок основан на библиотеке среды выполнения ThreadSanitizer из C/C++, которая использовалась для обнаружения множества ошибок в внутреннем коде Google и в Chromium. Технология была интегрирована с Go в сентябре 2012 года; с тех пор она обнаружила 42 гонки в стандартной библиотеке. Сейчас она является частью нашего непрерывного процесса сборки, где она продолжает выявлять гонки по мере их возникновения.

Как это работает

Детектор гонок интегрирован с инструментарием go. Когда передается флаг командной строки -race, компилятор добавляет код, который отслеживает все обращения к памяти, запоминая, когда и как осуществлялся доступ к памяти, в то время как библиотека среды выполнения наблюдает за несинхронизированными обращениями к общим переменным. Когда такое «гоняющее» поведение обнаруживается, выводится предупреждение. (См. эту статью для подробностей алгоритма.)

Из-за своей архитектуры детектор гонок может обнаружить гонку только тогда, когда она действительно срабатывает во время выполнения кода, что означает важность запуска бинарных файлов с включённым детектором под реалистичной нагрузкой. Однако бинарные файлы с включённым детектором могут использовать в десять раз больше CPU и памяти, поэтому постоянно включать детектор гонок нецелесообразно. Один из способов решения этой проблемы — запускать некоторые тесты с включённым детектором. Нагрузочные и интеграционные тесты являются хорошими кандидатами, поскольку они часто затрагивают конкурентные части кода. Другой подход — использовать рабочую нагрузку в продакшене, развертывая один экземпляр с включённым детектором среди множества работающих серверов.

Использование детектора гонок

Детектор гонок полностью интегрирован с инструментарием Go. Чтобы скомпилировать ваш код с включённым детектором гонок, просто добавьте флаг -race в командную строку:

<code>$ go test -race mypkg     // протестировать пакет
$ go run -race mysrc.go   // скомпилировать и запустить программу
$ go build -race mycmd    // скомпилировать команду
$ go install -race mypkg  // установить пакет
</code>

Чтобы самостоятельно попробовать детектор гонок, скопируйте следующий пример в файл racy.go:

<code>package main
import "fmt"
func main() {
  done := make(chan bool)
  m := make(map[string]string)
  m["name"] = "world"
  go func() {
    m["name"] = "data race"
    done <- true
  }()
  fmt.Println("Hello,", m["name"])
  <-done
}
</code>

Затем запустите его с включённым детектором гонок:

<code>$ go run -race racy.go
</code>

Примеры

Вот два примера реальных проблем, которые были найдены с помощью детектора гонок.

Пример 1: Timer.Reset

Первый пример — упрощённая версия реальной ошибки, найденной с помощью детектора гонок. Он использует таймер для вывода сообщения через случайный промежуток времени между 0 и 1 секундой. Это повторяется в течение пяти секунд. Он использует time.AfterFunc для создания Timer для первого сообщения, а затем использует метод Reset для планирования следующего сообщения, при этом повторно используя тот же Timer каждый раз.

<span>
package main
import (
  "fmt"
  "math/rand"
  "time"
)
</span>
<span class="number">10  </span>func main() {
  <span class="number">11  </span>    start := time.Now()
  <span class="number">12  </span>    var t *time.Timer
  <span class="number">13  </span>    t = time.AfterFunc(randomDuration(), func() {
    <span class="number">14  </span>        fmt.Println(time.Now().Sub(start))
    <span class="number">15  </span>        t.Reset(randomDuration())
    <span class="number">16  </span>    })
    <span class="number">17  </span>    time.Sleep(5 * time.Second)
    <span class="number">18  </span>}
    <span class="number">19  </span>
    <span class="number">20  </span>func randomDuration() time.Duration {
      <span class="number">21  </span>    return time.Duration(rand.Int63n(1e9))
      <span class="number">22  </span>}
      <span class="number">23  </span>

Код выглядит разумно, но при определённых обстоятельствах он завершается неожиданным образом:

<code>panic: 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
</code>

Что здесь происходит? Запуск программы с включённым детектором гонок даёт более ясную картину:

<code>==================
WARNING: DATA RACE
Read by goroutine 5:
main.func·001()
race.go:16 +0x169
Previous write by goroutine 1:
main.main()
race.go:14 +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
==================
</code>

Детектор гонок показывает проблему: несинхронизированное чтение и запись переменной t из разных горутин. Если начальный интервал таймера очень мал, функция таймера может сработать до того, как главная горутина присвоит значение переменной t, и поэтому вызов t.Reset происходит с nil t.

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

<span>
package main
import (
  "fmt"
  "math/rand"
  "time"
)
</span>
<span class="number">10  </span>func main() {
  <span class="number">11  </span>    start := time.Now()
  <span class="number">12  </span>    reset := make(chan bool)
  <span class="number">13  </span>    var t *time.Timer
  <span class="number">14  </span>    t = time.AfterFunc(randomDuration(), func() {
    <span class="number">15  </span>        fmt.Println(time.Now().Sub(start))
    <span class="number">16  </span>        reset <- true
    <span class="number">17  </span>    })
    <span class="number">18  </span>    for time.Since(start) < 5*time.Second {
      <span class="number">19  </span>        <-reset
      <span class="number">20  </span>        t.Reset(randomDuration())
      <span class="number">21  </span>    }
      <span class="number">22  </span>}
      <span class="number">23  </span>
<span>
func randomDuration() time.Duration {
  return time.Duration(rand.Int63n(1e9))
}
</span>

В данном случае главная горутина полностью отвечает за установку и сброс таймера t, а новый канал reset обеспечивает безопасный способ уведомления о необходимости сброса таймера.

Более простой, но менее эффективный подход — это избегать повторного использования таймеров.

Пример 2: ioutil.Discard

Второй пример более тонкий.

Объект Discard из пакета ioutil реализует интерфейс io.Writer, но отбрасывает все данные, записываемые в него. Представьте себе /dev/null: место, куда можно отправлять данные, которые нужно прочитать, но не сохранять. Он часто используется с io.Copy для опустошения читателя, например:

<code>io.Copy(ioutil.Discard, reader)
</code>

В июле 2011 года команда Go заметила, что использование Discard таким образом было неэффективным: функция Copy выделяет внутренний буфер размером 32 кБ каждый раз при вызове, но при использовании с Discard этот буфер становится избыточным, поскольку мы просто отбрасываем прочитанные данные. Мы считали, что такая идиоматическая конструкция с Copy и Discard не должна быть такой затратной.

Исправление было простым. Если заданный Writer реализует метод ReadFrom, то вызов Copy, подобный следующему:

<code>io.Copy(writer, reader)
</code>

делегируется потенциально более эффективному вызову:

<code>writer.ReadFrom(reader)
</code>

Мы добавили метод ReadFrom к базовому типу Discard, который имеет внутренний буфер, разделяемый между всеми его пользователями. Мы знали, что это теоретически является состоянием гонки, но поскольку все записи в буфер должны быть отброшены, мы не считали это важным.

Когда был реализован детектор гонок, он сразу же пометил этот код как содержащий гонку. Опять же, мы считали, что код может быть проблематичным, но решили, что состояние гонки не является «реальным». Чтобы избежать «ложноположительного» результата в сборке, мы реализовали версию без гонок, которая включается только тогда, когда запущен детектор гонок.

Но несколько месяцев спустя Брэд столкнулся с досадной и странный баг. После нескольких дней отладки он сузил проблему до реального состояния гонки, вызванного ioutil.Discard.

Вот код с известной гонкой в io/ioutil, где Discard — это devNull, который разделяет один буфер между всеми своими пользователями.

var blackHole [4096]byte <span class="comment">// shared buffer</span>
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 и записывает хеш-дайджест прочитанных данных.

<code>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
}
</code>

Например, его можно использовать для вычисления хеша SHA-1 файла во время его чтения:

<code>tdr := trackDigestReader{r: file, h: sha1.New()}
io.Copy(writer, tdr)
fmt.Printf("File hash: %x", tdr.h.Sum(nil))
</code>

В некоторых случаях данные могли быть негде записать — но всё ещё требовалось хешировать файл — и поэтому использовался Discard:

<code>io.Copy(ioutil.Discard, tdr)
</code>

Но в этом случае буфер blackHole не просто чёрная дыра; это допустимое место для хранения данных между чтением их из исходного io.Reader и записью в hash.Hash. При одновременном хешировании файлов несколькими горутинами, каждая из которых использует один и тот же буфер blackHole, состояние гонки проявилось коррупцией данных между чтением и хешированием. Ошибок или паник не происходило, но хеши были неверны. Ужасно!

<code>func (t trackDigestReader) Read(p []byte) (n int, err error) {
  // буфер p является blackHole
  n, err = t.r.Read(p)
  // p может быть испорчен другой горутиной здесь,
  // между Read выше и Write ниже
  t.h.Write(p[:n])
  return
}
</code>

Ошибка была наконец-то исправлена путем предоставления уникального буфера для каждого использования ioutil.Discard, что устранило состояние гонки на общем буфере.

Заключение

Детектор гонок — это мощный инструмент для проверки корректности параллелизированных программ. Он не выдает ложноположительных результатов, поэтому следует серьезно относиться к его предупреждениям. Но он работает так же хорошо, как и ваши тесты; необходимо убедиться, что они тщательно проверяют параллельные свойства вашего кода, чтобы детектор гонок мог выполнить свою работу.

Что вы ждете? Запустите "go test -race" на вашем коде уже сегодня!

Следующая статья: Первая программа на Go
Предыдущая статья: Go и Google Cloud Platform
Индекс блога

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang