Skip to content
gohacks.dev
Concurrency

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.

8 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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:

main.goGo
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
}
Text
true 20
false 20

The 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:

main.goGo
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())
}
Text
goroutines that won the race: 1

Twenty 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:

  1. Load the current value.
  2. Compute the new value from it.
  3. CompareAndSwap(current, new).
  4. If it failed, someone else changed the value; go back to step 1.
main.goGo
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())
}
Text
peak: 1000

A 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:

main.goGo
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)")
}
Text
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:

cas_test.goGo
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()
		}
	})
}
Text
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.654s

On 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

main.goGo
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)
}
Text
100 5050

A 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.