Go by Example

Atomic Counters

Use sync/atomic for lock-free single-variable operations across goroutines.

When multiple goroutines increment a plain integer at the same time, the result is wrong. This happens because counter++ is not one operation -- it reads the current value, adds 1, then writes it back. Two goroutines can read the same value simultaneously and both write the same incremented result, effectively losing one increment. This is called a race condition.

Here is the broken version. Run it with go run -race and Go will report a data race. The final count will be less than 1000.

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    counter := 0 // plain int, NOT safe for concurrent use
 
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // read-modify-write: not atomic
        }()
    }
 
    wg.Wait()
    fmt.Println("counter:", counter) // often less than 1000
}

The fix is to make each increment a single indivisible operation so no two goroutines can interfere with each other. The sync/atomic package provides exactly this. Go 1.19 added typed atomic types (atomic.Int64, atomic.Uint64) that are safer than the older function-based API.

Replace the plain int with atomic.Int64. The Add method increments in one uninterruptible step. Load reads the current value safely. The result is always exactly 1000.

package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
func main() {
    var counter atomic.Int64 // zero value is 0, ready to use
 
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Add(1) // atomic: always correct
        }()
    }
 
    wg.Wait()
    fmt.Println("counter:", counter.Load()) // always 1000
}

Compare-and-swap (CAS) is a more advanced atomic operation. It reads the current value, checks if it matches what you expect, and only if it does, swaps it with a new value -- all in one uninterruptible step. It returns true if the swap happened, false if something else already changed the value first.

A practical example: imagine 5 goroutines all try to claim a background job. Only one should win. You can't use a plain if check because two goroutines might both read false at the same instant and both think they claimed it. CAS prevents this -- only the goroutine that gets there first succeeds, and all others see the updated value and back off.

5 goroutines race to claim a job. Each calls CompareAndSwap(false, true) -- only the first one finds the flag still false and wins. The rest get false back and skip.

package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
func main() {
    var claimed atomic.Bool
 
    var wg sync.WaitGroup
    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            if claimed.CompareAndSwap(false, true) {
                fmt.Printf("goroutine %d claimed the job\n", id)
            } else {
                fmt.Printf("goroutine %d: already claimed, skipping\n", id)
            }
        }(i)
    }
 
    wg.Wait()
}
// exactly one goroutine prints "claimed the job"
// the rest print "already claimed, skipping"

atomic.Pointer[T] lets you swap out an entire struct pointer atomically. This is a common pattern for publishing updated configuration: writers store a new pointer, readers always load the latest one, and no mutex is needed.

The writer allocates a new Config and stores the pointer. Reader goroutines call Load and get either the old or new config -- never a half-written one.

type Config struct{ MaxConns int }
 
var current atomic.Pointer[Config]
 
// writer: publish a new config snapshot
newCfg := &Config{MaxConns: 100}
current.Store(newCfg)
 
// reader: always gets a complete, consistent snapshot
cfg := current.Load()
fmt.Println(cfg.MaxConns)

In production

Atomic operations are cheaper than mutexes for simple counters and flags, but they do not compose. If you need to update two variables together as one unit, you need a mutex. The sync/atomic package is correct only for single-variable operations -- using it for multi-variable invariants is a common subtle bug.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free