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

В общем, несмотря на кажущуюся простоту атомиков, используйте их осторожно. Мьютекс, конечно, подубовее будет, но зато и надежнее.

Подписывайтесь на канал, чтобы не пропустить новые заметки 🚀