1. Why do we need atomic if we already have Mutex?
When you first learned about Mutex, you might have had this feeling: “That’s it, now I’ll just wrap any data access in Lock/Unlock and live happily ever after.” And that is a pretty good plan for most tasks. But sometimes you want something simpler: for example, just count how many times a function was called, or raise a “we have already stopped” flag so that a second goroutine does not “stop” what is already stopped.
And this is where Mutex can be overkill. Not because it is “bad”, but because its purpose is to protect invariants and multiple related actions. And when you need to do one tiny operation on one variable, like “increment the counter by 1” or “set the flag to true”, sync/atomic gives you a way to do it correctly without an explicit lock.
It is important to set the tone right away: atomic is not a “trendy optimization” and not a “replacement for Mutex”. It is like a scalpel: useful when you need precision on a small area, but weird for cutting bread, sausage, and life problems.
2. Mental model: “atomic” means “as a single action”
The word “atomic” here means: the operation is performed as if it were indivisible for other goroutines. That is, either it has not happened yet, or it has happened completely—without “half-access” and without “I read the old value while you already managed to write the new one.”
The most common beginner mistake is to think that n++ is “one operation.” For a human, it is one operation. For the CPU and the compiler, it is usually “read n → add 1 → write back.” Between the read and the write, another goroutine can also jump in and do its own “read → add → write”, and you end up losing increments.
Let’s look at it as a mini diagram:
n++ (in reality) = Load(n) -> Add(1) -> Store(n)
If two threads do this at the same time, they can both read the same value, and then both write the same result.
Mini example “how to break a counter” (the code is intentionally wrong):
package main
import (
"fmt"
"sync"
)
func main() {
var n int
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); n++ }()
go func() { defer wg.Done(); n++ }()
wg.Wait()
fmt.Println(n) // may be 1 or 2 (nondeterministically)
}
Yes, sometimes you will see 2, sometimes 1. And that “sometimes” is the main red flag in concurrent code: “sometimes it works” almost always means “sometimes it breaks, but you have not noticed yet.”
3. Atomic types in Go: atomic.Int64, atomic.Bool
The sync/atomic package in modern Go offers convenient wrapper types: atomic.Int64, atomic.Uint64, atomic.Bool, and others. They are nice because they read like a normal API, rather than a set of cryptic functions.
The key benefit for us as humans: instead of “call atomic.AddInt64(&x, 1) and do not mess up the ampersand” you can write x.Add(1).
Counter: atomic.Int64
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var hits atomic.Int64
hits.Add(1)
hits.Add(5)
fmt.Println(hits.Load()) // 6
}
Here Add is an atomic “increment and return the new value”, and Load is an atomic “read the current value”.
Flag: atomic.Bool
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var ready atomic.Bool
ready.Store(true)
fmt.Println(ready.Load()) // true
}
This is useful when you need to store a “yes/no” state that is read and written from different goroutines.
Cheat sheet for the main methods
To avoid turning atomic into a religion, it helps to keep a simple set of operations in mind:
| What we want to do | Typically use | Meaning |
|---|---|---|
| Read a value | |
safe read “right now” |
| Write a value | |
safe write |
| Increment/decrement | |
atomic counter update |
| “Swap and return the old value” | |
sometimes convenient for reset |
| “Swap only if...” | |
CAS: “first wins” |
4. Example: TaskStore and atomic counters
So that atomic does not remain an abstraction, let’s continue our running story about tasks (a mini todo). We have an in-memory task store: internally it is a map, and access to it must be protected (we already did this via Mutex/RWMutex, because map is not thread-safe by itself).
But metrics like “how many tasks were created” or “how many times a task was read” are perfect candidates for atomic counters. Why? Because these are independent numbers, and we do not need to maintain a shared “invariant” between them and the map. If the counter “runs slightly ahead” of the real state for a millisecond, the world will not collapse—it is statistics.
Let’s describe the TaskStore struct (a piece of it):
package main
import (
"sync"
"sync/atomic"
)
type TaskStore struct {
mu sync.RWMutex
tasks map[int]string
nextID atomic.Int64
creates atomic.Int64
reads atomic.Int64
}
Here:
- tasks is protected via RWMutex (because it is real data where ordering and consistency matter),
- nextID, creates, reads are atomic, because they are just numbers.
Initialization:
package main
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[int]string),
}
}
Creating a task: atomically take a new id, under Lock write into the map, and increment the creates counter atomically.
package main
func (s *TaskStore) Create(title string) int {
id := int(s.nextID.Add(1))
s.mu.Lock()
s.tasks[id] = title
s.mu.Unlock()
s.creates.Add(1)
return id
}
Reading a task: under RLock read from the map, but increment the reads counter atomically (and do this without holding RLock, so as not to stretch the critical section).
package main
func (s *TaskStore) Get(id int) (string, bool) {
s.mu.RLock()
title, ok := s.tasks[id]
s.mu.RUnlock()
s.reads.Add(1)
return title, ok
}
Snapshot of metrics for printing:
package main
type Stats struct {
Creates int64
Reads int64
}
func (s *TaskStore) Stats() Stats {
return Stats{
Creates: s.creates.Load(),
Reads: s.reads.Load(),
}
}
It is important to understand here: Stats() returns a snapshot, and between the Load() of two fields, nanoseconds can pass during which another thread may update something. But for metrics, that is fine. We are not building a banking system; we are just counting how many times something happened.
5. State flag: “closed” via atomic.Bool
Now let’s add a lifecycle idea to TaskStore: the store can be “closed”, and after closing, any operations should fail correctly.
This is a common practical case: you have a service that is shutting down, and you do not want a couple of goroutines to keep doing work “as if nothing is happening” during shutdown.
Add a field:
package main
import "sync/atomic"
type TaskStore struct {
// ...
closed atomic.Bool
}
Close method:
package main
func (s *TaskStore) Close() {
s.closed.Store(true)
}
And a check in the Create method (simplified):
package main
import "errors"
func (s *TaskStore) Create(title string) (int, error) {
if s.closed.Load() {
return 0, errors.New("task store is closed")
}
id := int(s.nextID.Add(1))
// ...
return id, nil
}
At this point, you can ask a good engineering question: “What if Close() happens exactly between the closed.Load() check and the write into the map?” The honest answer: that is possible. If you need a strict rule of “after closing—no writes at all”, then atomic.Bool alone is not enough, and you will have to expand the design (for example, close under Lock and check the flag under Lock as well). That is, atomic solves the simple part of the task, but it does not eliminate the need to think about boundaries.
CAS: CompareAndSwap — “first wins”
CompareAndSwap (often abbreviated as CAS) is an operation of the form: “replace the value with a new one, but only if the current value equals the expected one.” And it is also atomic.
For the “closed” flag, CAS gives a convenient “close once” pattern:
package main
func (s *TaskStore) CloseOnce() bool {
return s.closed.CompareAndSwap(false, true)
}
Now the caller can tell who was first:
package main
import "fmt"
func main() {
s := NewTaskStore()
fmt.Println(s.CloseOnce()) // true (first to close)
fmt.Println(s.CloseOnce()) // false (already closed)
}
This trick is useful not only for “close.” It often shows up in concurrent code for things like “start once,” “lazy initialize” (although for initialization, sync.Once is usually nicer), “the first request warms up the cache.”
6. When atomic is NOT suitable: “the invariant lives across fields”
It is very tempting to take atomic and start making everything atomic: the map, the slice, and “three numbers at once,” and generally make it “lock-free,” so it sounds like a rock band name.
The problem is that atomic guarantees the integrity of an operation on one variable. But if your meaning (invariant) is distributed across multiple fields, then you need a Mutex (or another higher-level mechanism), because you are protecting not a line of code and not a variable, but a data consistency rule.
Imagine you store:
- sum — the sum of all scores,
- count — the number of scores,
and you want to compute the average.
Even if both fields are atomic, between the Load() of these two fields another concurrent update can change one of them, and you will get an “average” from values that never existed at the same time. For analytics, this may be acceptable, but for business logic (for example, computing discounts, limits, or pricing)—it is not.
It is important not to fall into extremes: sometimes an “approximate value” is fine, but if you catch yourself thinking “I need these two fields to be updated and read consistently,” then that almost always means a Mutex and a critical section where you update both fields together.
7. When atomic is justified
In practice, sync/atomic is most often justified in two scenarios: counters and flags. A counter—because increment/decrement on a single number fits the atomic model perfectly. A flag—because “yes/no” usually does not require a complex invariant.
But there is a nuance that breaks a popular myth: atomic operations do not have to be cheaper than Mutex. On real hardware, atomic operations can cause heavy contention on a single cache line (and that is its own kind of pain). So atomic is not a “turbo mode by default,” but a tool for specific cases.
A good illustration from the Go tooling world: coverage mode -covermode=atomic is needed when it is important to count executed statements exactly in parallel tests, but it can be noticeably more expensive because it uses atomic operations from sync/atomic. This is exactly the philosophy worth internalizing: use atomic when you need correctness/precision for simple variables under parallel load, not because “I want it fast.”
Let’s summarize the choice in a table (as a “cheat sheet for living humans”):
| Tool | What it protects | When to choose it | Typical example |
|---|---|---|---|
|
an invariant, a related set of fields/operations | almost always, as the baseline choice | map, slice, multiple fields of a struct |
|
an invariant, but with different access modes | when there are truly many reads and few writes | directory, “read-heavy” cache |
|
a single variable | independent counter/flag, CAS “first wins” | metrics, nextID, closed |
8. Common mistakes when working with sync/atomic
Mistake #1: mixing atomic and non-atomic access to the same variable.
The most common scenario looks like this: “the counter is atomic.Int64, but for convenience I will do fmt.Println(x) or set x = 0 directly.” And right at that moment you bring the data race back. If a variable is declared atomic, then all reads and writes must go through Load/Store/Add/Swap/CompareAndSwap. Otherwise, it is like buckling the seatbelt over only half your body.
Mistake #2: trying to use atomic to maintain consistency across multiple fields.
Two atomic variables are not an “atomic pair.” If a business rule requires A and B to change together, and be read as a single whole, then atomic will not save you. In the best case you get slightly “drifty” values; in the worst case you break an important invariant, and the bug will look like “very rare strange behavior in production.”
Mistake #3: clinging to the illusion that Load() gives a “stable snapshot of the world.”
Load() honestly reads the current value. But that does not mean it will not change a microsecond later. So any approach like “I read the flag—and now I can definitely run ten lines of code without checks” must either be expressed as a stronger contract (via Mutex/state), or explicitly acknowledged as “best effort.”
Mistake #4: copying structs with atomic fields after they have started being used.
Just like with Mutex, copying concurrent state is almost always a bad idea. Atomic types have internal semantics that assume correct shared use of one instance. When you copy a struct, you get “two versions of the counter,” and then it is easy to start counting “in different directions.” The rule is simple: such structs are usually passed by pointer and not copied after concurrent work begins.
Mistake #5: using atomic as a “default optimization.”
Sometimes atomic is added simply because “it is concurrency, so we need atomic.” In practice, this complicates the code, hurts readability, and can even make things slower due to contention. It is much healthier to start with a clear Mutex and apply atomic surgically: counters, flags, CAS switches like “first wins.”
GO TO FULL VERSION