The sync package gives you the low-level tools for coordinating goroutines: WaitGroup to wait for them, Mutex to protect data they share, Once to run setup exactly once, and the sync/atomic package for simple counters. By the end of this lesson you’ll be able to fix the racy counter from the Goroutines lesson three different ways and know when to use a mutex instead of a channel.
sync.WaitGroup in detail
A WaitGroup is a counter of unfinished tasks. Wait blocks until the counter is back to zero. There are two ways to drive it.
The modern way: wg.Go
wg.Go(f) adds one to the counter, runs f in a new goroutine, and subtracts one when f returns. It was added in Go 1.25, and it’s what I use in new code.
package main
import (
"fmt"
"sync"
"time"
)
func check(site string) string {
time.Sleep(10 * time.Millisecond) // pretend to make a request
return site + " is up"
}
func main() {
sites := []string{"a.example", "b.example", "c.example"}
status := make([]string, len(sites))
var wg sync.WaitGroup
for i, site := range sites {
wg.Go(func() {
status[i] = check(site)
})
}
wg.Wait()
for _, s := range status {
fmt.Println(s)
}
}Code language: Go (go)
a.example is up
b.example is up
c.example is upCode language: plaintext (plaintext)
The three checks run at the same time, so the whole thing takes about 10 milliseconds instead of 30. Each goroutine writes to its own element of status, so there’s no shared variable to protect.
The classic way: Add, Done, Wait
You’ll see this form in almost all Go code written before Go 1.25, and you still need it when the goroutine is started some other way. Add(n) increases the counter, Done() decreases it by one.
package main
import (
"fmt"
"sync"
)
func main() {
results := make([]int, 3)
var wg sync.WaitGroup
for i := range 3 {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = i * i
}()
}
wg.Wait()
fmt.Println(results)
}Code language: Go (go)
[0 1 4]Code language: plaintext (plaintext)
Two rules make this form work:
- Call
Addbefore thegostatement, never inside the goroutine. OtherwiseWaitmight run before the goroutine has had a chance to callAdd, see a counter of zero, and return too early. - Call
Donewithdeferas the first line of the goroutine, so it runs even if the function returns early. (Defer covers how that works.)
go vet catches the first mistake for you:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := range 3 {
go func() {
wg.Add(1)
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}Code language: Go (go)
main.go:12:10: WaitGroup.Add called from inside new goroutineCode language: plaintext (plaintext)
wg.Go makes both mistakes impossible, which is the main reason I prefer it.
Don’t copy a WaitGroup
If you pass a WaitGroup to a function by value, the function gets its own copy, and its Done calls never reach the original. Pass a pointer, *sync.WaitGroup, or better, keep the WaitGroup in the function that starts the goroutines. go vet flags the copy:
package main
import (
"fmt"
"sync"
)
func work(wg sync.WaitGroup) {
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go work(wg)
wg.Wait()
}Code language: Go (go)
main.go:8:14: work passes lock by value: sync.WaitGroup contains sync.noCopyCode language: plaintext (plaintext)
The same rule applies to every type in this lesson: Mutex, RWMutex, Once and the atomic types must not be copied after first use. That’s also why types that contain them get pointer receivers.
sync.Mutex: fixing the racy counter
Here’s the bug from the goroutines lesson again: a thousand goroutines doing count++ lose increments, and go run -race reports a data race. The problem is that read, add and write can interleave between goroutines.
A mutex (short for “mutual exclusion”) is a lock. Lock waits until no one else holds the lock and then takes it. Unlock releases it. The code between them, the critical section, runs in at most one goroutine at a time.
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
count int
)
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
mu.Lock()
count++
mu.Unlock()
})
}
wg.Wait()
fmt.Println("count:", count)
}Code language: Go (go)
count: 1000Code language: plaintext (plaintext)
Now it’s 1000 every time, and go run -race . stays silent. Like WaitGroup, a zero Mutex is ready to use (unlocked). Reading count after wg.Wait() without the lock is fine: Wait guarantees all the goroutines’ writes are finished and visible.
Keep the mutex next to the data
In real programs, put the mutex in a struct together with the data it protects, and only touch the data through methods that take the lock. Callers can’t forget to lock, because they never see the lock at all. Using defer mu.Unlock() right after Lock makes sure the lock is released on every return path.
package main
import (
"fmt"
"maps"
"slices"
"sync"
)
// Visits counts page views. It is safe for concurrent use.
type Visits struct {
mu sync.Mutex
counts map[string]int
}
func NewVisits() *Visits {
return &Visits{counts: make(map[string]int)}
}
func (v *Visits) Add(page string) {
v.mu.Lock()
defer v.mu.Unlock()
v.counts[page]++
}
func (v *Visits) Snapshot() map[string]int {
v.mu.Lock()
defer v.mu.Unlock()
return maps.Clone(v.counts)
}
func main() {
visits := NewVisits()
pages := []string{"/", "/about", "/", "/blog", "/", "/blog"}
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
for _, p := range pages {
visits.Add(p)
}
})
}
wg.Wait()
counts := visits.Snapshot()
for _, page := range slices.Sorted(maps.Keys(counts)) {
fmt.Println(page, counts[page])
}
}Code language: Go (go)
/ 300
/about 100
/blog 200Code language: plaintext (plaintext)
Maps are not safe for concurrent writes, so without the mutex this program would race, and the runtime may even crash it with fatal error: concurrent map writes. Snapshot returns a copy made with maps.Clone, so the caller can read it at leisure without holding the lock.
Keep critical sections short, and never wait on something slow (a network call, a channel) while holding a lock. Every other goroutine that needs the lock waits too. And don’t call Lock twice from the same goroutine without unlocking: Go’s mutex isn’t reentrant, so the second call waits forever.
sync.RWMutex: many readers, one writer
When data is read far more often than it’s written, a sync.RWMutex can help. Any number of goroutines can hold the read lock (RLock/RUnlock) at the same time, while the write lock (Lock/Unlock) is exclusive.
package main
import (
"fmt"
"sync"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
c := &Cache{data: make(map[string]string)}
c.Set("lang", "Go")
var wg sync.WaitGroup
hits := make([]bool, 10)
for i := range hits {
wg.Go(func() {
_, hits[i] = c.Get("lang")
})
}
wg.Wait()
v, _ := c.Get("lang")
fmt.Println(v, hits)
}Code language: Go (go)
Go [true true true true true true true true true true]Code language: plaintext (plaintext)
An RWMutex has more overhead than a plain Mutex, so it only pays off when reads clearly dominate and the critical sections aren’t tiny. I start with Mutex and switch only if profiling shows lock contention.
sync.Once: run something exactly once
Sometimes several goroutines need something that should only be set up once: a configuration file, a connection, a big lookup table. once.Do(f) calls f the first time and does nothing on later calls. Goroutines that call Do while f is still running wait until it finishes.
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string
)
func loadConfig() {
fmt.Println("loading config")
config = map[string]string{"env": "production"}
}
func main() {
envs := make([]string, 3)
var wg sync.WaitGroup
for i := range envs {
wg.Go(func() {
once.Do(loadConfig)
envs[i] = config["env"]
})
}
wg.Wait()
fmt.Println(envs)
}Code language: Go (go)
loading config
[production production production]Code language: plaintext (plaintext)
Three goroutines, one “loading config”. Reading config after once.Do is race-free, because Do guarantees the setup is complete before it returns.
Since Go 1.21 there’s a shorter form for the common case of computing a value once: sync.OnceValue wraps a function and returns a new function that calls it only the first time and then keeps returning the same result.
package main
import (
"fmt"
"sync"
)
var getConfig = sync.OnceValue(func() map[string]string {
fmt.Println("loading config")
return map[string]string{"env": "production"}
})
func main() {
fmt.Println(getConfig()["env"])
fmt.Println(getConfig()["env"])
}Code language: Go (go)
loading config
production
productionCode language: plaintext (plaintext)
sync/atomic for simple counters
For a single number or flag, a mutex is more than you need. The sync/atomic package has types like atomic.Int64, atomic.Bool and atomic.Pointer[T] (added in Go 1.19) whose methods are safe to call from many goroutines at once, without a lock.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var wg sync.WaitGroup
for range 1000 {
wg.Go(func() {
count.Add(1)
})
}
wg.Wait()
fmt.Println("count:", count.Load())
}Code language: Go (go)
count: 1000Code language: plaintext (plaintext)
The value is hidden inside the type, so you can only reach it through Add, Load, Store and friends, which makes it hard to accidentally do a non-atomic count++. Atomics are great for counters, statistics and “is it shut down yet” flags. As soon as two values have to change together (say, a balance and a transaction log), use a mutex: two atomic operations in a row are not atomic as a pair.
Channels or mutexes?
Go’s famous advice, from Effective Go, is “Do not communicate by sharing memory; instead, share memory by communicating.” That’s a good default, not a ban on mutexes. The standard library uses plenty of them. Here’s how I choose:
| Situation | Reach for |
|---|---|
| Passing data or ownership from one goroutine to another | Channel |
| Pipelines, worker pools, distributing jobs | Channel |
| Signaling “stop” or “done” to many goroutines | Closed channel or context |
| Waiting for a group of goroutines to finish | sync.WaitGroup |
| Several goroutines reading and updating shared state (a cache, a map of counts) | sync.Mutex inside a struct |
| A single counter or flag | sync/atomic |
| One-time setup | sync.Once or sync.OnceValue |
A quick test: if you’re thinking about data flowing between goroutines, use channels. If you’re thinking about protecting a piece of state that stays in one place, use a mutex. Whatever you pick, run your tests with go test -race.
Practice
1. Concurrent word count. Write a WordCount type with a mutex and a map[string]int, and a method Add(text string) that counts each word in text (use strings.Fields). Count the sentences "go is fun", "go is fast" and "fun is good", each in its own goroutine, then print the words in sorted order with their counts.
Show a solution
package main
import (
"fmt"
"maps"
"slices"
"strings"
"sync"
)
type WordCount struct {
mu sync.Mutex
counts map[string]int
}
func (wc *WordCount) Add(text string) {
wc.mu.Lock()
defer wc.mu.Unlock()
for _, w := range strings.Fields(text) {
wc.counts[w]++
}
}
func main() {
wc := &WordCount{counts: make(map[string]int)}
sentences := []string{"go is fun", "go is fast", "fun is good"}
var wg sync.WaitGroup
for _, s := range sentences {
wg.Go(func() {
wc.Add(s)
})
}
wg.Wait()
for _, w := range slices.Sorted(maps.Keys(wc.counts)) {
fmt.Println(w, wc.counts[w])
}
}Code language: Go (go)
fast 1
fun 2
go 2
good 1
is 3Code language: plaintext (plaintext)2. Atomic tally. Start one goroutine for each number from 1 to 100. Each goroutine checks whether its number is divisible by 3 and, if so, increments an atomic.Int64. Print the final count (33).
Show a solution
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var divisible atomic.Int64
var wg sync.WaitGroup
for i := range 100 {
n := i + 1
wg.Go(func() {
if n%3 == 0 {
divisible.Add(1)
}
})
}
wg.Wait()
fmt.Println("divisible by 3:", divisible.Load())
}Code language: Go (go)
divisible by 3: 33Code language: plaintext (plaintext)Key takeaways
- Use
wg.Go(f)andwg.Wait()to run and wait for goroutines. With the olderAdd/Doneform, callAddbeforegoanddefer wg.Done(). - A
sync.Mutexmakes a critical section run in one goroutine at a time. Keep it in a struct next to the data it protects and usedefer mu.Unlock(). RWMutexallows many readers at once; use it only when reads clearly dominate.sync.Onceandsync.OnceValuerun setup code exactly once, safely.- Atomic types like
atomic.Int64are the simplest fix for a shared counter. - Channels for passing data, mutexes for guarding state. Never copy any of these types after use.