Конкурентность в 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 все будет хорошо, так как там 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())
}
Чтобы быть уверенным, в том что 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())
}
И снова проверяем, остались ли у нас "гонки":
$ go run -race mutex.go
1000000
Все отлично работает, "гонок" нет.
Заключение
Когда мы пишем многопоточное приложение:
- Нужно помнить, что теперь программа работает не последовательно
- Нужно быть осторожным при синхронизации данных между go-рутинами
- Необходимо использовать каналы, мьютексы и атомарные счетчики
- Для поиска гонок необходимо использовать встроенные инструменты языка,
-race
ваш друг - Не помешало бы реализовать приведенные выше пример счетчика с помощью каналов
Что дальше?
Если вам понравился это материал, то вы можете почитать мою серию статей для начинающих