Dev Skills

The sync package in Go: every type, with examples

Every type in sync, what it is for, a working example, and the mistake people make with it.

Topic 43 of 101 · 20 min to read · 20 min to build

By devskills editorial team · Last updated

The sync package holds Go's low-level synchronisation tools. Channels handle passing data between goroutines; sync handles everything around shared state: locking it, waiting for goroutines, doing setup once, and reusing memory. Its sibling sync/atomic handles single values without a lock.

One rule applies to every type here: do not copy it after first use. A copied Mutex is a second, unrelated lock, and a copied WaitGroup counts nothing. Pass pointers, and let go vet (its copylocks check) catch the mistakes.

TypeUse it toAddedThe usual mistake
MutexLet one goroutine at a time touch shared stateGo 1.0 (TryLock 1.18)Copying it; holding it across slow I/O
RWMutexMany readers at once, or one writerGo 1.0Recursive RLock; using it where reads are short
WaitGroupWait for a group of goroutines to finishGo 1.0 (Go method 1.25)Add inside the goroutine
Once, OnceFunc, OnceValue, OnceValuesRun setup exactly once, lazilyOnce 1.0, the rest 1.21Expecting a failed setup to be retried
PoolReuse temporary objects to cut allocationsGo 1.3Assuming objects stay in it; not resetting them
MapA concurrent map for two specific access patternsGo 1.9Using it as a general map replacement
CondWake goroutines waiting for a conditionGo 1.0Calling Wait outside a for loop
sync/atomic typesOne counter, flag or pointer without a lockTyped API 1.19Mixing atomic and plain access to one value

sync.Mutex

Lock and Unlock around every access to the shared data, with defer mu.Unlock() straight after Lock so an early return or a panic cannot leave it locked. Keep the mutex next to the data it protects, in the same struct, and keep the locked section short: never hold a lock while making a network call.

TryLock exists (Go 1.18) but its own documentation warns that correct uses are rare. If you are reaching for it, there is usually a design problem. The Go mutex is also fair under pressure: a waiter that has waited more than 1 ms switches the lock into starvation mode, where it is handed over directly in queue order. The full lesson is Mutexes and races.

sync.RWMutex

RLock lets any number of readers in together; Lock waits until there are none and keeps everyone else out. It only beats a plain Mutex when reads clearly outnumber writes and each read holds the lock long enough for readers to overlap. It has one trap that deadlocks real programs: a goroutine must never take RLock twice. Both are covered with benchmarks in Mutex vs RWMutex.

sync.WaitGroup and WaitGroup.Go

A WaitGroup is a counter. Add(n) raises it, Done() lowers it by one, and Wait() blocks until it reaches zero. Call Add before the go statement; inside the goroutine, Wait can run first and return immediately.

Go 1.25 added wg.Go(f), which does the Add(1), starts f in a goroutine, and calls Done when it returns. Use it whenever your module targets Go 1.25 or later; it removes both classic mistakes. A WaitGroup does not collect errors. For that, use errgroup (Worker pool).

waitgroup.go
package main
import (
"fmt"
"sync"
)
func main() {
squares := make([]int, 5)
// Go 1.25+: Go does Add(1), starts the goroutine and calls Done.
var wg sync.WaitGroup
for i := range squares {
wg.Go(func() {
squares[i] = i * i // each goroutine writes its own slot
})
}
wg.Wait()
fmt.Println("with wg.Go:", squares)
// The same thing on Go 1.24 and older.
var old sync.WaitGroup
for i := range squares {
old.Add(1) // before go, never inside it
go func() {
defer old.Done()
squares[i] = i * i * i
}()
}
old.Wait()
fmt.Println("with Add/Done:", squares)
}

sync.Once, OnceFunc, OnceValue and OnceValues

once.Do(f) runs f the first time it is called from any goroutine. Concurrent callers wait until that first call finishes, then return without running f. Use it for lazy setup: parsing a config, compiling a regular expression, creating a shared client.

Go 1.21 added three helpers that are usually what you want. sync.OnceFunc(f) returns a function that runs f once. sync.OnceValue(f) returns a function that runs f once and then returns its result every time. sync.OnceValues(f) does the same for a (value, error) pair.

The trap: "once" means once, even if it failed. If the setup returns an error, every later call gets the same error, and it is never retried. When the setup can fail for a temporary reason, like a network call, use a mutex and a "done" flag instead, so a later call can try again.

once.go
package main
import (
"errors"
"fmt"
"strconv"
"sync"
)
var calls int
// loadPort runs the parse once, however many goroutines call it.
var loadPort = sync.OnceValues(func() (int, error) {
calls++
return strconv.Atoi("8080")
})
var loadBroken = sync.OnceValues(func() (int, error) {
return 0, errors.New("config server unreachable")
})
func main() {
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
port, err := loadPort()
_, _ = port, err
})
}
wg.Wait()
port, err := loadPort()
fmt.Println("port:", port, "err:", err, "setup ran:", calls, "time(s)")
// a failure is remembered too: it is not retried
_, err1 := loadBroken()
_, err2 := loadBroken()
fmt.Println(err1, "|", err2)
}
Eleven calls, one parse. The broken loader returns the same error both times without running again.

sync.Pool

A Pool keeps temporary objects for reuse, so a hot path that needs a scratch buffer on every request does not allocate a new one each time. Get returns a pooled object or calls New to make one; Put hands it back.

Two rules. First, the pool may drop anything it holds at any time; in practice objects that sit unused across garbage collections are freed. So it is a cache for memory, never a place to keep state. Second, reset an object before using it again, because it still holds whatever the last user left in it. Do not pool things that need closing, such as connections or files; that is a job for a real pool like *sql.DB.

Measure before adding one. A Pool only pays off when allocation shows up in a profile. The standard library uses it in places like fmt, where buffers are allocated on every call.

pool.go
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(name string) string {
b := bufPool.Get().(*bytes.Buffer)
b.Reset() // it may still hold the previous user's bytes
defer bufPool.Put(b)
b.WriteString("hello, ")
b.WriteString(name)
return b.String() // String copies, so returning it after Put is safe
}
func main() {
for _, n := range []string{"asha", "ben", "chen"} {
fmt.Println(render(n))
}
}
Without `Reset`, the second call would print "hello, ashahello, ben" whenever it got the same buffer back.

sync.Map

A map that is safe for concurrent use without your own lock. Its documentation is specific about when it helps: when each key is written once and then read many times (a cache that only grows), or when different goroutines read and write disjoint sets of keys. For everything else, a plain map guarded by a Mutex or RWMutex is simpler, type-safe and often faster.

Its methods are Load, Store, LoadOrStore, LoadAndDelete, Delete, Range, plus Swap, CompareAndSwap and CompareAndDelete (Go 1.20) and Clear (Go 1.23). Values come back as any, so you need a type assertion on every read.

syncmap.go
package main
import (
"fmt"
"sync"
)
func main() {
var seen sync.Map // zero value is ready to use
var wg sync.WaitGroup
var firsts sync.Map
urls := []string{"/a", "/b", "/a", "/c", "/b", "/a"}
for i, u := range urls {
wg.Go(func() {
// only the first goroutine for each key stores; the rest load
if _, loaded := seen.LoadOrStore(u, i); !loaded {
firsts.Store(u, true)
}
})
}
wg.Wait()
n := 0
firsts.Range(func(k, v any) bool {
n++
return true // keep iterating
})
fmt.Println("distinct paths:", n)
if v, ok := seen.Load("/c"); ok {
fmt.Println("/c first seen at index", v.(int))
}
}
`LoadOrStore` makes "insert if absent" one atomic step. With a plain map you would need the lock around both the check and the insert.

sync.Cond

A condition variable: goroutines Wait until another goroutine changes some state and calls Signal (wake one) or Broadcast (wake all). Wait unlocks the associated mutex while sleeping and locks it again before returning.

Always call Wait inside a for loop that re-checks the condition, because by the time a woken goroutine runs, another may have changed the state again. The package documentation itself says that for many simple cases channels are a better fit. Cond earns its place when many goroutines wait on a condition over shared state, and Broadcast wakes all of them at once, which a channel can only do by being closed (once).

cond.go
package main
import (
"fmt"
"sync"
)
type queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
}
func newQueue() *queue {
q := &queue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *queue) Put(v int) {
q.mu.Lock()
q.items = append(q.items, v)
q.mu.Unlock()
q.cond.Signal() // wake one waiting taker
}
func (q *queue) Take() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 { // a loop, not an if
q.cond.Wait() // unlocks while asleep, relocks before returning
}
v := q.items[0]
q.items = q.items[1:]
return v
}
func main() {
q := newQueue()
var wg sync.WaitGroup
sum := 0
var mu sync.Mutex
for range 3 {
wg.Go(func() {
v := q.Take()
mu.Lock()
sum += v
mu.Unlock()
})
}
for v := 1; v <= 3; v++ {
q.Put(v * 10)
}
wg.Wait()
fmt.Println("taken:", sum)
}
Prints 60. A buffered channel would do this job in three lines, which is the documentation's point.

sync/atomic

For a single counter, flag or pointer, an atomic operation is cheaper than a lock. Since Go 1.19 the package has typed values (atomic.Int64, atomic.Bool, atomic.Pointer[T] and others) that cannot be accessed non-atomically by accident, which is a real improvement over the older atomic.AddInt64(&n, 1) functions.

Atomics protect one value. As soon as two values must change together (a balance and a history, a count and a total), use a mutex. Every access to an atomic value must be atomic: one plain read next to atomic writes is a data race.

atomic.go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var hits atomic.Int64
var ready atomic.Bool
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() { hits.Add(1) })
}
wg.Wait()
ready.Store(true)
fmt.Println("hits:", hits.Load(), "ready:", ready.Load())
fmt.Println("swapped 1000 -> 0:", hits.CompareAndSwap(1000, 0), "now:", hits.Load())
}

When should you use a channel instead of sync?

When the problem is about handing data or work from one goroutine to another: a pipeline, a job queue, a result, a timeout with select. Use sync when goroutines share state in place. The Go wiki's advice is to use whichever is simpler, and Mutex vs channel walks through both versions of the same program.

Watch out: Whatever you use, run tests with go test -race. Every type on this page exists to prevent bugs the race detector is built to find.
Check you can now
  • Pick the right sync type for a problem: lock, wait, run once, reuse, or signal.
  • Use WaitGroup.Go, sync.OnceValue and the typed atomics from recent Go releases.
  • Name the trap in each type, such as copying a mutex or trusting sync.Pool to keep an object.
The short version
  • Never copy a sync value after first use; pass pointers and let go vet check.
  • wg.Go(f) (Go 1.25) replaces the Add/Done pair; Add before go on older versions.
  • OnceValue and OnceValues (Go 1.21) run setup once and remember the result, including a failure.
  • sync.Pool is a memory cache that can be emptied at any time. Reset what you get from it.
  • sync.Map is for write-once-read-many keys or disjoint key sets; otherwise use a map and a mutex.
  • Typed atomics (Go 1.19) for single values; a mutex as soon as two values must change together.
Remember: Mutex for state, WaitGroup to wait, Once for setup, Pool for scratch memory, atomic for one value, and a channel when data moves.
Then try these in your own editor
  1. 1Write a Counter struct with a sync.Mutex and an Inc method on a value receiver. Run go vet. What does it say, and what is the fix?
  2. 2Replace the sync.Cond queue in cond.go with a buffered channel. How many lines did you save?
  3. 3Your service loads a token from a secrets API at startup with sync.OnceValues. The API was briefly down at boot. What happens for the life of the process, and what would you write instead?
Check yourselfTake the Concurrency test