Once, Atomics, and RWMutex: Lighter Than a Lock

Three synchronization tools past the plain Mutex — sync.Once for exactly-once setup, the typed sync/atomic values for lock-free counters, and sync.RWMutex for read-heavy state — with when each beats reaching for a full lock. Compiled and run against Go 1.26.5.

The previous chapter gave you the sync.Mutex and the race detector, and a mutex is the honest default: when goroutines share state, guard it with a lock and you are correct. But a plain mutex is a blunt instrument. It serializes everything it protects, readers and writers alike, and it makes you write Lock/Unlock around operations that are sometimes far simpler than a critical section deserves. Go ships three lighter tools for the cases where the full lock is more than you need: sync.Once for one-time setup, the typed values in sync/atomic for lock-free counters and flags, and sync.RWMutex for state that is read far more often than it is written. None of them replaces the mutex. Each one fits a shape the mutex handles clumsily.

Everything here is compiled and run against Go 1.26.5, and the shared-state examples are also run under the race detector with the real output pasted in.

sync.Once: exactly once, even in a stampede

Some work must happen exactly one time no matter how many goroutines ask for it: opening a database pool, parsing a config file, building an expensive lookup table. The naive guard — a boolean you check and set — is itself a race, and two goroutines can both read false before either writes true. sync.Once solves precisely this. It has one method, Do(f), and it guarantees that across every goroutine that ever calls it, f runs once and every caller blocks until that one run finishes.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once
	var count int
	var wg sync.WaitGroup

	init := func() { count++ }

	for range 1000 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			once.Do(init)
		}()
	}
	wg.Wait()

	fmt.Println("init ran", count, "time(s)")
}
init ran 1 time(s)

A thousand goroutines call once.Do(init) at once, and count comes out 1. That is the whole contract: init ran a single time, the other 999 callers found the work already done and returned. Before Once existed you wrote this by hand as double-checked locking — a boolean guarded by a mutex, checked twice — and it was a classic place to get the memory ordering subtly wrong. Once is that pattern, correct, in one method call. Note that count++ here needs no lock of its own, because Once guarantees the function body is never run concurrently with itself. Two things are worth committing to memory. Do blocks all callers until the first invocation returns, so it is safe to read whatever the function initialized the moment Do returns. And the “once” is bound to that specific Once value — a sync.Once is not reusable and not copyable, so it lives as a field or a package-level variable, never passed by value.

Typed atomics: a counter with no lock

A mutex protecting a single integer counter is correct but heavy. Every increment takes and releases a lock. For that shape — one number, read and bumped by many goroutines — the sync/atomic package offers operations the CPU performs indivisibly, with no lock at all. Since Go 1.19 these come as typed values: atomic.Int64, atomic.Int32, atomic.Uint64, atomic.Bool, and atomic.Pointer[T]. Prefer them over the older free functions like atomic.AddInt64; the typed wrappers can’t be read or written non-atomically by accident, because the underlying field is unexported and the only way to touch it is through the atomic methods.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter atomic.Int64
	var wg sync.WaitGroup

	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				counter.Add(1)
			}
		}()
	}
	wg.Wait()

	fmt.Println("counter =", counter.Load())
}
counter = 100000

A hundred goroutines each add one a thousand times, and the counter lands on exactly 100000 with no mutex anywhere. .Add(1) performs the read-modify-write as one indivisible step, and .Load() reads the current value safely. The interesting proof is the race detector. Recall from the last chapter that an unguarded count++ from many goroutines is a data race the detector flags loudly. This code is doing conceptually the same thing — many goroutines writing one shared integer — yet it is not a race, because the atomic operations are synchronized. Running it under -race confirms it:

$ go run -race .
counter = 100000

No race report, exact result. That silence is the point: an atomic write is a properly synchronized write, so the detector has nothing to complain about.

Atomics do more than count. atomic.Bool gives you a flag many goroutines can flip and check, and its CompareAndSwap is the primitive that makes lock-free algorithms possible — set the value only if it currently equals what you expect, atomically, reporting whether you won:

var done atomic.Bool
fmt.Println("first CAS swapped:", done.CompareAndSwap(false, true))
fmt.Println("second CAS swapped:", done.CompareAndSwap(false, true))
first CAS swapped: true
second CAS swapped: false

The first swap sees false, matches, and flips to true; the second sees true, fails to match, and does nothing. Exactly one caller can “win” that transition — which is how you’d elect a single goroutine to run some finalization without a lock. atomic.Pointer[T] extends the same idea to whole values: you can atomically swap in a fresh pointer to an immutable config struct, and readers always see a complete old-or-new value, never a half-updated one.

The boundary is sharp and worth stating. Atomics work on one word — a single integer, bool, or pointer. The moment your invariant spans two fields (“decrement balance and append to the ledger, together”), a single atomic can’t express it and you are back to a mutex. Atomics are for the one-variable case, not a general escape from locking.

sync.RWMutex: many readers or one writer

The plain mutex treats every access as exclusive, but plenty of state is read constantly and written rarely: a routing table, a feature-flag set, a cached config. Forcing readers to wait for each other wastes the concurrency you have. sync.RWMutex splits the lock in two. RLock/RUnlock take a read lock that any number of readers can hold simultaneously, while Lock/Unlock take a write lock that is fully exclusive — it waits for all current readers to finish and blocks new ones until the write is done.

type config struct {
	mu     sync.RWMutex
	values map[string]string
}

func (c *config) get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.values[key]
}

func (c *config) set(key, val string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.values[key] = val
}

Driving that with one writer and fifty concurrent readers and running under the race detector:

$ go run -race .
mode = prod

Clean under -race, and the fifty readers holding RLock at the same time never block one another. Only the lone set call needs the whole map to itself. That is the win: read-heavy state scales with the number of readers instead of serializing them one at a time.

Now the honest caveat, because this is where people over-reach. An RWMutex is not a free upgrade over a Mutex. It carries more bookkeeping, so under low contention or a roughly even read/write mix it can be slower than the plain lock it replaced. It pays off only when reads genuinely dominate and each read holds the lock long enough to matter. When in doubt, start with a sync.Mutex — it is simpler, and simpler is faster to reason about. Reach for RWMutex when you have measured a read-heavy hotspot, not on a hunch that reads “should” be parallel.

sync.Cond: waiting for a condition to become true

Sometimes the problem is not “guard this state” but “block until this state changes.” A pool of consumers should sleep while a queue is empty and wake the instant something arrives; a set of workers should idle until a shared flag says “go.” You could poll in a loop with a sleep, but that burns CPU and adds latency. sync.Cond is the tool built for it: a condition variable paired with a lock, with three methods. Wait atomically unlocks and suspends the caller until it is woken; Signal wakes one waiter; Broadcast wakes all of them.

var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false

var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		mu.Lock()
		for !ready {
			cond.Wait()
		}
		mu.Unlock()
		fmt.Printf("worker %d saw ready\n", id)
	}()
}

mu.Lock()
ready = true
cond.Broadcast()
mu.Unlock()

wg.Wait()
worker 2 saw ready
worker 1 saw ready
worker 3 saw ready
all workers proceeded

Three workers each lock the mutex, see ready is false, and call cond.Wait() — which releases the lock and parks them. Main then locks, sets ready, and calls Broadcast, waking all three; each reacquires the lock inside Wait before returning, rechecks the condition, and proceeds. The one non-negotiable detail is that Wait is wrapped in for !ready, never a plain if. A woken goroutine is not guaranteed the condition is still true — it may have been woken spuriously, or another waiter may have consumed the state first — so it must re-test the predicate and go back to sleep if it does not hold. The loop is the correctness. You also must hold the lock around both the wait and the state change, because Cond synchronizes the waiting, not the shared variable it waits on.

In practice Cond is niche. For most producer/consumer work a buffered channel is clearer and harder to misuse, and that is the idiomatic Go answer. Reach for Cond when many goroutines wait on one shared predicate that a channel models awkwardly — a “broadcast to everyone that the state changed” moment like the one above.

sync.Map: the concurrent map, for narrow cases

The ordinary Go map is not safe for concurrent use: two goroutines writing at once is a data race the detector catches and the runtime may crash on outright. The default fix is the one from the last chapter — wrap the map in a sync.Mutex (or RWMutex) and lock around every access. That is the right answer for almost every concurrent map you will write. sync.Map exists for the few cases where it is not.

var m sync.Map

// ... several goroutines each do:
m.Store(fmt.Sprintf("key-%d", i), i*i)

// then:
if v, ok := m.Load("key-3"); ok {
	fmt.Println("key-3 =", v)
}
m.Range(func(k, v any) bool {
	// visit every pair; return false to stop early
	return true
})
key-3 = 9
all: [key-0=0 key-1=1 key-2=4 key-3=9 key-4=16]

Store, Load, and Range do what they say, all safe to call from any goroutine with no lock of your own. Note the cost baked into the signature: keys and values are any, so you give up compile-time types and pay for boxing on every operation. That is a real tax, and it is why sync.Map is a specialist, not a default.

The documentation names the two shapes it actually wins on. One is an append-only or write-once-read-many cache, where an entry is set once and then read repeatedly. The other is when goroutines touch disjoint sets of keys — each goroutine owns its own keys and they rarely overlap. Both let sync.Map’s internal design avoid lock contention that a single mutex would serialize. For everything else — a map with a mix of reads and writes across shared keys — a plain map behind a sync.Mutex is simpler, typed, and usually faster. Measure before you reach for sync.Map; the intuition that “concurrent map means sync.Map” is wrong more often than it is right.

sync.Pool: reusing allocations to ease GC

When a program repeatedly allocates and discards short-lived objects of the same type — a buffer per request, a scratch slice per job — the allocations pile up and the garbage collector has to keep sweeping them. sync.Pool is a free list that lets you recycle those objects instead. Get returns a pooled object or, if the pool is empty, calls your New function to make one; Put hands an object back for reuse. Both are safe across goroutines.

var pool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

// in each of 1000 goroutines:
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
fmt.Fprintf(buf, "job %d payload", i)
total += int64(buf.Len())
pool.Put(buf)
total bytes formatted: 14890

A thousand goroutines each grab a buffer, use it, and return it, and far fewer than a thousand buffers are ever allocated — the pool hands the same ones back out as they come free. Two disciplines make it correct. Always Reset (or otherwise clear) an object from Get, because it carries whatever the last user left in it. And only pool objects whose reuse is genuinely cheaper than reallocation; for tiny values the pool’s own bookkeeping can cost more than it saves.

The caveat that trips people up: a pooled object can vanish at any garbage collection. The pool is a cache the runtime is free to empty, and it clears unreferenced pooled objects on GC. So a Pool is only ever a performance optimization, never a place to keep something you need — you cannot use it as a fixed-size resource pool or a leak-proof cache. Put an object in, and it may or may not still be there next time. That is fine for its actual job, which is shaving allocation pressure off a hot path, and wrong for anything else.

OnceFunc, OnceValue, OnceValues: the ergonomic wrappers

sync.Once from the top of this chapter has an awkward edge: it runs a function once, but if that function computes a value you want later, you have to stash the result in a variable outside the closure and read it back yourself. Go 1.21 added three wrappers that fold that pattern into the return value. OnceFunc(f) returns a function that runs f a single time. OnceValue(f) returns a function that runs f once and returns its cached result on every call. OnceValues(f) is the same for a function returning two values.

var calls atomic.Int64

expensive := sync.OnceValue(func() int {
	calls.Add(1)
	return 42
})

// call it from 100 goroutines, then once more:
fmt.Println("value:", expensive())
fmt.Println("computations:", calls.Load())
value: 42
computations: 1

A hundred goroutines call expensive(), then main calls it once more, and the body ran exactly once — every call after the first returns the cached 42 without recomputing. This is the natural fit for lazy initialization that produces something: a parsed config, a compiled regexp, an opened connection. Instead of a package-level sync.Once plus a separate result variable plus a getter, you write one OnceValue and call it wherever you need the value. It is the same exactly-once guarantee as Once.Do, with the result threaded through so you never hand-roll the storage.

Final thoughts

Three tools, three shapes the plain mutex fits poorly. sync.Once runs setup exactly once under any amount of concurrent pressure, blocking every caller until that single run completes — reach for it for lazy initialization. The typed sync/atomic values (Int64, Bool, Pointer[T]) give you lock-free reads and writes on a single word, verified here to produce an exact count with a clean race report and no mutex at all — reach for them for counters and flags, but only while your invariant fits in one variable. And sync.RWMutex lets readers proceed in parallel while writers stay exclusive — reach for it for read-heavy state, and default back to the plain Mutex otherwise. Past those, the rest of the sync package is a set of narrower specialists: sync.Cond to block goroutines until a shared predicate flips, always inside a for loop; sync.Map for the append-only or disjoint-key cases where a mutex-wrapped map contends; sync.Pool to recycle short-lived allocations off a hot path, remembering that pooled objects vanish on a GC; and the OnceValue/OnceFunc/OnceValues wrappers that thread a result through the exactly-once guarantee. The mutex from the previous chapter remains the baseline you reach for when nothing lighter fits; everything here is a specialist that beats it on its own home turf.

Next: context: cancellation that propagates — how you cancel and time-bound concurrent work, and why the signal flows downward through every derived context.

Comments