Вперед — Состояние расы: Обнаружение и предотвращение


Состояние гонки возникает, когда два или более объекта (процесс, угроза, goroutine и т.д.) одновременно обращаются к одному и тому же месту в памяти, и хотя бы один из этих обращений является записью.

Давайте разберем быстрый и простой пример

package main

import (
    "fmt"
    "sync"
)

const (
    stepCount    = 100000
    routineCount = 2
)

var counter int64

func main() {
    var wg sync.WaitGroup

    for i := 0; i < routineCount; i++ {
    wg.Add(1)
        go incr(&wg)
    }

    wg.Wait() // wait until all goroutines executed
    fmt.Printf("Step Count: %dnLastValue: %dnExpected: %dn", stepCount, counter, stepCount*routineCount)
}

func incr(wg *sync.WaitGroup) {
    for i := 0; i < stepCount; i++ {
        counter++
    }

    wg.Done()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Функция incr отвечает за увеличение счетчика как значения stepCount. В данном примере у нас есть 2 горутины, каждая из которых увеличивает счетчик в 100000 раз. Тогда мы ожидаем увидеть 200000 (routineCount x stepCount) в значении переменной counter.

Давайте посмотрим на вывод кода 🙃

Step Count: 100000
LastValue : 192801
Expected  : 200000
Вход в полноэкранный режим Выйти из полноэкранного режима

Упс, мы получили 192801 вместо 200000 😮 . Но почему? Что не так?

Критический раздел

Прежде чем разбираться с проблемой, мы должны знать, что такое критическая секция. Критическая секция — это сегмент кода, который не должен выполняться несколькими процессами одновременно. Только один процесс/программа может выполняться в критической секции, остальные должны ждать своей очереди. В противном случае результат будет таким же, как и в предыдущем случае.

Проанализируйте проблему

Теперь мы знаем, что такое критическая секция. Давайте рассмотрим проблему. У нас есть 2 одинаковые процедуры, которые увеличивают значение счетчика, применяя следующие шаги:

1. Read value of the counter
2. Add 1 to counter
3. Store increased value in counter
Войти в полноэкранный режим Выход из полноэкранного режима

Критическая секция состоит из этих двух шагов. Рутины не должны выполнять эти шаги одновременно.

Представим сценарий:

Routine 1: Read value of the counter (counter = 12)
Routine 2: Read value of the counter (counter = 12)
Routine 1: Add 1 to counter (12 + 1 = 13)
Routine 1: Store increased value in counter (counter = 13)
Routine 2: Add 1 to counter (12 + 1 = 13)
Routine 2: Store increased value in counter (counter = 13)
Войти в полноэкранный режим Выход из полноэкранного режима

Упс, две программы выполняют некоторые шаги одновременно. Хотя две программы увеличивают счетчик на 1, что означает, что мы ожидаем увеличения счетчика на 2, счетчик увеличивается только на 1. Вот почему мы получили неожиданный ответ на выходе. Маршрутизация 2 должна была дождаться выполнения критической секции маршрутизации 1. (Состояние гонки 🙋)

Как обнаружить?

Если код написан на языке go, то вам повезло. В Golang есть внутренний инструмент детектора гонок (его не нужно устанавливать явно), который написан на C/C++ с использованием библиотеки времени выполнения ThreadSanitizer. Инструмент следит за несинхронизированными доступами к общим переменным. Если он обнаруживает какой-либо случай состояния гонки, то печатает предупреждение. Пожалуйста, будьте осторожны при использовании этого инструмента, не запускайте его в продакшене. Он может потреблять в десять раз больше процессора и памяти.

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

Как использовать инструмент Race-Detector?

Не нужно ничего устанавливать. Он полностью интегрирован в цепочку инструментов Go. Просто добавьте флаг -race при компиляции/запуске вашего приложения.

$ go test -race mypkg    // test the package
$ go run -race mysrc.go  // compile and run the program
$ go build -race mycmd   // build the command
$ go install -race mypkg // install the package
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте запустим его для нашего пикантного кода.

$ go run -race main.go

==================
WARNING: DATA RACE
Read at 0x000001279320 by goroutine 8:
  main.incr()
      main.go:29 +0x47

Previous write at 0x000001279320 by goroutine 7:
  main.incr()
      main.go:29 +0x64

Goroutine 8 (running) created at:
  main.main()
      main.go:20 +0xc4

Goroutine 7 (running) created at:
  main.main()
      main.go:20 +0xc4
==================
Step Count: 100000
LastValue : 192801
Expected : 200000

Found 1 data race(s)
exit status 66
Войти в полноэкранный режим Выход из полноэкранного режима

Результат показывает, что произошла рассинхронизация событий переменной counter из разных подпрограмм. Мы рассмотрим решение в следующем разделе.

Другими моментами для предотвращения и обнаружения условий гонки являются

  • квалифицированный анализ кода
  • проектирование и моделирование приложений, которые используют как можно меньше общих ресурсов
  • повышение уровня знаний о подобных ситуациях
  • модульные тесты для параллельных задач

Как справиться?

Пока мы не знаем, мы поняли проблему и обнаружили ошибку. Давайте ее исправим!

Использование мьютекса

Мьютекс (взаимное исключение) — это механизм блокировки/разблокировки критических секций. Если он заблокирован, то критическая секция зарезервирована для одной горутины, другие должны ждать разблокировки. В нашем коде мы должны заблокировать код, который увеличивает счетчик. Другие горутины не должны иметь возможности увеличить счетчик, если одна горутина уже работает над ним.

package main

import (
    "fmt"
    "sync"
)

const (
    stepCount    = 100000
    routineCount = 2
)

var counter int64

func main() {
    var wg sync.WaitGroup
    var mx sync.Mutex // initialize mutex

    for i := 0; i < routineCount; i++ {
    wg.Add(1)
        go incr(&wg, &mx) // pass mutex to each routine
    }

    wg.Wait() // wait until all goroutines executed
    fmt.Printf("Step Count: %dnLastValue: %dnExpected: %dn", stepCount, counter, stepCount*routineCount)
}

func incr(wg *sync.WaitGroup, mx *sync.Mutex) {
    for i := 0; i < stepCount; i++ {
        mx.Lock() // lock critical section for this routine
        counter++  // critical section
        mx.Unlock() // unlock critical section then other routines can use it.
    }

    wg.Done()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование каналов

Согласно документу go;

Каналы — это трубы, которые соединяют параллельные горутины. Вы можете отправлять значения в каналы из одной программы и получать эти значения в другой программе.

Фактически это простая труба. В этом сценарии мы можем использовать буферизованные каналы с пропускной способностью 1 для синхронизации наших горутин. Это означает, что канал принимает только одни данные и не принимает новые данные, пока не прочитает текущие.

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

package main

import (
    "fmt"
    "sync"
)

const (
    stepCount    = 100000
    routineCount = 2
)

var counter int64

func main() {
    var wg sync.WaitGroup
    ch := make(chan struct{}, 1) // define buffered channel 

    for i := 0; i < routineCount; i++ {
        wg.Add(1)
        go incr(&wg, ch)
    }

    wg.Wait() // wait until all goroutines executed
    fmt.Printf("Step Count: %dnLastValue: %dnExpected: %dn", stepCount, counter, stepCount*routineCount)
}

func incr(wg *sync.WaitGroup, ch chan struct{}) {
    ch <- struct{}{} // send empty struct into channel to block other routines.
    for i := 0; i < stepCount; i++ {
        counter++
    }
    <- ch // clear out the channel

    wg.Done()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Использование пакета Atomic

Атомарные функции не нуждаются в блокировке, они реализованы на аппаратном уровне. Если производительность действительно важна для вас, пакет atomic может быть использован для создания приложения без блокировок. Но вы или ваша команда должны знать, как работают атомарные функции in-behind. Например, атомарные переменные должны управляться только атомарными функциями. Не читайте и не записывайте их как классические переменные.

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

const (
    stepCount    = 100000
    routineCount = 2
)

var counter int64

func main() {
    var wg sync.WaitGroup

    for i := 0; i < routineCount; i++ {
        wg.Add(1)
        go incr(&wg)
    }

    wg.Wait() // wait until all goroutines executed
    fmt.Printf("Step Count: %dnLastValue: %dnExpected: %dn", stepCount, counter, stepCount*routineCount)
}

func incr(wg *sync.WaitGroup) {
    for i := 0; i < stepCount; i++ {
        atomic.AddInt64(&counter, 1) // use atomic function to increase counter
    }

    wg.Done()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Оцените статью
Procodings.ru
Добавить комментарий