Atomic operations composition in Go

An atomic operation in a concurrent program is a great thing. Such operation usually transforms into a single processor instruction, and 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:

func main() {
    for range 100 {
        increment()
    }
    fmt.Println("counter =", counter)
}
counter = 200

then counter is guaranteed to equal 200.

In a concurrent environment, increment() is unsafe due to races on counter += 1:

func main() {
    var wg sync.WaitGroup
    wg.Add(100)
    for range 100 {
        go func() {
            increment()
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("counter =", counter)
}
counter = 183

You may wonder why the counter = 200 when run from the browser. It's because you're lucky (and the sandbox is single-core).

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)
}
counter = 200

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)
    }
}
counter = 184

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())
}
counter = 9386

Is the counter value guaranteed?

Answer

It's not guaranteed.

Composition

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 range 100 {
    go func() {
        increment()
        wg.Done()
    }()

}

wg.Wait()
fmt.Println("counter =", counter)

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.

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.

Final thoughts

Somehow the human brain tends to treat "atomic" as "safe and predictable in all situations". But of course it is not. Individual operations may be atomic, but their composition is not, and it does not guarantee a consistent result.

Despite the apparent simplicity of atomics, use them with care. Mutexes are less shiny, but they lead to fewer concurrency-related errors.

──

P.S. Interactive examples in this post are powered by codapi — an open source tool I'm building. Use it to embed live code snippets into your product docs, online course or blog.

★ Subscribe to keep up with new posts.