Compare and Swap in Go for Lock Free Counters and Flags
How CompareAndSwap works in Go: one-shot flags, retry loops for atomic max and bounded counters, lock-free stacks, the ABA problem and when a mutex is better.

A mutex gets exclusive access by making other goroutines wait. Compare-and-swap, universally abbreviated CAS, gets safe updates by making them check: "set this to the new value, but only if it is still the value I read". If another goroutine changed it in the meantime, the swap fails, and you try again with the fresh value. There is no lock, no waiting and no sleeping goroutine. It is the primitive that atomic counters, lock-free queues and most of sync itself are built on, and a handful of its uses are worth knowing.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds the retry loop, shows three uses, and is honest about when to stop. It follows atomic operations, and it belongs to the concurrency material. Everything ran on Go 1.25.6.
The operation
Every atomic type has CompareAndSwap(old, new) returning a bool:
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var n atomic.Int64
n.Store(10)
fmt.Println(n.CompareAndSwap(10, 20), n.Load()) // matched: swapped
fmt.Println(n.CompareAndSwap(10, 30), n.Load()) // 10 is stale: refused
}true 20
false 20The first swap sees the expected value and writes 20. The second expects 10, finds 20 and does nothing. The check and the write are one indivisible step in hardware, so no other goroutine can slip between them. That is all CAS is; everything else is how you use the failure.
Use 1: do something exactly once
A flag flip with CompareAndSwap(false, true) has exactly one winner, however many goroutines race:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var started atomic.Bool
var winners atomic.Int32
var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
if started.CompareAndSwap(false, true) {
winners.Add(1) // only the goroutine that flipped the flag gets here
}
})
}
wg.Wait()
fmt.Println("goroutines that won the race:", winners.Load())
}goroutines that won the race: 1Twenty goroutines race, one wins. This is the lock-free version of "start the background refresher if nobody has", or "log this warning only once". It is also how sync.Once begins its fast path. Note what it does not do: the losers return immediately without waiting for the winner to finish its work, unlike Once, whose Do blocks them. If callers must wait for the result, use Once.
Use 2: the retry loop
For updates that are not a plain add, such as "keep the maximum" or "increment unless it would exceed a limit", the pattern is:
- Load the current value.
- Compute the new value from it.
CompareAndSwap(current, new).- If it failed, someone else changed the value; go back to step 1.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
// updateMax raises m to v if v is larger, atomically.
func updateMax(m *atomic.Int64, v int64) {
for {
cur := m.Load()
if v <= cur {
return // nothing to do
}
if m.CompareAndSwap(cur, v) {
return
}
// lost a race: another goroutine changed m; look again
}
}
func main() {
var peak atomic.Int64
var wg sync.WaitGroup
for i := int64(1); i <= 1000; i++ {
wg.Go(func() { updateMax(&peak, i) })
}
wg.Wait()
fmt.Println("peak:", peak.Load())
}peak: 1000A thousand goroutines offer values 1 to 1000, and the peak is 1000 on every run. Each goroutine either sees a larger current value and leaves, or tries to install its own and, if beaten, looks again. No update is lost and nobody blocks. The same shape gives a bounded counter, which refuses to go above a limit:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
// tryAcquire takes one of limit slots, reporting whether it got one.
func tryAcquire(slots *atomic.Int32, limit int32) bool {
for {
cur := slots.Load()
if cur >= limit {
return false
}
if slots.CompareAndSwap(cur, cur+1) {
return true
}
}
}
func main() {
var slots atomic.Int32
var granted atomic.Int32
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
if tryAcquire(&slots, 10) {
granted.Add(1)
}
})
}
wg.Wait()
fmt.Println("slots granted out of 50 requests:", granted.Load(), "(limit 10)")
}slots granted out of 50 requests: 10 (limit 10)Exactly ten requests succeed, however the goroutines interleave. A plain Add(1) followed by a check would let the counter overshoot the limit transiently, and then you would have to undo; the CAS loop never exceeds it. This is a non-blocking semaphore: you take a slot or find out immediately that none is free (semaphores covers the blocking kind).
What the loop costs
Under contention, a CAS loop retries. Every failed swap is wasted work, and with many cores hitting one variable the number of retries per success grows. Compare the "update the max" operation done with a CAS loop and with a mutex, on all cores, with values that mostly are not new maxima (so the fast path returns without writing) and with values that always are:
package main
import (
"sync"
"sync/atomic"
"testing"
)
func casMax(m *atomic.Int64, v int64) {
for {
cur := m.Load()
if v <= cur || m.CompareAndSwap(cur, v) {
return
}
}
}
func BenchmarkCASMaxMostlySmaller(b *testing.B) {
var m atomic.Int64
m.Store(1 << 40)
b.RunParallel(func(pb *testing.PB) {
var i int64
for pb.Next() {
i++
casMax(&m, i)
}
})
}
func BenchmarkMutexMaxMostlySmaller(b *testing.B) {
var mu sync.Mutex
var m int64 = 1 << 40
b.RunParallel(func(pb *testing.PB) {
var i int64
for pb.Next() {
i++
mu.Lock()
if i > m {
m = i
}
mu.Unlock()
}
})
}
func BenchmarkCASMaxAlwaysLarger(b *testing.B) {
var m atomic.Int64
var next atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
casMax(&m, next.Add(1))
}
})
}
func BenchmarkMutexMaxAlwaysLarger(b *testing.B) {
var mu sync.Mutex
var m int64
var next atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
v := next.Add(1)
mu.Lock()
if v > m {
m = v
}
mu.Unlock()
}
})
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkCASMaxMostlySmaller-10 1000000000 0.1710 ns/op 0 B/op 0 allocs/op
BenchmarkCASMaxMostlySmaller-10 1000000000 0.1560 ns/op 0 B/op 0 allocs/op
BenchmarkCASMaxMostlySmaller-10 1000000000 0.1640 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxMostlySmaller-10 10443106 110.8 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxMostlySmaller-10 10911297 112.9 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxMostlySmaller-10 10520620 110.6 ns/op 0 B/op 0 allocs/op
BenchmarkCASMaxAlwaysLarger-10 9607128 122.4 ns/op 0 B/op 0 allocs/op
BenchmarkCASMaxAlwaysLarger-10 10244563 104.0 ns/op 0 B/op 0 allocs/op
BenchmarkCASMaxAlwaysLarger-10 11162482 127.2 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxAlwaysLarger-10 8365801 138.2 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxAlwaysLarger-10 8816250 145.8 ns/op 0 B/op 0 allocs/op
BenchmarkMutexMaxAlwaysLarger-10 8145777 133.0 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 12.654sOn an Apple M1 Max, when most offered values were not new maxima, the CAS version took about 0.16 ns per operation of wall time across ten cores (it only does a Load and returns, which scales with the cores) against 111 ns for the mutex, which serialises every call. When every value was a new maximum, the CAS loop retried heavily and the gap nearly closed: about 122 ns against 138 ns for the mutex, because the value changes under every attempt. (The benchmark's "always larger" cases also contend on the shared next counter, so read the pair as relative, not absolute.) The point is not that CAS or mutex is faster, but that CAS shines when the common case needs no write and loses most of its edge as contention on the write path grows. That makes it a good fit for statistics such as a high-water mark, where most observations change nothing, and a poor fit for a hot counter that every call updates.
The ABA problem
A CAS succeeds if the value equals what you read, not if it is unchanged. If another goroutine changed A to B and back to A in between, your swap succeeds although the world moved. For counters and flags this is harmless. For pointers in a lock-free structure it can corrupt the data: you read the top of a stack, a rival pops it, pops another, pushes the first node back, and your CAS on the top pointer succeeds against a stack whose second element is now different.
In Go the garbage collector helps. A node that a goroutine still references is not freed and reused at the same address, so the classic reuse form of ABA cannot happen for freshly allocated nodes (new(Node) each push). The risk remains if you recycle nodes yourself, for example with a sync.Pool or a free list (sync.Pool), and the cure is to not recycle, or to pair the pointer with a version counter.
A lock-free stack, for illustration
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type node struct {
val int
next *node
}
type Stack struct{ head atomic.Pointer[node] }
func (s *Stack) Push(v int) {
n := &node{val: v}
for {
old := s.head.Load()
n.next = old
if s.head.CompareAndSwap(old, n) {
return
}
}
}
func (s *Stack) Pop() (int, bool) {
for {
old := s.head.Load()
if old == nil {
return 0, false
}
if s.head.CompareAndSwap(old, old.next) {
return old.val, true
}
}
}
func main() {
var s Stack
var wg sync.WaitGroup
for i := 1; i <= 100; i++ {
wg.Go(func() { s.Push(i) })
}
wg.Wait()
sum, count := 0, 0
for {
v, ok := s.Pop()
if !ok {
break
}
sum += v
count++
}
fmt.Println(count, sum)
}100 5050A hundred concurrent pushes lose nothing: 100 items, sum 5050. This is the textbook Treiber stack, and the reason it is safe in Go is the GC argument above. It is a demonstration of the technique, though, and a reason to be cautious: most programs are better served by a mutex-protected slice, which is easier to read, to extend (peeking, sizing, bulk operations) and to verify.
When to use CAS
Use it for a one-shot flag, for a single-variable read-modify-write where the common case is "no change", and for building non-blocking primitives like try-acquire. Don't use it to avoid a lock around a compound operation; two CAS loops on two variables are not atomic together. Don't write lock-free data structures in application code. If you do, test them under the race detector with many goroutines and a long run, and treat any failure as a design flaw. The method is documented per type in sync/atomic, and the Go memory model's atomic section states the ordering guarantees.


