Replace Mutex Guarded Values With Typed Atomics in Go
When a mutex around a single value can become atomic.Int64, atomic.Bool or atomic.Pointer, with before and after code and a read-heavy benchmark.

A common sight in older Go code is a small struct with a mutex whose only job is to protect one field: a request counter, an enabled flag, a pointer to the current configuration. The mutex is correct. It is also more machinery than the job needs, and in a hot path it is slower than it has to be, because every reader has to take a lock to read one word. Go has had atomic operations since the beginning, and since Go 1.19 it has had atomic types that make using them hard to get wrong. Replacing a mutex-guarded single value with one is a small, safe migration that is often worth doing.
Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64
This article shows the conversions for the three common shapes, benchmarks the read-heavy case, and spends as much time on when not to convert. It belongs to the concurrency patterns cluster and relates to atomics, compare and swap, mutexes and safe counters. Everything ran on Go 1.25.6 on an Apple M1 Max.
The rule for converting
A mutex can become an atomic when the mutex guards exactly one value, and every operation on it is one load, one store, one add or one compare-and-swap. The moment an operation needs to read a value and then write something that depends on it, or touches two fields that must stay consistent, the mutex is doing real work. An atomic cannot replace it.
Say the rule as a question before touching code: is there any moment at which another goroutine could observe a state that is inconsistent, because I have written one field and not yet another? If yes, keep the lock. If no, because the guarded thing is a single word, convert.
Counters and flags
The simplest case. A stats struct with a mutex and two integers:
package main
import "sync"
// Before: a mutex guarding independent counters.
type OldStats struct {
mu sync.Mutex
requests int64
errors int64
}
func (s *OldStats) Request() { s.mu.Lock(); s.requests++; s.mu.Unlock() }
func (s *OldStats) Error() { s.mu.Lock(); s.errors++; s.mu.Unlock() }
func (s *OldStats) Snapshot() (requests, errors int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.requests, s.errors
}The two counters are independent: nothing requires requests and errors to be read at the same instant. Each can be its own atomic, and the mutex disappears:
package main
import "sync/atomic"
// After: each counter is an atomic; no lock, and no way to read one non-atomically.
type NewStats struct {
requests atomic.Int64
errors atomic.Int64
}
func (s *NewStats) Request() { s.requests.Add(1) }
func (s *NewStats) Error() { s.errors.Add(1) }
func (s *NewStats) Snapshot() (requests, errors int64) {
return s.requests.Load(), s.errors.Load()
}One thing changed semantically, and it is worth being honest about. The old Snapshot returned a consistent pair: both counters as of one instant. The new one loads them one after the other, so a request that completes between the two loads can make the pair slightly inconsistent (an error counted whose request was not yet). For metrics that is irrelevant. For a ratio that must never exceed 1, it is not. If the pair matters, the mutex stays.
A boolean flag is the same story with atomic.Bool:
var shuttingDown atomic.Bool // was: a bool guarded by a mutex
func (s *Server) Drain() { shuttingDown.Store(true) }
func (s *Server) accepting() bool { return !shuttingDown.Load() }The graceful shutdown article uses exactly this for a readiness flag (graceful shutdown).
Configuration: atomic.Pointer and immutable values
The high-value conversion is the configuration that is read on every request and replaced rarely. With a mutex, every request pays for a lock; with an atomic pointer to an immutable struct, reads are a single load:
package main
import (
"sync"
"sync/atomic"
)
type Config struct { // never modified after it is published
Timeout int
Region string
Flags map[string]bool
}
// Before: every read takes the lock.
type OldHolder struct {
mu sync.RWMutex
cfg *Config
}
func (h *OldHolder) Get() *Config { h.mu.RLock(); defer h.mu.RUnlock(); return h.cfg }
func (h *OldHolder) Set(c *Config) { h.mu.Lock(); h.cfg = c; h.mu.Unlock() }
// After: readers load a pointer; writers swap a complete new Config.
type NewHolder struct {
cfg atomic.Pointer[Config]
}
func (h *NewHolder) Get() *Config { return h.cfg.Load() }
func (h *NewHolder) Set(c *Config) { h.cfg.Store(c) }The conversion is only safe because of the comment on Config: it is never modified after publication. A reader that loads the pointer holds a complete, consistent configuration, and a writer that wants to change one field builds a copy with that change and stores the new pointer. If readers could mutate the struct they loaded, or a writer edited the published one in place, the atomic pointer would protect nothing: it makes the pointer swap atomic, not the pointee. This is copy on write, covered in the map crash article. When several writers update different fields concurrently, serialize them with a mutex around the read-copy-store sequence, and keep the atomic for the readers.
What it buys: the benchmark
With a read-heavy config, the three holders compared on ten cores, plus a mutex (not RWMutex) version since that is what many older codebases have:
package main
import (
"sync"
"testing"
)
type MutexHolder struct {
mu sync.Mutex
cfg *Config
}
func (h *MutexHolder) Get() *Config { h.mu.Lock(); defer h.mu.Unlock(); return h.cfg }
var sink *Config
func BenchmarkMutexRead(b *testing.B) {
h := &MutexHolder{cfg: &Config{Timeout: 5}}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
sink = h.Get()
}
})
}
func BenchmarkRWMutexRead(b *testing.B) {
h := &OldHolder{cfg: &Config{Timeout: 5}}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
sink = h.Get()
}
})
}
func BenchmarkAtomicPointerRead(b *testing.B) {
h := &NewHolder{}
h.Set(&Config{Timeout: 5})
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
sink = h.Get()
}
})
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMutexRead-10 9070731 121.4 ns/op 0 B/op 0 allocs/op
BenchmarkRWMutexRead-10 9298024 121.0 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicPointerRead-10 1000000000 0.5272 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 3.352sThe plain mutex read cost about 121 ns per operation with ten cores competing for it. The RWMutex read, which in theory lets readers share, cost the same 121 ns: each RLock and RUnlock is still an atomic add on one cache line, so the cores queue for that word even though they hold the lock together. The atomic pointer load took about half a nanosecond, roughly the cost of the loop around it, a speedup of over two hundred times. It is a plain read of a word that is rarely written: each core keeps it in its own cache, so there is nothing to contend on, and the cost approaches that of reading an ordinary variable. This is the same cache-line story as in the counter benchmark, in reverse: writes contend, reads of unchanging data do not.
When to leave the mutex alone
Two fields that change together. A struct with min and max whose invariant is min <= max cannot be two atomics, because a reader can see a new min with an old max. Keep the mutex, or put both in one immutable struct and swap an atomic.Pointer to it.
Read-modify-write sequences. if balance >= n { balance -= n } has a gap between the check and the act. Two atomics do not close it (check then act). Use CompareAndSwap in a loop for a single value, or a mutex.
Condition waiting. An atomic flag cannot make a goroutine sleep until it changes. Polling it is a busy loop; use a channel or sync.Cond.
Anything with a map or slice you modify in place. The atomic protects the pointer, not what it points to (safe collections).
Code where nobody has measured. A mutex around a counter that is updated once per request is not a problem worth fixing for speed. The migration is justified by clarity and by removing the chance of a non-atomic access, which the typed values enforce at compile time, and not by nanoseconds.
Migrating step by step
The safe order for a real codebase is the same each time. Find the mutex and list every field it guards; if the list has one entry, continue. List every operation that touches that field and confirm each is a single load, store or add. Change the field type to the atomic type, and let the compiler show you every site that used the old field directly, since a typed atomic cannot be read or assigned as a plain value. Replace each site with Load, Store or Add. Delete the mutex field. Run the tests with -race, and add a concurrent test if there was none, so the conversion is protected by something that would fail if you got it wrong. The change is usually a ten-line diff that removes more than it adds, and that is a sign it was the right one.
atomic.Value and why Pointer[T] replaced it
Before generics, the way to store an arbitrary value atomically was atomic.Value, which holds an interface{}. It panics if you store values of different concrete types, and it needs a type assertion on every load. atomic.Pointer[T] (Go 1.19) is typed, checked at compile time, and has CompareAndSwap and Swap as well. New code should use it, and old code using atomic.Value for a config pointer is a good place to start migrating (modernizing concurrency code).
The reference is the sync/atomic documentation, which also states the memory-ordering guarantee: atomic operations behave as sequentially consistent, which is what makes publishing a pointer after filling the struct safe. The Go memory model has the formal statement.


