Конкурентность в Go -‘гонки’

Перевод статьи "Golang concurrency - data races"

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

Начальные условия

Чтобы убедиться, что у вас все правильно работает, вам нужно будет запустить примеры кода на машине с несколькими ядрами и значением GOMAXPROCS больше 1 (иначе не будет двух или более одновременно работающих go-рутин). Стоит отметить, что в Go версии выше 1.5, значение GOMAXPROCS автоматически равно количеству ядер.

Пример 1 - "гонки"

В примере, приведенном ниже, мы реализуем простой счетчик в основе которого инкремент целого значения.

Затем добавим 100 go-рутин, каждая из которых будет инкрементировать счетчик 10 000 раз, что в результате должно дать нам значение в 1 000 000.

package main

import (
    "fmt"
    "time"
)

type intCounter int64

func (c *intCounter) Add(x int64) {
    *c++
}

func (c *intCounter) Value() (x int64) {
    return int64(*c)
}

func main() {
    counter := intCounter(0)

    for i := 0; i < 100; i++ {
        go func(no int) {
            for i := 0; i < 10000; i++ {
                counter.Add(1)
            }
        }(i)
    }

    time.Sleep(time.Second)
    fmt.Println(counter.Value())

}

Запустить на play.golang.org

Давайте запустим наш пример (запускайте пример на своей машина, в песочнице play.golang.org все будет хорошо, так как там GOMAXPROCS установлен в 1)

❯ go run counter.go
248863

Что произошло? Ведь мы должны были получить результат равный 1 000 000. Вот это и называется "гонками"(data race).

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

go run -race app.go

И в результате вы увидите что-то такое:

❯ go run -race app.go >> out.txt
==================
WARNING: DATA RACE
Read by goroutine 7:
  main.main.func1()
      /home/exu/src/github.com/exu/go-workshops/101-concurrency-other/app.go:24 +0x42

Previous write by goroutine 6:
  main.main.func1()
      /home/exu/src/github.com/exu/go-workshops/101-concurrency-other/app.go:24 +0x58

Goroutine 7 (running) created at:
  main.main()
      /home/exu/src/github.com/exu/go-workshops/101-concurrency-other/app.go:26 +0x92

Goroutine 6 (running) created at:
  main.main()
      /home/exu/src/github.com/exu/go-workshops/101-concurrency-other/app.go:26 +0x92
==================
Found 1 data race(s)
exit status 66

Да-да! Go может обнаруживать "гонки" автоматически, не забывайте пользоваться детектором гонок когда вы работаете с множеством go-рутин. Такие ошибки достаточно сложно воспроизвести и они могут проскакивать на продакшен, поэтому почаще пишите тесты.

И так, мы обнаружили "гонки". Что дальше? Давайте попробуем исправить их. Есть несколько подходов к этому, но основное правило очень простое - синхронизируйте ваши данные.

Пример 2 - Атомарные счетчики

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

package main

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

type atomicCounter struct {
    val int64
}

func (c *atomicCounter) Add(x int64) {
    atomic.AddInt64(&c.val, x)
    runtime.Gosched()
}

func (c *atomicCounter) Value() int64 {
    return atomic.LoadInt64(&c.val)
}

func main() {
    counter := atomicCounter{}

    for i := 0; i < 100; i++ {
        go func(no int) {
            for i := 0; i < 10000; i++ {
                counter.Add(1)
            }
        }(i)
    }

    time.Sleep(time.Second)
    fmt.Println(counter.Value())
}

Запустить на play.golang.org

Чтобы быть уверенным, в том что go-рутина не простаивает, мы используем явное "выталкивание"" с помощью runtime.Gosched() после каждой операции. Такое "выталкивание"" происходит автоматически при работе с channel или при блокирующих вызовах, таких как time.Sleep. Но в нашем конкретном случае мы сами должны позаботиться об этом сами.

Теперь наш счетчик потокобезопасный. Сейчас вы можете проверить, остались ли у вас "гонки":

$ go run -race atomic.go
1000000

Ура! Мы победи "гонки"!

Пример 3 - Мьютексы

Теперь мы можем попробовать исправить наш пример с помощью мьютексов, которые предоставляются вместе со стандартной библиотекой в пакете sync. Использование атомарных счетчиков и выталкивание с помощью runtime.Gosched выглядит не очень красиво. Использование mutex - это более правильный подход.

Нам нужно будет немного изменить код:

package main

import (
    "fmt"
    "sync"
    "time"
)

type mutexCounter struct {
    mu sync.Mutex
    x  int64
}

func (c *mutexCounter) Add(x int64) {
    c.mu.Lock()
    c.x += x
    c.mu.Unlock()
}

func (c *mutexCounter) Value() (x int64) {
    c.mu.Lock()
    x = c.x
    c.mu.Unlock()
    return
}

func main() {
    counter := mutexCounter{}

    for i := 0; i < 100; i++ {
        go func(no int) {
            for i := 0; i < 10000; i++ {
                counter.Add(1)
            }
        }(i)
    }

    time.Sleep(time.Second)
    fmt.Println(counter.Value())

}

Запустить на play.golang.org

И снова проверяем, остались ли у нас "гонки":

$ go run -race mutex.go
1000000

Все отлично работает, "гонок" нет.

Заключение

Когда мы пишем многопоточное приложение:

  • Нужно помнить, что теперь программа работает не последовательно
  • Нужно быть осторожным при синхронизации данных между go-рутинами
  • Необходимо использовать каналы, мьютексы и атомарные счетчики
  • Для поиска гонок необходимо использовать встроенные инструменты языка, -race ваш друг
  • Не помешало бы реализовать приведенные выше пример счетчика с помощью каналов

Что дальше?

Если вам понравился это материал, то вы можете почитать мою серию статей для начинающих

updatedupdated2021-03-062021-03-06