Sync and Atomics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. Channels are Go’s preferred way to pass data between goroutines, but they are not the right tool for every problem: protecting a map that several goroutines read and write, waiting for a group of goroutines to finish, running an initialization exactly once, or bumping a counter are all simpler with the primitives of the sync and sync/atomic packages. This page covers each of them, the mistakes they invite and the checks that catch those mistakes (go vet and the race detector). It builds on Goroutines and the Scheduler, Channels and Select and Concurrency Patterns; the formal rules that make these primitives work are in Memory Model and Data Races. For the same ideas in another language see Shared State, Send, Sync and Atomics in the Rust Reference.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (the vet messages and the race report are likewise copied from real runs). The go.mod of the example module is:

module example.com/shelf

go 1.27

The package documentation opens with a deliberately modest sentence: "Other than the Once and WaitGroup types, most are intended for use by low-level library routines. Higher-level synchronization is better done via channels and communication." Read it as a ranking, not a prohibition: reach for channels when goroutines hand work to each other, and for sync when they merely share state.

Decision chart with five questions on the left and one answer each on the right. Handing data or ownership between goroutines leads to a channel and select. Only waiting for goroutines or initializing once leads to WaitGroup and Once with its OnceFunc and OnceValue variants. A single counter, flag or pointer leads to a typed atomic value. Shared state with an invariant leads to a Mutex, or an RWMutex when reads dominate. Sleeping until a condition on that state holds leads to a Cond on the same Mutex, or a channel that is closed when ready.

Why shared state needs synchronization

Two goroutines that touch the same variable, at least one of them writing, without anything that orders the accesses are in a data race. The result is not "a slightly wrong number": the language gives racy programs very little guarantee (see Memory Model and Data Races), and a race on a map can crash the whole program with fatal error: concurrent map writes. The following program increments a plain int from two goroutines:

package main

import (
	"fmt"
	"sync"
)

func main() {
	loans := 0
	var wg sync.WaitGroup
	for range 2 {
		wg.Go(func() {
			for range 1000 {
				loans++ // unsynchronized read-modify-write
			}
		})
	}
	wg.Wait()
	fmt.Println(loans > 0)
}

Run normally, it prints true and nothing complains, because loans > 0 hides the lost updates. Run with the race detector (go run -race, go test -race or go build -race), it reports the conflicting accesses:

==================
WARNING: DATA RACE
Read at 0x00c00019e038 by goroutine 8:
  main.main.func1()
      .../main.go:14 +0x38

Previous write at 0x00c00019e038 by goroutine 7:
  main.main.func1()
      .../main.go:14 +0x48

Goroutine 8 (running) created at:
  sync.(*WaitGroup).Go()
      .../sync/waitgroup.go:238 +0x6c

Goroutine 7 (finished) created at:
  sync.(*WaitGroup).Go()
      .../sync/waitgroup.go:238 +0x6c
==================
Found 2 data race(s)
exit status 66

The report was shortened (paths replaced by ... and the second report, a write/write conflict on the same line, omitted); addresses, goroutine numbers and the order of reports differ from run to run. The format, the GORACE options and the cost of the detector are covered in Memory Model and Data Races. The rest of this page uses -race as the safety net: every example below that shares state was also run with it and was clean.

sync.Mutex

A sync.Mutex is a mutual exclusion lock. Its zero value is an unlocked mutex, so a struct can embed one without a constructor. Lock blocks until the mutex is free and takes it; Unlock releases it. The code between them is the critical section; the invariant of the protected data holds before Lock and again before Unlock, and may be broken in between. The memory model gives the guarantee that makes this work: for any sync.Mutex, the n-th call to Unlock is synchronized before the m-th call to Lock returns, for n < m, so everything written inside one critical section is visible in the next.

The idiom is to put the mutex right above the fields it guards and to release it with defer, so that every return path (including a panic) unlocks:

package main

import (
	"fmt"
	"sync"
)

// Book is a catalogue entry.
type Book struct {
	ISBN  string
	Title string
}

// MemStore is an in-memory catalogue guarded by a mutex.
type MemStore struct {
	mu    sync.Mutex
	books map[string]Book
}

// NewMemStore returns an empty store.
func NewMemStore() *MemStore {
	return &MemStore{books: make(map[string]Book)}
}

// Add stores a book.
func (s *MemStore) Add(b Book) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.books[b.ISBN] = b
}

// Get returns a book by ISBN.
func (s *MemStore) Get(isbn string) (Book, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	b, ok := s.books[isbn]
	return b, ok
}

// Len returns the number of books.
func (s *MemStore) Len() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.books)
}

func main() {
	s := NewMemStore()
	var wg sync.WaitGroup
	for i := range 100 {
		wg.Go(func() {
			s.Add(Book{ISBN: fmt.Sprintf("isbn-%03d", i), Title: fmt.Sprintf("Title %d", i)})
		})
	}
	wg.Wait()
	b, ok := s.Get("isbn-042")
	fmt.Println(s.Len(), b.Title, ok)
}
100 Title 42 true

Source: sync: Mutex

This is the Store of the shelf scenario in its simplest form. Notes on the rules:

  • A mutex is not reentrant. A goroutine that calls Lock twice without an Unlock in between waits for itself forever. When every goroutine is blocked the runtime detects it and aborts (the first line of the trace is shown; the rest is a stack dump):

    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    func main() {
    	var mu sync.Mutex
    	mu.Lock()
    	fmt.Println("locked once")
    	mu.Lock() // not reentrant: waits for itself
    	fmt.Println("never printed")
    }
    locked once
    fatal error: all goroutines are asleep - deadlock!
    
    goroutine 1 [sync.Mutex.Lock]:

    In a real server other goroutines keep running, so the same bug is a silent hang, not a crash. The practical rule: a method that holds the lock must not call another method that takes the same lock. Split the work into an exported method that locks and an unexported helper (often suffixed Locked) that assumes the lock is held.

  • Unlocking a mutex that is not locked is a fatal error, not a recoverable panic, so recover cannot save the program:

    package main
    
    import "sync"
    
    func main() {
    	var mu sync.Mutex
    	mu.Unlock()
    }
    fatal error: sync: unlock of unlocked mutex
  • A locked mutex is not tied to a goroutine: one goroutine may lock it and another unlock it. That is legal, but it is rarely a good design, because the reader of the code can no longer see the matching Unlock.

  • Keep critical sections short and never hold a lock across slow operations such as network calls, or across a channel send that may block, or you turn the mutex into a bottleneck and invite deadlocks. Copy the data out under the lock and work on the copy.

  • When two locks are needed, always take them in the same order everywhere; two goroutines that take them in opposite orders can each hold one lock and wait for the other.

  • The implementation has a normal mode and a starvation mode (since Go 1.9): a waiter that cannot get the lock for about a millisecond makes the mutex hand ownership directly to the longest waiter, so a busy mutex cannot starve a goroutine forever. You never configure it.

Copying a mutex: copylocks

Because a mutex holds state, copying one copies the state. A copy of a locked mutex is locked forever, and a copy of an unlocked one protects nothing shared. The package documentation says "Values containing the types defined in this package should not be copied", and go vet enforces it with the copylocks analyzer. The classic trap is a value receiver:

package main

import (
	"fmt"
	"sync"
)

// Counter counts loans.
type Counter struct {
	mu sync.Mutex
	n  int
}

// Inc increments the counter.
func (c Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func main() {
	var c Counter
	c2 := c
	c.Inc()
	fmt.Println(c.n, c2.n)
}

The program compiles and prints 0 0: Inc incremented the field of its own copy. go vet explains why:

./main.go:15:9: Inc passes lock by value: example.com/shelf/e03.Counter contains sync.Mutex
./main.go:23:8: assignment copies lock value to c2: example.com/shelf/e03.Counter contains sync.Mutex

copylocks also reports lock values passed as function arguments, returned, ranged over, or captured in composite literals. The fix is a pointer receiver, and a type that contains a mutex should have pointer receivers only (see Methods and Receivers) and be passed around as a pointer:

package main

import (
	"fmt"
	"sync"
)

// Counter counts loans. It must not be copied after first use.
type Counter struct {
	mu sync.Mutex
	n  int
}

// Inc increments the counter; the pointer receiver shares the mutex.
func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

// Value returns the current count.
func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

func main() {
	c := &Counter{}
	var wg sync.WaitGroup
	for range 50 {
		wg.Go(c.Inc)
	}
	wg.Wait()
	fmt.Println(c.Value())
}
50

The same rule applies to WaitGroup, Once, Cond, Pool, Map, RWMutex and every type in sync/atomic: they all contain a noCopy marker that copylocks recognizes.

Embedding a mutex or using a named field

A mutex can be embedded in the struct or held in a named field. Embedding promotes Lock, Unlock and TryLock into the method set of the enclosing type (see Structs and Embedding):

package main

import (
	"fmt"
	"sync"
)

// Embedded exposes Lock and Unlock to every caller.
type Embedded struct {
	sync.Mutex
	n int
}

// Named keeps the mutex private.
type Named struct {
	mu sync.Mutex
	n  int
}

// Inc increments under the lock.
func (c *Named) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func main() {
	var e Embedded
	e.Lock() // part of the public API of Embedded
	e.n++
	e.Unlock()

	var l sync.Locker = &e // *Embedded now satisfies sync.Locker
	l.Lock()
	l.Unlock()

	var n Named
	n.Inc()
	// n.Lock() would not compile: Named has no method Lock.
	fmt.Println(e.n, n.n)
}
1 1

Prefer the named, unexported field (mu sync.Mutex) for exported types. Embedding makes the locking protocol part of the type’s public API: any importing package can call Lock and hold it for as long as it likes, and you can never change the locking strategy (say, to an RWMutex or an atomic) without breaking callers. Embedding is acceptable inside an unexported type, or when the type is deliberately a lockable thing that implements sync.Locker. Put the mutex directly above the fields it protects and say so in a comment, as the standard library does.

TryLock

TryLock (since Go 1.18) tries to take the lock without blocking and reports whether it succeeded; RWMutex has TryLock and TryRLock too. The documentation itself warns that "correct uses of TryLock are rare"; the legitimate uses are "skip this optional work if someone else is already doing it" and opportunistic fast paths.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex
	fmt.Println("first TryLock:", mu.TryLock())
	fmt.Println("second TryLock:", mu.TryLock())
	mu.Unlock()
	fmt.Println("after Unlock:", mu.TryLock())
	mu.Unlock()

	var rw sync.RWMutex
	fmt.Println("TryRLock:", rw.TryRLock())
	fmt.Println("second TryRLock:", rw.TryRLock())
	fmt.Println("TryLock with readers:", rw.TryLock())
	rw.RUnlock()
	rw.RUnlock()
	fmt.Println("TryLock without readers:", rw.TryLock())
}
first TryLock: true
second TryLock: false
after Unlock: true
TryRLock: true
second TryRLock: true
TryLock with readers: false
TryLock without readers: true

Do not build a polling loop out of TryLock; if you need to wait with a timeout, use a channel of capacity one as a lock (a "semaphore" channel) or golang.org/x/sync/semaphore.

sync.RWMutex

A sync.RWMutex allows any number of concurrent readers or one writer. RLock/RUnlock take and release the shared read lock; Lock/Unlock take the exclusive write lock; RLocker() returns a sync.Locker view of the read side. It pays off when reads clearly dominate and the critical section is long enough that parallel readers help:

package main

import (
	"fmt"
	"sync"
)

// Cache is a read-mostly lookup table.
type Cache struct {
	mu    sync.RWMutex
	items map[string]string
}

// Get takes a read lock, so many readers run together.
func (c *Cache) Get(k string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.items[k]
	return v, ok
}

// Set takes the exclusive write lock.
func (c *Cache) Set(k, v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.items == nil {
		c.items = make(map[string]string)
	}
	c.items[k] = v
}

func main() {
	var c Cache
	c.Set("978-0-13-468599-1", "The Go Programming Language")

	var wg sync.WaitGroup
	results := make([]string, 4)
	for i := range results {
		wg.Go(func() {
			results[i], _ = c.Get("978-0-13-468599-1")
		})
	}
	wg.Wait()
	fmt.Println(results)
}
[The Go Programming Language The Go Programming Language The Go Programming Language The Go Programming Language]

Source: sync: RWMutex

The rules that differ from Mutex:

  • Once a goroutine is blocked in Lock, new RLock calls block until the writer has finished, so writers are not starved. As a consequence, a goroutine must not take the read lock recursively: if a writer arrives between the two RLock calls, the second one waits for the writer, which waits for the first read lock, and the program deadlocks.

  • A read lock may not be upgraded to a write lock; release it and take the write lock, then re-check the condition.

  • Writing under only a read lock is a data race that the compiler and vet cannot see; keep the read side strictly read-only (including lazy caching, which is a write).

  • An RWMutex is more expensive than a Mutex per operation. Measure before choosing it (see Benchmarks, Fuzzing and synctest); with short critical sections a plain Mutex is often faster.

sync.WaitGroup

A sync.WaitGroup waits for a collection of goroutines to finish. It holds a counter: Add(n) increases it, Done() is Add(-1), and Wait() blocks until the counter is zero. The call that makes a goroutine finish (“Done”) is synchronized before the Wait it unblocks, so results written by the goroutines are visible after Wait.

WaitGroup.Go (Go 1.25)

Go 1.25 added WaitGroup.Go(f), which does the Add(1), starts a goroutine running f, and calls Done when f returns, even if f calls runtime.Goexit. It removes the most common WaitGroup mistakes (a forgotten Done, Add in the wrong place) and has been used in every example above. The old and the new shape side by side, in one program:

package main

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

func main() {
	var wg sync.WaitGroup
	var done atomic.Int32
	for i := range 5 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			done.Add(int32(i))
		}()
	}
	wg.Wait()
	fmt.Println(done.Load())

	var wg2 sync.WaitGroup
	var sum atomic.Int32
	for i := range 5 {
		wg2.Go(func() { sum.Add(int32(i)) })
	}
	wg2.Wait()
	fmt.Println(sum.Load())
}
10
10

(The loop variable i is a fresh variable on every iteration since Go 1.22, so the closures capture the right value; see Control Flow.) Rules for Go:

  • f must not panic: a panic in a goroutine that nothing recovers terminates the program, exactly as with a plain go statement. WaitGroup.Go does not collect errors or cancel siblings; for that use errgroup from golang.org/x/sync.

  • If the group is empty, Go must happen before Wait (call it in the goroutine that waits, before waiting). If the group is not empty, a goroutine started by Go may itself call Go.

  • A WaitGroup may be reused for a new set of tasks only after every earlier Wait has returned.

  • Pass a WaitGroup by pointer. A copy has its own counter, and copylocks flags the copy.

The classic form and its mistakes

Code written before Go 1.25, and code that needs to run Done from somewhere else, uses Add and Done directly. The rule is that Add with a positive delta, when the counter is zero, must happen before Wait: call it in the goroutine that launches the work, never inside the new goroutine. The waitgroup analyzer of go vet (Go 1.25) catches the common mistake:

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	for range 3 {
		go func() {
			wg.Add(1) // races with Wait: Wait may already have returned
			defer wg.Done()
		}()
	}
	wg.Wait()
}
./main.go:9:10: WaitGroup.Add called from inside new goroutine

Calling Done more often than Add is a panic, not a fatal error:

package main

import "sync"

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	wg.Done()
	wg.Done()
}
panic: sync: negative WaitGroup counter

The forgotten-Done mistake has no run-time message at all: Wait blocks forever, and if nothing else is running, the runtime reports a deadlock. Since Go 1.25 go vet also has the waitgroup check; in Go 1.27 the go fix modernizer of the same name was renamed waitgroupgo to avoid the ambiguity, and it rewrites the Add(1)/go func/defer Done shape automatically. Running it on the first loop of the program above:

go fix -diff ./...
@@ -10,11 +10,9 @@
 	var wg sync.WaitGroup
 	var done atomic.Int32
 	for i := range 5 {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+		wg.Go(func() {
 			done.Add(int32(i))
-		}()
+		})
 	}
 	wg.Wait()
 	fmt.Println(done.Load())

(The file header lines of the diff are omitted.) go fix (without -diff) applies the change; the modernizers it runs are listed by go tool fix help. See Formatting, vet, fix and Static Analysis.

sync.Once and its function forms

sync.Once runs a function exactly once, no matter how many goroutines call Do; all of them wait until the first call returns, and the return is synchronized before every Do return. It is the standard way to initialize something lazily and safely (a compiled regular expression, a connection pool, a configuration file). Go 1.21 added three helpers that remove the boilerplate: OnceFunc returns a function that calls its argument once, and OnceValue/OnceValues do the same for functions that return one or two results, caching them:

package main

import (
	"fmt"
	"sync"
)

type Config struct{ Name string }

func main() {
	var once sync.Once
	for i := range 3 {
		once.Do(func() { fmt.Println("initialised on call", i) })
	}

	load := sync.OnceValue(func() Config {
		fmt.Println("loading config")
		return Config{Name: "shelf"}
	})
	fmt.Println(load().Name, load().Name)

	open := sync.OnceValues(func() (int, error) {
		fmt.Println("opening")
		return 0, fmt.Errorf("cannot open catalogue")
	})
	_, err1 := open()
	_, err2 := open()
	fmt.Println(err1, err2)

	hello := sync.OnceFunc(func() { fmt.Println("hello once") })
	hello()
	hello()
}
initialised on call 0
loading config
shelf shelf
opening
cannot open catalogue cannot open catalogue
hello once

Source: sync: OnceValue

Details worth knowing:

  • OnceValues caches the error too: a failed initialization is not retried. If you want retry-on-failure, use a mutex and a nil check, or an atomic.Pointer.

  • Do(f) with a f that calls Do on the same Once deadlocks, because the inner call waits for the outer one.

  • If f panics, Do considers the call finished: later calls do not run f again. OnceFunc, OnceValue and OnceValues instead remember the panic and panic again with the same value on every call:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once
	for i := range 2 {
		func() {
			defer func() { fmt.Println("recovered:", recover()) }()
			once.Do(func() { panic(fmt.Sprintf("first call %d", i)) })
			fmt.Println("Do returned normally on call", i)
		}()
	}

	f := sync.OnceFunc(func() { panic("boom") })
	for range 2 {
		func() {
			defer func() { fmt.Println("recovered:", recover()) }()
			f()
		}()
	}
}
recovered: first call 0
Do returned normally on call 1
recovered: <nil>
recovered: boom
recovered: boom

Source: sync: OnceFunc

A package-level var initialized by a function call, or an init function, is initialized exactly once before main and needs no Once at all (see Packages, Imports and Initialization); use Once for work that should be deferred until first use.

sync.Cond

A sync.Cond lets goroutines sleep until a condition on shared state becomes true. It is created with sync.NewCond(l) around a sync.Locker (usually a *sync.Mutex). The protocol is fixed:

  • Wait must be called with L held. It atomically unlocks L and suspends the goroutine, and relocks L before returning.

  • Because the condition may be false again when the goroutine wakes (another consumer got there first, or the wake-up was a Broadcast for something else), Wait must always be called in a loop that re-checks the condition.

  • Signal wakes one waiting goroutine, Broadcast wakes all of them. The caller may, but need not, hold L.

  • A Cond must not be copied after first use.

sequenceDiagram participant C as Consumer participant M as Mutex participant P as Producer C->>M: Lock C->>C: queue empty, call Wait C->>M: Wait unlocks and sleeps P->>M: Lock P->>P: append item P->>M: Unlock P-->>C: Signal wakes the consumer C->>M: Wait relocks before returning C->>C: loop re-checks, queue not empty C->>M: Unlock

A blocking queue of ISBNs to look up, with several consumers and a way to shut them down:

package main

import (
	"fmt"
	"sync"
)

// Queue is a blocking FIFO of ISBNs.
type Queue struct {
	mu     sync.Mutex
	ready  *sync.Cond
	items  []string
	closed bool
}

// NewQueue returns an empty queue.
func NewQueue() *Queue {
	q := &Queue{}
	q.ready = sync.NewCond(&q.mu)
	return q
}

// Put appends an item and wakes one waiting consumer.
func (q *Queue) Put(s string) {
	q.mu.Lock()
	q.items = append(q.items, s)
	q.mu.Unlock()
	q.ready.Signal()
}

// Close wakes every consumer so it can observe the closed flag.
func (q *Queue) Close() {
	q.mu.Lock()
	q.closed = true
	q.mu.Unlock()
	q.ready.Broadcast()
}

// Get blocks until an item is available or the queue is closed.
func (q *Queue) Get() (string, bool) {
	q.mu.Lock()
	defer q.mu.Unlock()
	for len(q.items) == 0 && !q.closed {
		q.ready.Wait() // unlocks q.mu while waiting, relocks before returning
	}
	if len(q.items) == 0 {
		return "", false
	}
	s := q.items[0]
	q.items = q.items[1:]
	return s, true
}

func main() {
	q := NewQueue()
	var wg sync.WaitGroup
	var mu sync.Mutex
	got := 0
	for range 3 {
		wg.Go(func() {
			for {
				if _, ok := q.Get(); !ok {
					return
				}
				mu.Lock()
				got++
				mu.Unlock()
			}
		})
	}
	for i := range 10 {
		q.Put(fmt.Sprintf("isbn-%d", i))
	}
	q.Close()
	wg.Wait()
	fmt.Println("consumed:", got)
}
consumed: 10

Source: sync: Cond

Cond is rarely the best choice in Go. A buffered channel already is a blocking queue, and "something happened" broadcasts are usually better expressed by closing a channel, which also composes with select, timeouts and context cancellation, none of which Cond.Wait supports. Use Cond when the condition involves several fields under one lock and you need Broadcast.

sync.Map

sync.Map (since Go 1.9) is a map safe for concurrent use without external locking. Its API is untyped (any keys and values) and it is a specialized tool; the documentation says that most code should use a plain map with its own Mutex or RWMutex for type safety and to keep other invariants next to the data. It is faster than a locked map in two situations only: (1) an entry is written once and read many times, as in caches that only grow, and (2) several goroutines read, write and overwrite disjoint sets of keys. Its operations are Load, Store, LoadOrStore, LoadAndDelete, Delete, Range, plus Swap, CompareAndSwap and CompareAndDelete (Go 1.20) and Clear (Go 1.23). Go 1.24 changed the implementation to a concurrent hash-trie, which improved performance, and GOEXPERIMENT=nosynchashtriemap at build time restores the old one if you hit a problem.

package main

import (
	"fmt"
	"slices"
	"sync"
)

func main() {
	var m sync.Map
	m.Store("isbn-1", "Dune")
	m.Store("isbn-2", "Emma")

	v, ok := m.Load("isbn-1")
	fmt.Println(v, ok)

	actual, loaded := m.LoadOrStore("isbn-1", "Other")
	fmt.Println(actual, loaded)
	actual, loaded = m.LoadOrStore("isbn-3", "Ulysses")
	fmt.Println(actual, loaded)

	prev, loaded := m.Swap("isbn-2", "Emma (2nd ed.)")
	fmt.Println(prev, loaded)
	fmt.Println(m.CompareAndSwap("isbn-2", "Emma", "x"), m.CompareAndSwap("isbn-2", "Emma (2nd ed.)", "Emma"))

	v, loaded = m.LoadAndDelete("isbn-3")
	fmt.Println(v, loaded)
	fmt.Println(m.CompareAndDelete("isbn-1", "Wrong"))

	var keys []string
	m.Range(func(k, v any) bool {
		keys = append(keys, k.(string)+"="+v.(string))
		return true
	})
	slices.Sort(keys) // Range order is unspecified
	fmt.Println(keys)

	m.Clear()
	_, ok = m.Load("isbn-1")
	fmt.Println(ok)
}
Dune true
Dune true
Ulysses false
Emma true
false true
Ulysses true
false
[isbn-1=Dune isbn-2=Emma]
false

Source: sync: Map

Points to remember: Range does not take a snapshot and its order is unspecified (sort if you need determinism, as above); CompareAndSwap and CompareAndDelete panic if the stored value is not comparable; a sync.Map must not be copied after first use; and values come back as any, so each read needs a type assertion (a wrapper type with typed methods is a common cure). For a plain map that is not shared, see Maps.

sync.Pool

A sync.Pool is a set of temporary objects that can be saved and reused to take pressure off the garbage collector. Get returns an arbitrary item from the pool (or calls the pool’s New function, or returns nil if New is not set); Put returns an item. The package fmt uses a pool of buffers for exactly this reason.

package main

import (
	"bytes"
	"fmt"
	"sync"
)

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

func render(title string) string {
	b := bufPool.Get().(*bytes.Buffer)
	defer func() {
		b.Reset() // always reset before returning to the pool
		bufPool.Put(b)
	}()
	fmt.Fprintf(b, "<h1>%s</h1>", title)
	return b.String() // String copies, so b can be reused
}

func main() {
	fmt.Println(render("Dune"))
	fmt.Println(render("Emma"))
}
<h1>Dune</h1>
<h1>Emma</h1>

Source: sync: Pool

The contract is weaker than it looks:

  • The pool may drop any item at any time, and the garbage collector clears it (a "victim cache" keeps items for one extra cycle). It is a cache of garbage, not a store: never keep state in a pooled object that you need later, and never use a pool to hold resources that need closing, such as connections.

  • Reset the object before Put (or after Get), and never use it after Put.

  • Put pointers (or slices wrapped in a pointer). Putting a non-pointer value into the any parameter allocates, which defeats the purpose; staticcheck reports it as SA6002.

  • A pool only helps when allocation is measurably hot; verify with a benchmark and -benchmem (see Benchmarks, Fuzzing and synctest and Memory Management and Garbage Collection).

  • The memory model orders a Put(x) before the Get that returns that same x.

sync/atomic

The sync/atomic package provides atomic memory operations: indivisible read, write, swap, add and compare-and-swap on a single memory word. They are the building blocks of the other primitives and are useful for statistics counters, flags and lock-free publication of immutable data. In the vocabulary of the memory model, if the effect of an atomic operation A is observed by atomic operation B, then A synchronizes before B, and all atomic operations behave as if executed in one sequentially consistent order (the same semantics as C++'s sequentially consistent atomics and Java’s volatile). Go provides no weaker orderings.

The typed values (Go 1.19)

Since Go 1.19 the preferred API is the family of types: atomic.Int32, Int64, Uint32, Uint64, Uintptr, Bool, Pointer[T] and the older Value. Each has Load, Store, Swap and CompareAndSwap; the integer types also have Add and, since Go 1.23, And and Or, which return the old value. They cannot be copied (copylocks flags it), are safe to use only through their methods, and guarantee the 64-bit alignment that the old function API needed special care for on 32-bit platforms.

package main

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

func main() {
	var loans atomic.Int64
	var open atomic.Bool
	var wg sync.WaitGroup
	for range 8 {
		wg.Go(func() {
			for range 1000 {
				loans.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println(loans.Load())

	fmt.Println(open.Load(), open.CompareAndSwap(false, true), open.Load())
	fmt.Println(open.Swap(false), open.Load())

	var n atomic.Int64
	n.Store(10)
	fmt.Println(n.CompareAndSwap(10, 11), n.CompareAndSwap(10, 12), n.Load())

	var flags atomic.Uint32
	flags.Store(0b0101)
	fmt.Printf("old=%04b ", flags.Or(0b0010))
	fmt.Printf("now=%04b ", flags.Load())
	fmt.Printf("old=%04b ", flags.And(0b0110))
	fmt.Printf("now=%04b\n", flags.Load())
}
8000
false true true
true false
true false 11
old=0101 now=0111 old=0111 now=0110

Source: sync/atomic

CompareAndSwap(old, new) is the primitive for optimistic updates: it stores new only if the current value is still old and reports whether it did, so a retry loop can compute a new value from a snapshot without a lock.

Pointer[T]: publishing an immutable snapshot

atomic.Pointer[T] is a type-safe atomic pointer (since Go 1.19). Together with the rule "never modify what you have published", it gives lock-free reads of configuration or routing tables: writers build a complete new value and swap the pointer; readers load the pointer once and use that consistent snapshot.

package main

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

// Settings is an immutable snapshot of the configuration.
type Settings struct {
	LoanDays int
	MaxLoans int
}

var current atomic.Pointer[Settings]

func main() {
	current.Store(&Settings{LoanDays: 14, MaxLoans: 5})

	var wg sync.WaitGroup
	wg.Go(func() {
		// writers publish a fresh copy; they never modify a published one
		old := current.Load()
		next := *old
		next.LoanDays = 21
		current.Store(&next)
	})
	wg.Wait()

	s := current.Load()
	fmt.Println(s.LoanDays, s.MaxLoans)

	// compare-and-swap loop: update only if nobody else changed it meanwhile
	for {
		old := current.Load()
		next := *old
		next.MaxLoans++
		if current.CompareAndSwap(old, &next) {
			break
		}
	}
	fmt.Println(current.Load().MaxLoans)
}
21 5
6

The memory-model consequence is the point of the exercise: the plain writes that filled next happen before the atomic Store, which is synchronized before the Load that observes it, so a reader that sees the new pointer also sees the fully written struct. The same publication pattern with a plain pointer and no atomic would be a data race. This is also how a flag publishes data:

package main

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

func main() {
	var ready atomic.Bool
	var data int
	var wg sync.WaitGroup
	wg.Go(func() {
		data = 42         // plain write...
		ready.Store(true) // ...published by the atomic store
	})
	for !ready.Load() { // spin; fine for a demo, use a channel in real code
	}
	fmt.Println(data) // safe: the Store synchronizes before the Load that saw true
	wg.Wait()
}
42

atomic.Value

atomic.Value (since Go 1.4) predates generics: it stores any value, but every Store must use the same concrete type, and storing nil or a different type panics. Load returns nil before the first Store. New code should prefer atomic.Pointer[T], which the compiler type-checks.

package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var v atomic.Value
	fmt.Println(v.Load())
	v.Store("first")
	fmt.Println(v.Load())
	defer func() { fmt.Println("recovered:", recover()) }()
	v.Store(42) // inconsistent type
}
<nil>
first
recovered: sync/atomic: store of inconsistently typed value into Value

The function API and the atomictypes modernizer

The older functions (AddInt64(&x, 1), LoadInt64, StoreInt64, SwapInt64, CompareAndSwapInt64, AndInt64, OrInt64, and the 32-bit, unsigned, Uintptr and unsafe.Pointer variants) still work, but a plain variable can be touched non-atomically by mistake, and 64-bit operations on 32-bit platforms require the variable to be 64-bit aligned. Go 1.27 added the atomictypes modernizer to go fix, which rewrites them:

package main

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

func main() {
	var hits int64
	var wg sync.WaitGroup
	for range 4 {
		wg.Go(func() {
			for range 500 {
				atomic.AddInt64(&hits, 1)
			}
		})
	}
	wg.Wait()
	fmt.Println(atomic.LoadInt64(&hits))
}
2000

Running go fix -diff on it proposes:

-	var hits int64
+	var hits atomic.Int64
 	var wg sync.WaitGroup
 	for range 4 {
 		wg.Go(func() {
 			for range 500 {
-				atomic.AddInt64(&hits, 1)
+				hits.Add(1)
 			}
 		})
 	}
 	wg.Wait()
-	fmt.Println(atomic.LoadInt64(&hits))
+	fmt.Println(hits.Load())

When atomics are appropriate

Atomics are the hardest synchronization tool to use correctly, and the package documentation says as much: "These functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package." Use them when the shared state is genuinely one word:

  • a statistics counter, an ID generator or a closed/shutting down flag;

  • a published pointer to an immutable value (configuration, a compiled routing table);

  • inside a library that implements a synchronization primitive.

Do not use them to protect several related fields, and do not compose them: two atomic operations in a row are not atomic together. if x.Load() == 0 { x.Store(1) } is a race between the check and the update (use CompareAndSwap), and an invariant that spans two atomic variables needs a mutex. Atomic code is also hard to read and to review; if a mutex is fast enough, it is the better design. Measure rather than guess, because an atomic on a variable shared by many cores still bounces the cache line between them.

Choosing between a channel and a mutex

The Go wiki’s guidance is to use whichever is most expressive and/or simplest. As a rule of thumb:

Situation Prefer Why

Passing ownership of data, distributing work, pipelines, results

Channel

The send is the synchronization; ownership moves, so the receiver needs no lock.

Protecting an internal cache, counter or registry

Mutex

The state is the point, not the communication; a lock is shorter and faster.

Waiting for N goroutines to finish

WaitGroup

Counts completion without moving data.

One-time initialization

Once, OnceValue

Exactly-once and visibility are built in.

One shared number or flag

Atomic

No lock, no blocking.

Cancellation and timeouts

context and channels

Mutex and Cond cannot take part in a select.

Waiting for a condition on shared state

Closed channel, or Cond

A channel works with select; Cond supports Broadcast with several fields.

An owner goroutine that is the only code touching a map, with other goroutines sending it requests over a channel, is the channel version of a mutex-protected store; it is more code but makes the ownership explicit:

package main

import "fmt"

type request struct {
	isbn  string
	reply chan int
}

// owner is the only goroutine that touches the map; others send requests.
func owner(reqs <-chan request) {
	loans := map[string]int{}
	for r := range reqs {
		loans[r.isbn]++
		r.reply <- loans[r.isbn]
	}
}

func main() {
	reqs := make(chan request)
	go owner(reqs)
	reply := make(chan int)
	for range 3 {
		reqs <- request{isbn: "978-0-13-468599-1", reply: reply}
		fmt.Println("loans so far:", <-reply)
	}
	close(reqs)
}
loans so far: 1
loans so far: 2
loans so far: 3

Beyond the standard library, golang.org/x/sync provides errgroup (a WaitGroup that collects the first error and cancels a context), singleflight (collapse duplicate concurrent calls for the same key) and semaphore (a weighted semaphore); see golang.org/x/sync. The thread-safe counterpart in other languages' references is described in Shared State, Send, Sync and Atomics.

Pitfalls checklist

  • Forgetting that a map, slice or struct shared between goroutines is not synchronized, even for reads, when any goroutine writes. Run the tests with -race.

  • Copying a value that contains a mutex, WaitGroup, Once, Cond or atomic (value receivers, passing by value, range over a slice of such structs). go vet reports it (copylocks).

  • wg.Add(1) inside the goroutine; Done not called on every path; Add after Wait. Prefer wg.Go.

  • Holding a lock while calling unknown code (callbacks, interface methods), while sending on a channel, or while doing I/O.

  • defer mu.Unlock() inside a loop in the same function: the unlocks pile up until the function returns. Extract the body into a function.

  • Locking in String, Error or MarshalJSON methods that are also called by code that already holds the lock.

  • Using sync.Map or sync.Pool as a general-purpose tool; both are optimizations with narrow use cases.

  • Assuming atomic.Load followed by atomic.Store is atomic.

  • Reading a variable with a plain read while another goroutine updates it atomically: all accesses to that variable must be atomic, or the race detector will report the mix.

Where the books are outdated

Bodner’s chapter 10 (concurrency) teaches WaitGroup as Add/go func/Done, Once with a package-level variable and a helper, the mutex-versus-channel decision and a short note on sync.Map and atomics. Everything in it still works, but it predates WaitGroup.Go (Go 1.25) and the waitgroup vet check, the OnceFunc, OnceValue and OnceValues helpers (Go 1.21), the typed atomics atomic.Int64, Bool and Pointer[T] (Go 1.19) and And/Or (Go 1.23), sync.Map.Clear, Swap and CompareAndSwap (Go 1.20 and 1.23), and the go fix modernizers that convert old code. Its advice that atomics are rarely what you need is still right and is repeated here. Vivien’s chapter 9 introduces Mutex, an RWMutex cache, WaitGroup and -race with the Go 1.6 and 1.7 toolchain; the shapes are valid today, but it has no copylocks discussion, no TryLock (Go 1.18), and it calls the scheduler cooperative, which asynchronous preemption (Go 1.14) made untrue (see Goroutines and the Scheduler). Its listings that copy the loop variable by hand before starting a goroutine are no longer needed since Go 1.22.

Version notes

  • Go 1.3: sync.Pool. Go 1.9: sync.Map.

  • Go 1.18: Mutex.TryLock, RWMutex.TryLock and TryRLock.

  • Go 1.19: typed atomics (atomic.Int64, Bool, Pointer[T], …​); the memory model now documents sequentially consistent atomics.

  • Go 1.20: sync.Map.Swap, CompareAndSwap, CompareAndDelete.

  • Go 1.21: sync.OnceFunc, OnceValue, OnceValues.

  • Go 1.23: sync.Map.Clear; atomic.And and atomic.Or (functions and methods).

  • Go 1.24: sync.Map reimplemented as a concurrent hash-trie.

  • Go 1.25: sync.WaitGroup.Go; the waitgroup analyzer of go vet.

  • Go 1.27: the atomictypes modernizer; the go fix modernizer waitgroup renamed waitgroupgo.