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.
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.
Source: Data Race Detector
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
Locktwice without anUnlockin 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]:Source: 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
recovercannot save the program:package main import "sync" func main() { var mu sync.Mutex mu.Unlock() }fatal error: sync: unlock of unlocked mutexSource: sync: Mutex.Unlock
-
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)
}
Source: copylock analyzer
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
Source: copylock analyzer
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
Source: Effective Go: Embedding
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
Source: sync: Mutex.TryLock
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, newRLockcalls 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 twoRLockcalls, 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
vetcannot see; keep the read side strictly read-only (including lazy caching, which is a write). -
An
RWMutexis more expensive than aMutexper operation. Measure before choosing it (see Benchmarks, Fuzzing and synctest); with short critical sections a plainMutexis 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
Source: sync: WaitGroup.Go
(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:
-
fmust not panic: a panic in a goroutine that nothing recovers terminates the program, exactly as with a plaingostatement.WaitGroup.Godoes not collect errors or cancel siblings; for that useerrgroupfromgolang.org/x/sync. -
If the group is empty,
Gomust happen beforeWait(call it in the goroutine that waits, before waiting). If the group is not empty, a goroutine started byGomay itself callGo. -
A
WaitGroupmay be reused for a new set of tasks only after every earlierWaithas returned. -
Pass a
WaitGroupby pointer. A copy has its own counter, andcopylocksflags 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
Source: sync: WaitGroup.Add
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
Source: cmd/vet: waitgroup analyzer
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.
Source: Go 1.27 release notes
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:
-
OnceValuescaches the error too: a failed initialization is not retried. If you want retry-on-failure, use a mutex and a nil check, or anatomic.Pointer. -
Do(f)with afthat callsDoon the sameOncedeadlocks, because the inner call waits for the outer one. -
If
fpanics,Doconsiders the call finished: later calls do not runfagain.OnceFunc,OnceValueandOnceValuesinstead 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:
-
Waitmust be called withLheld. It atomically unlocksLand suspends the goroutine, and relocksLbefore returning. -
Because the condition may be false again when the goroutine wakes (another consumer got there first, or the wake-up was a
Broadcastfor something else),Waitmust always be called in a loop that re-checks the condition. -
Signalwakes one waiting goroutine,Broadcastwakes all of them. The caller may, but need not, holdL. -
A
Condmust not be copied after first use.
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 afterGet), and never use it afterPut. -
Put pointers (or slices wrapped in a pointer). Putting a non-pointer value into the
anyparameter allocates, which defeats the purpose;staticcheckreports 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 theGetthat returns that samex.
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
Source: sync/atomic: Pointer
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
Source: sync/atomic: 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())
Source: Go 1.27 release notes
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 downflag; -
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 |
|
Counts completion without moving data. |
One-time initialization |
|
Exactly-once and visibility are built in. |
One shared number or flag |
Atomic |
No lock, no blocking. |
Cancellation and timeouts |
|
|
Waiting for a condition on shared state |
Closed channel, or |
A channel works with |
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
Source: Go Wiki: Mutex or Channel
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,Condor atomic (value receivers, passing by value,rangeover a slice of such structs).go vetreports it (copylocks). -
wg.Add(1)inside the goroutine;Donenot called on every path;AddafterWait. Preferwg.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,ErrororMarshalJSONmethods that are also called by code that already holds the lock. -
Using
sync.Maporsync.Poolas a general-purpose tool; both are optimizations with narrow use cases. -
Assuming
atomic.Loadfollowed byatomic.Storeis 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.TryLockandTryRLock. -
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.Andandatomic.Or(functions and methods). -
Go 1.24:
sync.Mapreimplemented as a concurrent hash-trie. -
Go 1.25:
sync.WaitGroup.Go; thewaitgroupanalyzer ofgo vet. -
Go 1.27: the
atomictypesmodernizer; thego fixmodernizerwaitgrouprenamedwaitgroupgo.