Why Your Go Counter Isn't Counting Right: sync/atomic Uncovered
While we can use mutex to shield an integer from simultaneous goroutine modifications, it might not always be the optimal choice.
Before we dive deeper into the sync/atomic
package, let's take a moment to unpack a piece of code that often trips up many developers
var counter = 0
var wg = sync.WaitGroup{}
func AddCounter() {
defer wg.Done()
counter++
}
func main() {
for i := 0; i < 2000; i++ {
wg.Add(1)
go AddCounter()
}
wg.Wait()
fmt.Println(counter) // ?
}
What number do you anticipate the counter
would display?
You might already sense that 2000 isn’t a guaranteed outcome, and that’s the heart of the challenge.
I remember running the code for the first time, expecting a clear 2000, but what I got was 1942, and then on a rerun, 1890… it felt like rolling dice.
Feel free to try it at: https://go.dev/play/p/iXK7nGFRrRZ
At first glance, the counter++
operation might look straightforward, but beneath the surface, it's performing a trio of tasks:
Fetching the current value of
counter
.Increasing that value by a single unit.
Storing this refreshed value back into
counter
.
Imagine two goroutines trying to update the counter at the exact same time.
They could both read the initial value, add to it, and then save the new number. In this scenario, the counter
might only increase by one, even though two separate routines tried to bump it up."
Your Fix to the Problem
Remember our discussion about the ‘Go Sync Package: 6 Key Concepts for Concurrency’? And you’re probably leaning towards using a mutex lock right?
Let’s play around with our goroutine and mutex lock a bit:
var mtx sync.Mutex // Our little guard for the counter
func AddCounter() {
defer wg.Done()
mtx.Lock()
defer mtx.Unlock()
counter++
}
This does the trick.
But, for such a simple task, it feels like we’re doing a lot and what if there was an easier method? Time to dive into the sync/atomic
package and see what it offers.
Diving Into the sync/atomic Package
When we talk about the sync/atomic
package, what we're really discussing is a set of tools designed for atomic memory operations. It's all about handling numbers and pointers in a safe way.
Understanding Atomic Operations on Integer Types
We have several types to consider: int32, uint32, int64, and uint64, but for our discussion, let’s focus on int64.
Here are its main functions:
func AddInt64(addr *int64, delta int64) (new int64)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func LoadInt64(addr *int64) (val int64)
func StoreInt64(addr *int64, val int64)
func SwapInt64(addr *int64, new int64) (old int64)
Remember our earlier counter challenge, AddInt64
comes to our rescue and here's how it works:
// BEFORE
var mtx sync.Mutex // Our little guard for the counter
func AddCounter() {
defer wg.Done()
mtx.Lock()
defer mtx.Unlock()
counter++
}
// AFTER
func AddCounter() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}
In this part, using atomic.AddInt64(&counter, 1)
ensures that our counter increases safely, and if you want to get the current value, atomic.LoadInt64(&counter)
provides a straightforward read.
“Why might we not just read the
counter
directly? Isn't using atomic.LoadInt64 a bit overkill?"
Well, imagine this: One goroutine is reading an int64 value, simultaneously, another goroutine starts writing a new value to that same spot. Based on the exact timing and system details, the reading goroutine might see a mix of old and new bytes.
This is known as a “torn read”.
“So the read goroutine can read either old or new value right?”
Maybe… but it’s not always that black and white.
Consider when a digital clock moves from 12:59
to 13:00
, so if the hour updates just a bit earlier than the minutes and you look at the clock during this brief transition, you’d see 13:59
, a time that doesn’t match the old or new value.
Now let’s talk about other functions:
CompareAndSwapInt64(&addr, old, new int64) bool
: Changes the value at addr to new, but only if it currently matches old. Returning true if the change happened and false if it didn’t.AddUint64(&addr uint64, delta uint32) (new uint32)
: The function increases addr by the specified delta and promptly hands back the new value.LoadUint64(&addr uint64) (val uint64)
: Safely and atomically, it retrieves the current value fromaddr
.StoreInt64(&addr int64, val int64)
: Atomically setsaddr
to the provided value.
func main() {
var valueInt64 int64 = 2
// StoreInt64
atomic.StoreInt64(&valueInt64, 3)
fmt.Println(valueInt64) // 3
// CompareAndSwapInt64
swapped := atomic.CompareAndSwapInt64(&valueInt64, 3, 5)
fmt.Println(valueInt64, swapped) // 5, true
swapped = atomic.CompareAndSwapInt64(&valueInt64, 3, 5)
fmt.Println(valueInt64, swapped) // 5, false
// AddInt64
atomic.AddInt64(&valueInt64, 2)
fmt.Println(valueInt64) // 7
// LoadInt64
fmt.Println(atomic.LoadInt64(&valueInt64)) // 7
}
sync/atomic after Go 1.19
Go 1.19 brought with it some thoughtful improvements to the sync/atomic
package and among these changes, a key highlight was the introduction of typed atomic variables, now covering booleans, integers, and pointers.
Specifically, for the Int64
type, we observe:
func (*Int64) Add(delta int64) (new int64)
func (*Int64) Load() int64
func (*Int64) Store(val int64)
func (*Int64) Swap(new int64) (old int64)
func (*Int64) CompareAndSwap(old, new int64) (swapped bool)
// ...
Let’s reshape our code to leverage these updated functions:
var counter atomic.Int64
var wg = sync.WaitGroup{}
func AddCounter() {
defer wg.Done()
counter.Add(1)
}
func main() {
for i := 0; i < 2000; i++ {
wg.Add(1)
go AddCounter()
}
wg.Wait()
fmt.Println(counter.Load()) // 2000
}
It’s a great example of how Go continues to evolve.
Atomic Operations on Pointers
The sync/atomic
package isn’t just about numbers; it brings atomic operations into the world of pointer types as well.
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func (x *Pointer[T]) Load() *T
func LoadUintptr(addr *uintptr) (val uintptr)
func (x *Uintptr) Load() uintptr
// ...
These functions offer the ability to atomically load, store, and swap pointers and this mirrors what we discussed regarding integer operations.
Yet, from my perspective, they don’t come up as frequently in common tasks.
By the way, if terms like uintptr and unsafe.Pointer sound like a puzzle to you, there’s a detailed article What is unsafe.Pointer, or uintptr?