Gist of Go: Atomics
This is a chapter from my book on Go concurrency, which teaches the topic from the ground up through interactive examples.
Some concurrent operations don't require explicit synchronization. We can use these to create lock-free types and functions that are safe to use from multiple goroutines. Let's dive into the topic!
Non-atomic increment • Atomic operations • Composition • Atomic vs. mutex • Keep it up
Non-atomic increment
Suppose multiple goroutines increment a shared counter:
total := 0
var wg sync.WaitGroup
for range 5 {
wg.Go(func() {
for range 10000 {
total++
}
})
}
wg.Wait()
fmt.Println("total", total)
total 40478
There are 5 goroutines, and each one increments total
10,000 times, so the final result should be 50,000. But it's usually less. Let's run the code a few more times:
total 26775
total 22978
total 30357
The race detector is reporting a problem:
$ go run -race total.go
==================
WARNING: DATA RACE
...
==================
total 33274
Found 1 data race(s)
This might seem strange — shouldn't the total++
operation be atomic? Actually, it's not. It involves three steps (read-modify-write):
- Read the current value of
total
. - Add one to it.
- Write the new value back to
total
.
If two goroutines both read the value 42
, then each increments it and writes it back, the new total
will be 43
instead of 44
like it should be. As a result, some increments to the counter will be lost, and the final value will be less than 50,000.
As we talked about in the Race conditions chapter, you can make an operation atomic by using mutexes or other synchronization tools. But for this chapter, let's agree not to use them. Here, when I say "atomic operation", I mean an operation that doesn't require the caller to use explicit locks, but is still safe to use in a concurrent environment.
Atomic operations
An operation without synchronization can only be truly atomic if it translates to a single processor instruction. Such operations don't need locks and won't cause issues when called concurrently (even the write operations).
In a perfect world, every operation would be atomic, and we wouldn't have to deal with mutexes. But in reality, there are only a few atomics, and they're all found in the sync/atomic
package. This package provides a set of atomic types:
Bool
— a boolean value;Int32
/Int64
— a 4- or 8-byte integer;Uint32
/Uint64
— a 4- or 8-byte unsigned integer;Value
— a value ofany
type;Pointer
— a pointer to a value of typeT
(generic).
Each atomic type provides the following methods:
Load
reads the value of a variable, Store
sets a new value:
var n atomic.Int32
n.Store(10)
fmt.Println("Store", n.Load())
Store 10
Swap
sets a new value (like Store
) and returns the old one:
var n atomic.Int32
n.Store(10)
old := n.Swap(42)
fmt.Println("Swap", old, "->", n.Load())
Swap 10 -> 42
CompareAndSwap
sets a new value only if the current value is still what you expect it to be:
var n atomic.Int32
n.Store(10)
swapped := n.CompareAndSwap(10, 42)
fmt.Println("CompareAndSwap 10 -> 42:", swapped)
fmt.Println("n =", n.Load())
CompareAndSwap 10 -> 42: true
n = 42
var n atomic.Int32
n.Store(10)
swapped := n.CompareAndSwap(33, 42)
fmt.Println("CompareAndSwap 33 -> 42:", swapped)
fmt.Println("n =", n.Load())
CompareAndSwap 33 -> 42: false
n = 10
Numeric types also provide an Add
method that increments the value by the specified amount:
var n atomic.Int32
n.Store(10)
n.Add(32)
fmt.Println("Add 32:", n.Load())
Add 32: 42
And the And
/Or
methods for bitwise operations (Go 1.23+):
const (
modeRead = 0b100
modeWrite = 0b010
modeExec = 0b001
)
var mode atomic.Int32
mode.Store(modeRead)
old := mode.Or(modeWrite)
fmt.Printf("mode: %b -> %b\n", old, mode.Load())
mode: 100 -> 110
All methods are translated to a single CPU instruction, so they are safe for concurrent calls.
Strictly speaking, this isn't always true. Not all processors support the full set of concurrent operations, so sometimes more than one instruction is needed. But we don't have to worry about that — Go guarantees the atomicity of
sync/atomic
operations for the caller. It uses low-level mechanisms specific to each processor architecture to do this.
Like other synchronization primitives, each atomic variable has its own internal state. So, you should only pass it as a pointer, not by value, to avoid accidentally copying the state.
When using atomic.Value
, all loads and stores should use the same concrete type. The following code will cause a panic:
var v atomic.Value
v.Store(10)
v.Store("hi")
panic: sync/atomic: store of inconsistently typed value into Value
Now, let's go back to the counter program:
total := 0
var wg sync.WaitGroup
for range 5 {
wg.Go(func() {
for range 10000 {
total++
}
})
}
wg.Wait()
fmt.Println("total", total)
And rewrite it to use an atomic counter:
var total atomic.Int32
var wg sync.WaitGroup
for range 5 {
wg.Go(func() {
for range 10000 {
total.Add(1)
}
})
}
wg.Wait()
fmt.Println("total", total.Load())
total 50000
Much better!
✎ Exercise: Atomic counter +1 more
Practice is crucial in turning abstract knowledge into skills, making theory alone insufficient. The full version of the book contains a lot of exercises — that's why I recommend getting it.
If you are okay with just theory for now, let's continue.
Atomics composition
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 combine atomic operations? Let's find out.
Atomicity
Let's look at a function that increments a counter:
var counter int32
// increment increases the counter value by two.
func increment() {
counter += 1
sleep(10)
counter += 1
}
// sleep pauses the current goroutine for up to maxMs ms.
func sleep(maxMs int) {
dur := time.Duration(rand.IntN(maxMs)) * time.Millisecond
time.Sleep(dur)
}
As you already know, increment
isn't safe to call from multiple goroutines because counter += 1
causes a data race.
Now I will try to fix the problem 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 atomic operations also magically becomes an atomic operation. But it doesn't.
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
for range 100 {
wg.Go(increment)
}
wg.Wait()
fmt.Println("counter =", counter.Load())
Run the program with the -race
flag — there are no races:
% go run atomic-2.go
192
% go run atomic-2.go
191
% go run atomic-2.go
189
But can we be sure what the final value of counter
will be? Nope. counter.Load
and counter.Add
calls are interleaved from different goroutines. This causes a race condition (not to be confused with a data race) and leads to an unpredictable counter
value.
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.
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.
A bulletproof way to make a composite operation atomic and prevent race conditions is to use a mutex:
var delta int32
var counter int32
var mu sync.Mutex
func increment() {
mu.Lock()
delta += 1
sleep(10)
counter += delta
mu.Unlock()
}
// After 100 concurrent increments, the final value is guaranteed:
// counter = 1+2+...+100 = 5050
counter = 5050
But sometimes an atomic variable with CompareAndSwap
is all you need. Let's look at an example.
✎ Exercise: Concurrent-safe stack
Practice is crucial in turning abstract knowledge into skills, making theory alone insufficient. The full version of the book contains a lot of exercises — that's why I recommend getting it.
If you are okay with just theory for now, let's continue.
Atomic instead of mutex
Let's say we have a gate that needs to be closed:
type Gate struct {
closed bool // gate state
}
func (g *Gate) Close() {
if g.closed {
return // ignore repeated calls
}
g.closed = true
// free resources
}
func work() {
var g Gate
defer g.Close()
// do something while the gate is open
}
In a concurrent environment, there are data races on the closed
field. We can fix this with a mutex:
type Gate struct {
closed bool
mu sync.Mutex // protects the state
}
func (g *Gate) Close() {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
return // ignore repeated calls
}
g.closed = true
// free resources
}
Alternatively, we can use CompareAndSwap
on an atomic Bool
instead of a mutex:
type Gate struct {
closed atomic.Bool
}
func (g *Gate) Close() {
if !g.closed.CompareAndSwap(false, true) {
return // ignore repeated calls
}
// The gate is closed.
// We can free resources now.
}
The Gate
type is now more compact and simple.
This isn't a very common use case — we usually want a goroutine to wait on a locked mutex and continue once it's unlocked. But for "early exit" situations, it's perfect.
Keep it up
Atomics are a specialized but useful tool. You can use them for simple counters and flags, but be very careful when using them for more complex operations. You can also use them instead of mutexes to exit early.
In the next chapter, we'll talk about testing concurrent code (coming soon).
Pre-order for $10 or read online
★ Subscribe to keep up with new posts.