Композиция атомиков в Go
Атомарная операция в многозадачной программе — отличная штука. Такая операция превращается в единственную инструкцию процессора, поэтому не требует блокировок. Ее можно безопасно вызывать из разных горутин и получить предсказуемый результат.
Но что будет, если злоупотреблять атомиками? Давайте разбираться.
Атомарность
Посмотрим на функцию, которая увеличивает счетчик:
var counter int32
func increment() {
counter += 1
// случайный сон до 10 мс
sleep(10)
counter += 1
}
Если вызвать 100 раз в одной горутине:
for i := 0; i < 100; i++ {
increment()
}
то counter
гарантированно будет равен 200.
В многозадачной среде increment()
небезопасна из-за гонок на counter += 1
. Сейчас я попробую это исправить и предложу несколько вариантов.
В каждом случае ответьте на вопрос: если вызвать increment()
из 100 горутин, гарантировано ли итоговое значение counter
?
Пример 1
var counter atomic.Int32
func increment() {
counter.Add(1)
sleep(10)
counter.Add(1)
}
Гарантировано ли значение counter
?
Ответ
Гарантировано.
Пример 2
var counter atomic.Int32
func increment() {
if counter.Load()%2 == 0 {
sleep(10)
counter.Add(1)
} else {
sleep(10)
counter.Add(2)
}
}
Гарантировано ли значение counter
?
Ответ
Не гарантировано.
Пример 3
var delta atomic.Int32
var counter atomic.Int32
func increment() {
delta.Add(1)
sleep(10)
counter.Add(delta.Load())
}
Гарантировано ли значение counter
?
Ответ
Не гарантировано.
Композиция атомарных операций
Люди иногда думают, что композиция атомиков волшебным образом тоже становится атомарной операцией. Но это не так.
Например, второй пример из рассмотренных выше:
var counter atomic.Int32
func increment() {
if counter.Load()%2 == 0 {
sleep(10)
counter.Add(1)
} else {
sleep(10)
counter.Add(2)
}
}
Вызываем 100 раз из разных горутин:
var wg sync.WaitGroup
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
increment()
wg.Done()
}()
}
wg.Wait()
fmt.Println(counter.Load())
Запускаем с флагом -race
, гонок нет. Но можно ли гарантировать, какое значение будет у counter
в результате? Нет.
% go run atomic-2.go
192
% go run atomic-2.go
191
% go run atomic-2.go
189
Несмотря на отсутствие гонок, increment()
в целом — не атомарная.
Проверьте себя, ответив на вопрос: в каком примере increment()
— атомарная операция?
Ответ
Ни в одном.
Атомарность и коммутативность
Во всех примерах increment()
— не атомарная операция. Композиция атомиков всегда не атомарна.
Но первый пример при этом гарантирует итоговое значение counter
в многозадачной среде:
var counter atomic.Int32
func increment() {
counter.Add(1)
sleep(10)
counter.Add(1)
}
Если запустить 100 горутин, counter
в итоге будет равен 200 (если в процессе выполнения не было ошибок).
Причина в том, что Add()
— коммутативная операция (точнее, sequence-independent, но это на русский нормально не переводится, поэтому пусть будет «коммутативная»). Такие операции можно выполнять в любом порядке, результат при этом не изменится.
Второй и третий примеры используют некоммутативные операции. Когда мы запускаем 100 горутин, порядок выполнения операций получается каждый раз разный. Поэтому отличается и результат.
В общем, несмотря на кажущуюся простоту атомиков, используйте их осторожно. Мьютекс, конечно, подубовее будет, но зато и надежнее.
★ Подписывайтесь на новые заметки.