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:
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:
The race detector is reporting a problem:
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 of any type;
- Pointer — a pointer to a value of type T (generic).
Each atomic type provides the following methods:
Load reads the value of a variable, Store sets a new value:
Swap sets a new value (like Store) and returns the old one:
CompareAndSwap sets a new value only if the current value is still what you expect it to be:
Numeric types also provide an Add method that increments the value by the specified amount:
And the And/Or methods for bitwise operations (Go 1.23+):
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:
Now, let's go back to the counter program:
And rewrite it to use an atomic counter:
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:
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:
Is the counter value guaranteed?
AnswerIt is guaranteed.
Example 2:
Is the counter value guaranteed?
AnswerIt's not guaranteed.
Example 3:
Is the counter value guaranteed?
AnswerIt'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:
Call increment 100 times from different goroutines:
Run the program with the -race flag — there are no races:
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?
AnswerIn 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:
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:
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:
In a concurrent environment, there are data races on the closed field. We can fix this with a mutex:
Alternatively, we can use CompareAndSwap on an atomic Bool instead of a mutex:
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.
.png)


