вторник, 21 января 2020 г.

Состояния гонки в Golang

Состояния гонки (Race conditions) являются одними из самых коварных и неуловимых ошибок программирования. Как правило, они вызывают ошибочные и загадочные сбои, часто после того, как код был запущен в работу. Хотя механизмы конкурентности Go упрощают написание чистого конкурентного кода, они не предотвращают состояние гонки. Требуется осторожность, усердие и тестирование. И инструменты могут помочь.

Начиная с версии 1.1 Go включает в себя детектор гонки, инструмент для определения состояний гонки в коде Go. В настоящее время он доступен для систем Linux, OS X и Windows с 64-разрядными процессорами x86.

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

Как это устроено

Детектор гонки интегрирован с цепочкой инструментов 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

Примеры

Вот два примера реальных проблем, обнаруженных детектором гонки.

Пример 1: Timer.Reset

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

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

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

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

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

==================
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
==================

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

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

11 func main() {
12     start := time.Now()
13     reset := make(chan bool)
14     var t *time.Timer
15     t = time.AfterFunc(randomDuration(), func() {
16         fmt.Println(time.Now().Sub(start))
17         reset <- true
18     })
19     for time.Since(start) < 5*time.Second {
20         <-reset
21         t.Reset(randomDuration())
22     }
23 }

Здесь main процедура полностью отвечает за установку и сброс таймера t, а новый канал reset сообщает о необходимости сброса таймера потокобезопасным способом.

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

Пример 2: ioutil.Discard

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

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

io.Copy(ioutil.Discard, reader)

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

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

io.Copy(writer, reader)

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

writer.ReadFrom(reader)

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

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

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

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

var blackHole [4096]byte // разделяемый буфер

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 и записывает хэш-дайджест того, что он читает.

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 при его чтении:

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. При одновременном использовании нескольких файлов хэширования, каждый из которых использует один и тот же буфер blackHole, состояние гонки проявляется в повреждении данных между чтением и хэшированием. Не было ошибок или паники, но хэши были неправильными.

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

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

Выводы

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


Читайте также:


Комментариев нет:

Отправить комментарий