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.