BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Golang: The sync Package and atomic

DUKE260 viewsJuly 27, 2026

WaitGroup

A WaitGroup waits for a collection of goroutines to finish. It works with a simple internal counter and three methods: Add(delta) increases the counter (call it before launching the goroutine, not inside it), Done() decreases the counter by one (call it inside the goroutine, typically via defer), and Wait() blocks the calling goroutine until the counter reaches zero.

func main() {
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
work()
}()

wg.Wait()
}

Here main adds 1 to the counter, launches a single goroutine, and then blocks on Wait() until that goroutine calls Done(). If you forget to call Add before spawning, or forget Done inside the goroutine, Wait() either returns immediately (undercounting) or blocks forever (overcounting) — there's no way for the runtime to guess your intent.

When you need to share a WaitGroup across functions, it must be passed by pointer, because a sync.WaitGroup must never be copied after it has been used:

func work(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("working...")
}

Calling wg.Add(4) followed by four go work(&wg) calls produces four independent goroutines, each decrementing the shared counter when it finishes:

$ go run main.go
working...
working...
working...
working...

The order of the four lines isn't guaranteed — the goroutine scheduler decides when each one runs — but Wait() in main won't return until all four have printed and called Done().

Common errors with WaitGroup

var wg sync.WaitGroup

for i := 0; i < 4; i++ {
go func() {
wg.Add(1) // ⚠️ race: Add() must happen before the goroutine starts
defer wg.Done()
work()
}()
}
wg.Wait()

Calling Add inside the goroutine is a race: Wait() might run before any goroutine reaches its Add call, see a counter of zero, and return immediately while goroutines are still pending. The fix is to call Add from the goroutine that launches the work, before the go keyword:

for i := 0; i < 4; i++ {
wg.Add(1) // ✅ counted before the goroutine starts
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()

Another classic mistake is passing a WaitGroup by value into a function:

func launch(wg sync.WaitGroup) { // ❌ copies the WaitGroup
defer wg.Done()
work()
}

Because the function receives a copy, Done() decrements the copy's counter, not the original wg back in main — so Wait() blocks forever. go vet catches exactly this class of bug (see the Advanced Mutex section below); always take *sync.WaitGroup.

Mutex

A Mutex (mutual exclusion lock) protects a critical section — a piece of code that touches shared state — so that only one goroutine can execute it at a time. It exposes Lock(), Unlock(), and (since Go 1.18) TryLock(), which attempts to acquire the lock without blocking.

To see why a mutex matters, look at this Counter type without one:

type Counter struct {
value int
}

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
}

If four goroutines call Update concurrently with 10, -5, 25, and 19, you might see output like this:

$ go run main.go
Adding 10 to 0
Adding -5 to 0
Adding 25 to 0
Adding 19 to 0
Result is 49

Every goroutine prints "Adding X to 0" because each one reads c.value before any of the others have written their update back — they're all racing to read and write the same memory location with no coordination at all.

This is a genuine data race, not just an ordering quirk. Reading and writing c.value from multiple goroutines with no synchronization is undefined behavior in Go. Running go run -race main.go on this code would report a data race on c.value. The fact that the final "Result is 49" happens to be arithmetically correct here is pure luck — with a different number of goroutines, a different value of GOMAXPROCS, or a different day, you could just as easily see a lost update where two goroutines read the same stale value and one of the additions silently disappears. Never trust output like this as evidence that racy code is "fine in practice."

Adding a Mutex to the struct fixes the race by making every Update call wait its turn:

type Counter struct {
m sync.Mutex
value int
}

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
c.m.Lock()
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
c.m.Unlock()
}

Unlock should be deferred, not called manually at the end. The version above calls c.m.Unlock() as the last line of the function, which works as long as nothing between Lock() and that line ever panics or returns early. But real code grows: someone adds an early return for a validation check, or a call inside the critical section panics, and now the lock is never released — every other goroutine that calls Lock() afterward blocks forever. The idiomatic and panic-safe form is to defer the unlock immediately after acquiring the lock:

func (c *Counter) Update(n int, wg *sync.WaitGroup) {
c.m.Lock()
defer c.m.Unlock()
defer wg.Done()

fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
}

Deferred calls run in LIFO order, so wg.Done() fires first (signaling completion) and c.m.Unlock() fires right after, releasing the lock as the function returns — no matter which path it took to get there.

With the mutex in place, the four updates are serialized — only one goroutine touches c.value at a time — so the output becomes fully consistent:

$ go run main.go
Adding -5 to 0
Adding 19 to -5
Adding 25 to 14
Adding 10 to 39
Result is 49

The exact order in which goroutines acquire the lock still isn't guaranteed (the scheduler decides), so you might see -5, 19, 25, 10 in a different sequence on another run. What is guaranteed now is that each "Adding X to Y" line reflects the real, up-to-date value of the counter at that moment, and the final result is always 49 — not by luck, but because the mutex makes the four additions happen one after another instead of overlapping.

Advanced Mutex

Why defer Unlock() is panic-safe. A defer statement is guaranteed to run when its enclosing function returns, whether that return happens normally, via an early return, or by unwinding from a panic. If you call Unlock() as a plain statement at the end of a function and a panic occurs before that line executes, the lock stays held forever — every other goroutine blocked on Lock() for that mutex deadlocks permanently, and the panic doesn't even have to be in your critical section; it can be several function calls deep. Deferring Unlock() immediately after Lock() guarantees the lock is always released, turning "the program panics" into a recoverable, well-scoped failure instead of a total deadlock.

go vet catches lock copying. A sync.Mutex (like sync.WaitGroup, sync.RWMutex, and most other sync types) must never be copied after it's been used. Copying duplicates the internal state that tracks who holds the lock, so the copy and the original can end up out of sync — one goroutine locks the copy while another locks the original, and both proceed as if they have exclusive access. Go's static analyzer catches this automatically:

type Counter struct {
m sync.Mutex
value int
}

func launch(c Counter) { // ❌ copies the embedded Mutex
c.m.Lock()
defer c.m.Unlock()
}

Running go vet on this produces something like launch passes lock by value: Counter contains sync.Mutex. The fix is always to pass a pointer — func launch(c *Counter) — so every goroutine shares the same underlying lock. This is exactly why WaitGroup, Mutex, and friends are always passed around as pointers or embedded in structs that are themselves passed by pointer.

Common errors with Mutex

func launch(m sync.Mutex) { // ❌ vet: passes lock by value
m.Lock()
defer m.Unlock()
}

Take a pointer instead: func launch(m *sync.Mutex).

func (c *Counter) Update(n int) {
c.m.Lock()
c.value += n
// ❌ forgot Unlock — every subsequent Lock() call blocks forever
}

Any code path that acquires a lock and never releases it deadlocks the rest of the program the moment another goroutine tries to acquire the same lock. defer c.m.Unlock() right after Lock() eliminates this class of bug entirely.

RWMutex

A Mutex treats every access — read or write — the same way: only one goroutine at a time, period. But a lot of real-world data is read far more often than it's written, and multiple readers don't actually conflict with each other; they only conflict with a writer. sync.RWMutex models this directly: it allows either any number of concurrent readers or a single writer, never both at once. Alongside the regular Lock()/Unlock() pair (used for writing), it adds RLock()/RUnlock() for reading.

func (c *Counter) GetValue(wg *sync.WaitGroup) {
defer wg.Done()

c.m.RLock()
defer c.m.RUnlock()
fmt.Println("Get value:", c.value)
time.Sleep(400 * time.Millisecond)
}

If several goroutines call GetValue at roughly the same time, they can all hold the read lock simultaneously — none of them blocks the others, so all the "Get value" lines can print before any of the 400ms sleeps finish. Compare that to what would happen with a plain Mutex: each GetValue call would have to wait for the previous one to finish sleeping before it could even start, turning four fast reads into a slow, fully serialized chain. RWMutex is the right tool whenever reads vastly outnumber writes and the value being protected is read by many goroutines concurrently.

Both Mutex and RWMutex satisfy the sync.Locker interface (Lock() and Unlock()), which is why sync.NewCond — covered next — can accept either one.

Common errors with RWMutex

func (c *Counter) Bad() {
c.m.RLock()
defer c.m.RUnlock()

c.m.Lock() // ❌ deadlock: this goroutine already holds RLock
defer c.m.Unlock()
c.value++
}

RWMutex is not reentrant: a goroutine that already holds the read lock cannot also acquire the write lock (or the read lock again) without releasing the first one — doing so deadlocks against itself, because Lock() waits for all existing readers, including the caller, to release. If a function needs to both read and then write, release the read lock first, or restructure so reading and writing happen in separate calls.

Cond

sync.Cond is a condition variable: it lets goroutines that are waiting on some shared condition sleep efficiently until another goroutine signals that the condition has changed, instead of burning CPU in a polling loop. You create one with sync.NewCond(l Locker), passing in a Mutex or RWMutex that protects the shared state. Three methods drive it: Wait() atomically unlocks the associated lock and suspends the calling goroutine, then re-locks it before returning once woken; Signal() wakes one waiting goroutine; and Broadcast() wakes all of them.

var done = false

func read(name string, c *sync.Cond) {
c.L.Lock()
for !done {
c.Wait()
}
fmt.Println(name, "starts reading")
c.L.Unlock()
}

func write(name string, c *sync.Cond) {
fmt.Println(name, "starts writing")
time.Sleep(time.Second)

c.L.Lock()
done = true
c.L.Unlock()

fmt.Println(name, "wakes all")
c.Broadcast()
}

Notice that read checks done inside a for loop, not a plain if. This matters because Wait() can, in rare cases, return without the condition actually being true (a "spurious wakeup"), and because multiple goroutines might race to observe the condition after being woken. Re-checking the condition in a loop after every Wait() call is the standard, safe pattern for condition variables in every language that has them, not just Go. Also note that every access to done — reading it in the loop condition and setting it in write — happens while holding c.L; that lock is what keeps the condition check and the condition change from racing with each other.

If three readers call read and one writer calls write, all three readers block inside c.Wait() until the writer finishes its (simulated) work, flips done to true, and calls Broadcast(). That wakes all three readers at once; each one re-acquires the lock, sees done is now true, exits its loop, prints that it's starting to read, and releases the lock.

Advanced Cond

Cond vs. channels. Most idiomatic Go code reaches for channels before it reaches for sync.Cond — a channel close or a signal sent on a channel can express "something happened" just as well, and channels compose naturally with select and context cancellation. Cond earns its keep in a narrower case: when you need to wake up many goroutines that are all waiting on a change to some shared, mutex-protected state, and a plain channel close (which is a fine way to broadcast a single, permanent "done" event) doesn't fit because the condition can become true, false, and true again over the program's lifetime. If you find yourself building a hand-rolled broadcast mechanism out of channels and mutexes, that's usually the signal that sync.Cond is the more direct tool for the job.

Once

sync.Once guarantees that a function runs exactly once, no matter how many goroutines call it or how many times they call it. Its single method, Do(f), runs f on the very first call across all goroutines and does nothing on every subsequent call — even ones that happen concurrently with the first.

var once sync.Once

If 100 goroutines all call once.Do(increment) at roughly the same time, exactly one of them actually executes increment; the other 99 calls to Do return immediately without running the function, and any goroutine that calls Do while the first call is still in progress blocks until that first call completes before returning. The end result, after all 100 goroutines finish, is that the shared counter has been incremented exactly once — its count is 1, never more.

Advanced Once

Lazy singleton initialization. The most common real use of sync.Once is lazily constructing something expensive — a database connection, a parsed config file, a precomputed lookup table — the first time it's needed, safely, even if multiple goroutines reach for it simultaneously:

var (
instance *Client
once sync.Once
)

func GetClient() *Client {
once.Do(func() {
instance = newExpensiveClient()
})
return instance
}

No matter how many goroutines call GetClient() concurrently, newExpensiveClient() runs exactly once, and every caller — including the ones that arrived while initialization was still in flight — gets the same fully-initialized instance.

sync.OnceFunc and sync.OnceValue (Go 1.21+). Go 1.21 added a small set of helpers that wrap this exact pattern so you don't need a package-level sync.Once variable at all. sync.OnceFunc(f) returns a new function that calls f at most once:

var initialize = sync.OnceFunc(func() {
fmt.Println("initializing...")
})

sync.OnceValue(f) is the same idea for functions that return a value — every call after the first returns the cached result instead of recomputing it:

var getConfig = sync.OnceValue(func() *Config {
return loadConfig()
})

These are strictly more convenient than a manual once.Do wrapper for the common case of "compute this lazily, once, and cache the result."

Pool

sync.Pool is a concurrency-safe pool of temporary objects, designed to take pressure off the garbage collector when you're allocating and discarding a lot of short-lived objects of the same type. Instead of allocating a fresh object every time you need one, you Get() one from the pool — reusing an existing object if one is available — and Put() it back when you're done, so the next caller can reuse it instead of triggering a new allocation.

var pool = sync.Pool{
New: func() any {
fmt.Println("Creating a new person...")
return &Person{}
},
}

The New function is called by Get() only when the pool is currently empty; it's what produces a value when there's nothing to reuse. Since Get() returns any, you need a type assertion to get back a concrete type: pool.Get().(*Person). A typical sequence — Get, use, Put, Get again — shows the reuse in action: the first Get() finds the pool empty and calls New, printing "Creating a new person..."; after you Put() the object back, a second Get() finds it in the pool and returns the same object without calling New again, so nothing gets printed the second time around.

Advanced Pool

The pool can be emptied at any time. sync.Pool is explicitly not a cache with predictable retention: the runtime is free to remove items from a pool during garbage collection, without any notice, whenever it wants. That's a deliberate design choice — it means Pool can never cause a memory leak by holding onto objects forever, but it also means you can never rely on Get() returning a previously-Put() object; your code has to work correctly regardless of whether New fires or a recycled object comes back.

Reset objects before reuse. Because a Pool hands back the same object it was given, any state left over from the previous use is still there unless you clear it yourself:

p := pool.Get().(*Person)
p.Name = "Alice"
pool.Put(p)

p2 := pool.Get().(*Person)
fmt.Println(p2.Name) // "Alice" — stale data from the last user!

The pool has no idea what "clean" means for your type, so it's entirely your responsibility to reset fields to their zero value (or otherwise sanitize the object) either right before Put-ing it back or right after Get-ting it, before you hand it to a new caller.

Only worth it for high-frequency allocation. sync.Pool itself isn't free — it has internal synchronization and per-P local caches that carry real overhead, and for small, infrequently-allocated objects that overhead can easily exceed the cost of just letting the garbage collector do its job. Reach for Pool when profiling shows that allocation and GC pressure from a specific, hot, frequently-churned object type is an actual bottleneck (buffers in a busy HTTP server, temporary structs in a tight parsing loop), not as a reflexive optimization for every struct in your program. And like other sync types, a Pool must never be copied after first use.

sync.Map

sync.Map is a map implementation safe for concurrent use by multiple goroutines without any external locking, with methods like Store, Load, Delete, LoadAndDelete, LoadOrStore, and Range in place of the usual m[key] = value / v := m[key] syntax you'd use with a plain map.

It's tempting to reach for sync.Map any time you need a concurrent-safe map, but the Go documentation is explicit that most code should just use a plain map protected by a separate Mutex or RWMutex — it's simpler to reason about and, for typical access patterns, often faster too. sync.Map is specifically optimized for two narrower scenarios: (1) a cache that's written once and then read many times by many goroutines, where the set of keys is mostly stable, and (2) workloads where different goroutines each read and write a disjoint set of keys, so they rarely contend with each other. Outside those cases, the internal bookkeeping sync.Map does to avoid locking can actually make it slower than a mutex-guarded plain map.

var m sync.Map

func main() {
var wg sync.WaitGroup
wg.Add(10)

for i := 0; i < 5; i++ {
go func(n int) {
defer wg.Done()
m.Store(n, n*n)
}(i)
}

for i := 0; i < 5; i++ {
go func(n int) {
defer wg.Done()
v, ok := m.Load(n)
fmt.Println(n, v, ok)
}(i)
}

wg.Wait()
}

Five writer goroutines call Store and five reader goroutines call Load for the same five keys, all launched concurrently. Every individual Store and Load call is safe — sync.Map guarantees you'll never crash or corrupt the map's internal structure — but there's no guarantee about the order the writers and readers run in relative to each other. A reader goroutine for key n might run before the corresponding writer goroutine for that same key has called Store, in which case Load returns (nil, false): the value simply isn't there yet. That's expected, not a bug — if your logic needs a value to definitely exist before it's read, you need your own coordination (a WaitGroup, a channel, or restructuring so writes happen-before reads) on top of sync.Map's per-operation safety. One more caveat: Range does not provide a consistent snapshot of the map — if a Store or Delete happens concurrently with a Range call, the iteration may or may not reflect that change.

Common errors with concurrent maps

m := make(map[string]int)

for i := 0; i < 10; i++ {
go func(n int) {
m[fmt.Sprint(n)] = n // ❌ fatal error: concurrent map writes
}(i)
}

A plain Go map has no built-in concurrency safety at all — not even a data race that merely produces wrong answers, but a runtime check that actively crashes the program with fatal error: concurrent map writes (or concurrent map read and map write) the moment it detects simultaneous access. This isn't a panic you can recover() from either — it terminates the process. Guard a plain map with a Mutex/RWMutex, or switch to sync.Map if your access pattern fits one of the two cases above.

atomic

The sync/atomic package provides low-level atomic operations — Add, Load, Store, Swap, CompareAndSwap — that read-modify-write a single value (an integer or a pointer) as one indivisible hardware-level step, with no possibility of another goroutine observing a half-finished update. They're a lighter-weight alternative to a Mutex when the only thing you need to protect is a single word of memory.

func add(w *sync.WaitGroup, num *int32) {
defer w.Done()
atomic.AddInt32(num, 1)
}

Launching 1000 goroutines that each call add on a shared *int32 counter, with a WaitGroup to wait for all of them, guarantees the final result is exactly 1000 — every single increment lands, with no lost updates, because atomic.AddInt32 never lets two goroutines interleave their read-modify-write cycles on the same memory location.

Advanced atomic

Typed atomics since Go 1.19. The original atomic API — functions like atomic.AddInt32(&x, 1) — takes a raw pointer, which is easy to misuse: nothing stops you from also reading or writing x directly with a plain x++ somewhere else in the code, silently reintroducing the exact race the atomic call was supposed to prevent. Go 1.19 introduced typed atomic wrapper types — atomic.Int32, atomic.Int64, atomic.Uint32, atomic.Uint64, atomic.Bool, atomic.Pointer[T], and others — that hold the value internally and expose it only through atomic methods:

var counter atomic.Int32

func add(w *sync.WaitGroup) {
defer w.Done()
counter.Add(1)
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go add(&wg)
}
wg.Wait()
fmt.Println("Result:", counter.Load())
}

Because counter is a value of type atomic.Int32, not a plain int32, there's no raw variable lying around to accidentally access non-atomically — every read goes through .Load() and every write goes through .Add(), .Store(), or .CompareAndSwap(). This is the preferred style for new code; reach for the older pointer-based functions only when you're stuck with a plain int32/int64 field you can't change (for example, one embedded in a struct whose layout is fixed for other reasons).

atomic vs. mutex trade-off. Atomics are the right choice when the entire piece of shared state you need to protect is a single word — a counter, a flag, a pointer swap — because they avoid the overhead of acquiring and releasing a lock and never risk deadlock. But the moment you need to update more than one related value together and keep them consistent with each other (say, a struct with a balance and a lastUpdated timestamp that must always agree), atomics stop being sufficient: there's no way to atomically update two independent atomic variables as a single unit, and trying to coordinate them with separate atomic operations opens a window where another goroutine can observe an inconsistent combination. That's exactly the situation a Mutex is built for — it protects an invariant across multiple fields, not just a single value. As a rule of thumb: single counter or flag, reach for atomic; anything involving multiple fields that must change together, reach for a Mutex.


#Backend#Go Advance
DUKE
Written byDUKEView all stories by DUKE

Comments