Atomic operations composition in Go

An atomic operation in a concurrent program is a great thing. Such operation transforms into a single processor instruction, so it does not require locks. You can safely call it from different goroutines and receive a predictable result.

But what happens if you misuse atomics? Let's figure it out.

Atomicity

Let's look at a function that increments a counter:

var counter int32

func increment() {
    counter += 1
    // random sleep up to 10 ms
    sleep(10)
    counter += 1
}

If we call it 100 times in a single goroutine:

for i := 0; i < 100; i++ {
    increment()
}

then counter is guaranteed to equal 200.

In a concurrent environment, increment() is unsafe due to races on counter += 1. Now I will try to fix it and propose several options.

In each case, answer the question: if you call increment() from 100 goroutines, is the final value of the counter guaranteed?

Example 1

var counter atomic.Int32

func increment() {
    counter.Add(1)
    sleep(10)
    counter.Add(1)
}

Is the counter value guaranteed?

Answer

It is guaranteed.

Example 2

var counter atomic.Int32

func increment() {
    if counter.Load()%2 == 0 {
        sleep(10)
        counter.Add(1)
    } else {
        sleep(10)
        counter.Add(2)
    }
}

Is the counter value guaranteed?

Answer

It's not guaranteed.

Example 3

var delta atomic.Int32
var counter atomic.Int32

func increment() {
    delta.Add(1)
    sleep(10)
    counter.Add(delta.Load())
}

Is the counter value guaranteed?

Answer

It's not guaranteed.

Composition of Atomic Operations

People sometimes think that the composition of atomics also magically becomes an atomic operation. But this is not the case.

For example, the second of the above examples:

var counter atomic.Int32

func increment() {
    if counter.Load()%2 == 0 {
        sleep(10)
        counter.Add(1)
    } else {
        sleep(10)
        counter.Add(2)
    }
}

Call increment() 100 times from different goroutines:

var wg sync.WaitGroup
wg.Add(100)

for i := 0; i < 100; i++ {
    go func() {
        increment()
        wg.Done()
    }()

}

wg.Wait()
fmt.Println(counter.Load())

Run with the -race flag — there are no races. But can we guarantee what the counter value will be in the end? Nope.

% go run atomic-2.go
192
% go run atomic-2.go
191
% go run atomic-2.go
189

Despite the absence of races, increment() is not atomic.

Check yourself by answering the question: in which example is increment() an atomic operation?

Answer

In none of them.

Atomicity and Sequence-Independence

In all examples, increment() is not an atomic operation. The composition of atomics is always non-atomic.

The first example, however, guarantees the final value of the counter in a concurrent environment:

var counter atomic.Int32

func increment() {
    counter.Add(1)
    sleep(10)
    counter.Add(1)
}

If we run 100 goroutines, the counter will ultimately equal 200 (if there were no errors during execution).

The reason is that Add() is a sequence-independent operation. The runtime can perform such operations in any order, and the result will not change.

The second and third examples use sequence-dependent operations. When we run 100 goroutines, the order of operations is different each time. Therefore, the result is also different.

In general, despite the apparent simplicity of atomics, use them cautiously. Mutex is less shiny but more error-proof.

★ Subscribe to keep up with new posts.