Go Module 13 – Goroutines, channels and sync
sync: WaitGroup.Go, Mutex, RWMutex and Once
Wait for goroutines with WaitGroup.Go, guard shared data with Mutex and RWMutex, and run setup exactly once with sync.Once and OnceValues.
What you will learn
- Use sync.WaitGroup to wait for goroutines, with WaitGroup.Go and the older Add and Done
- Protect shared state with a Mutex, and explain why an unguarded counter loses updates
- Choose an RWMutex for read-heavy state, and predict when readers hold it together
- Implement lazy initialisation with sync.Once, OnceFunc, OnceValue or OnceValues
- Find WaitGroup and lock mistakes with go vet and the race detector, and fix them
Before you start
What you’ll learn · Before you start
What you will learn
- Use sync.WaitGroup to wait for goroutines, with WaitGroup.Go and the older Add and Done
- Protect shared state with a Mutex, and explain why an unguarded counter loses updates
- Choose an RWMutex for read-heavy state, and predict when readers hold it together
- Implement lazy initialisation with sync.Once, OnceFunc, OnceValue or OnceValues
- Find WaitGroup and lock mistakes with go vet and the race detector, and fix them
Before you start
On this page
Sixteen goroutines each added 100,000 to one shared counter, and without a lock the total came out short of
1,600,000, a different number on every run. With a sync.Mutex it was exactly 1,600,000 every time. The sync
package holds the tools that make shared data safe: a WaitGroup to wait for goroutines, a Mutex and an RWMutex to
guard data, and Once to run setup exactly once.
You will use each of them on a program that shows what it prevents, and predict which goroutines wait and for how long. Each output on this page is a run recorded on Go 1.27.1.
A WaitGroup counts goroutines, and wg.Go starts and counts them
A WaitGroup keeps a counter of tasks. wg.Go(f), new in Go 1.25, adds one, runs f in a new goroutine and takes
the one away when f returns; wg.Wait() blocks until the counter is zero. The older form does the same by hand:
wg.Add(1) before each go statement and defer wg.Done() inside the goroutine:
// wait_sum.go: a WaitGroup waits for 100 goroutines, started with wg.Go (Go 1.25) and with Add and Done.
package main
import (
"fmt"
"sync"
)
func sum(xs []int) int {
total := 0
for _, x := range xs {
total += x
}
return total
}
func main() {
parts := make([]int, 101) // each goroutine writes only its own element
var wg sync.WaitGroup
for i := 1; i <= 100; i++ {
wg.Go(func() { // counts the task, starts it, and marks it done when it returns
parts[i] = i
})
}
wg.Wait() // returns once all 100 have returned
fmt.Println("wg.Go, sum of 1..100:", sum(parts))
clear(parts)
var old sync.WaitGroup
for i := 1; i <= 100; i++ {
old.Add(1) // before the go statement, never inside the goroutine
go func() {
defer old.Done()
parts[i] = i
}()
}
old.Wait()
fmt.Println("Add and Done, the same sum:", sum(parts))
} Output
wg.Go, sum of 1..100: 5050 Add and Done, the same sum: 5050
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run wait_sum.go
Both forms summed 1 to 100 to 5050, each goroutine writing only its own element. Step through the counter:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- wg.Go, three times. Each call of wg.Go adds 1 to the counter and starts its function in a new goroutine. After three calls the counter is 3 and three goroutines run.
- main waits. wg.Wait blocks while the counter is above zero. main is now asleep inside Wait.
- The first one returns. When a function started by wg.Go returns, its task is done and the counter falls by one, to 2. Wait keeps waiting.
- The second one returns. Another goroutine returns, and the counter falls to 1.
- Zero releases Wait. The last goroutine returns and the counter reaches 0. Wait returns, and main goes on knowing that all three are done and their writes are visible to it.
When Wait returns, every counted goroutine has returned, and what each wrote before it finished is visible to the
goroutine that waited. wg.Go keeps the counting and the starting together, so the two can no longer drift apart.
Add inside the goroutine
go func() { wg.Add(1); … }() adds too late: Wait may run before any goroutine has started, see a counter of 0
and return at once. Call Add before the go statement, or use wg.Go, which cannot get it wrong.
go vet reports this mistake without running the code, and the race detector, turned on with -race, reports data
races while a program runs.
Checkpoint
Check: Waiting with a WaitGroup
A Mutex lets one goroutine at a time into a critical section
counter++ looks like one step but is three: read the value, add one, write it back. Two goroutines can both read 41
and both write 42, and an increment is lost. That is a data race: A write to a memory location at the same time as another read or write of it in another goroutine, with nothing ordering the two. Its result cannot be predicted, a lost update for example, and the Go memory model treats it as a bug., and here is what it
does to 1,600,000 increments:
// counter.go: 16 goroutines add 100,000 each to one counter, first with no lock, then with a Mutex.
package main
import (
"fmt"
"sync"
)
func main() {
const workers, adds = 16, 100_000
unguarded := 0
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for range adds {
unguarded++ // read, add, write: another goroutine can come between the steps
}
})
}
wg.Wait()
fmt.Printf("without a lock: %d of %d\n", unguarded, workers*adds)
var mu sync.Mutex
guarded := 0
for range workers {
wg.Go(func() {
for range adds {
mu.Lock()
guarded++ // one goroutine at a time runs this line
mu.Unlock()
}
})
}
wg.Wait()
fmt.Printf("with a Mutex: %d of %d\n", guarded, workers*adds)
} Output
without a lock: 1008101 of 1600000 with a Mutex: 1600000 of 1600000
This output changes from run to run: the total without a lock depends on how the goroutines' steps interleave, so it changes from run to run
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run counter.go
Without a lock the total fell short and changes with every run; with a mutex: A mutual exclusion lock, sync.Mutex. Lock waits until no other goroutine holds it, and releasing it lets the next one in, so the code in between runs in one goroutine at a time. Its zero value is a lock nobody holds. it was exactly 1,600,000. Step through one lost update:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three steps, not one. unguarded++ is a read, an add and a write. Nothing stops another goroutine from running its own steps in between.
- Goroutine 1 reads. Goroutine 1 reads the counter and gets 41.
- Goroutine 2 reads. Before goroutine 1 writes, goroutine 2 reads the same counter. It still holds 41.
- Goroutine 1 writes. Goroutine 1 adds 1 to the 41 it read and writes 42.
- Goroutine 2 writes. Goroutine 2 also adds 1 to the 41 it read, and writes 42 over goroutine 1's 42. Two increments ran, and the counter rose by one.
- With a Mutex. With mu.Lock() before and mu.Unlock() after, the second goroutine cannot read until the first has written, so it reads 42 and writes 43. No update is lost.
mu.Lock() waits until no other goroutine holds the lock, and mu.Unlock() releases it, so the code between them, the
critical section: Code that reads or writes shared data and must not run in two goroutines at once, such as the code a goroutine runs while it holds a Mutex., runs in one goroutine at a time. A Mutex’s zero value is a lock
nobody holds, ready to use. Write defer mu.Unlock() right after mu.Lock() in a function that can return early,
so every path releases it. A Mutex must not be copied after first use: share the struct that holds it by pointer.
Key idea
Every access to shared data that some goroutine writes must happen while holding the same lock, reads included.
Checkpoint
Check: Mutexes and critical sections
An RWMutex lets many readers in at once, or one writer
Data that is read far more often than it is written can use an RWMutex: A reader/writer lock, sync.RWMutex: any number of goroutines can hold its read lock together, taken with RLock, or a single goroutine its write lock, taken with Lock.. RLock takes a
read lock, which any number of goroutines can hold together; Lock takes the write lock, which excludes everyone.
Eight readers that each hold their lock for 100 ms show the difference:
// readers.go: eight readers hold a lock for 100 ms each. How many are inside at once with an RWMutex, and with a Mutex?
package main
import (
"fmt"
"sync"
"time"
)
// gauge counts the goroutines inside, and the most it has seen at once.
type gauge struct {
mu sync.Mutex
now, most int
}
func (g *gauge) enter() {
g.mu.Lock()
g.now++
g.most = max(g.most, g.now)
g.mu.Unlock()
}
func (g *gauge) leave() {
g.mu.Lock()
g.now--
g.mu.Unlock()
}
func main() {
var rw sync.RWMutex
var shared gauge
var wg sync.WaitGroup
start := time.Now()
for range 8 {
wg.Go(func() {
rw.RLock() // many readers may hold the read lock together
shared.enter()
time.Sleep(100 * time.Millisecond) // reading a large table
shared.leave()
rw.RUnlock()
})
}
wg.Wait()
fmt.Println("RWMutex: at most", shared.most, "readers at once; under 400 ms in all:", time.Since(start) < 400*time.Millisecond)
var mu sync.Mutex
var alone gauge
start = time.Now()
for range 8 {
wg.Go(func() {
mu.Lock() // one goroutine at a time, readers included
alone.enter()
time.Sleep(100 * time.Millisecond)
alone.leave()
mu.Unlock()
})
}
wg.Wait()
fmt.Println("Mutex: at most", alone.most, "reader at once; at least 800 ms in all:", time.Since(start) >= 800*time.Millisecond)
} Output
RWMutex: at most 8 readers at once; under 400 ms in all: true Mutex: at most 1 reader at once; at least 800 ms in all: true
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run readers.go
With the RWMutex all 8 readers were inside at once and the whole run took under 400 ms; with a Mutex they went in one at a time and needed at least 800 ms. A writer waits until every reader has released the read lock, and while it waits, new readers wait too, so a steady stream of readers cannot keep a writer out for ever.
The sync package also has two specialised types: sync.Map, a map for keys written once and read often, and
sync.Pool, a store of spare objects to reuse.
Checkpoint
Check: Readers and writers
sync.Once and OnceValues run initialisation exactly once
once.Do(f) calls f the first time it is called, for that Once, and never again; callers that arrive while f
runs wait for it to finish. Go 1.21 added three functions built on the same idea: sync.OnceFunc(f) returns a
function that calls f once, and sync.OnceValue and sync.OnceValues return a function that calls f once and
hands every caller its result, or its two results, usually a value and an error:
// once.go: four goroutines need the same configuration; sync.OnceValues loads it once and shares the result.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type config struct{ timezone, locale string }
var loads atomic.Int32
// load stands in for reading a file or calling a service: slow, and it can fail.
func load() (config, error) {
loads.Add(1)
return config{timezone: "Asia/Kolkata", locale: "en-IN"}, nil
}
var getConfig = sync.OnceValues(load) // a function that calls load on its first call only
func main() {
results := make([]string, 4)
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
c, err := getConfig() // every call gets the one result, error included
results[i] = fmt.Sprintf("%s %s %v", c.timezone, c.locale, err)
})
}
wg.Wait()
fmt.Println(results)
fmt.Println("load ran", loads.Load(), "time for 4 calls")
var once sync.Once
for range 3 {
once.Do(func() { fmt.Println("Once.Do: this line prints once") })
}
} Output
[Asia/Kolkata en-IN <nil> Asia/Kolkata en-IN <nil> Asia/Kolkata en-IN <nil> Asia/Kolkata en-IN <nil>] load ran 1 time for 4 calls Once.Do: this line prints once
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run once.go
Four goroutines called getConfig, made by sync.OnceValues(load), at the same time, and load ran once: every
caller got the same values and the same nil error. The Once printed its line once in three calls. This is
lazy initialisation: Creating a value on first use instead of when the program starts. In Go, sync.Once, OnceFunc, OnceValue and OnceValues do it safely from any number of goroutines, running the work exactly once.: the work happens on first use, not when the program
starts, and it is safe from any number of goroutines.
Checkpoint
Check: Once and lazy initialisation
WaitGroup and lock mistakes, reproduced with go vet, go fix and the runtime Pro: opens with a Pro, Premium or Ultimate pass
The sync types fail in two ways: quietly, with a wrong result or a race, and loudly, with a crash. This part runs the tools that find the quiet failures before they ship, rewrites old WaitGroup code automatically, and reproduces the crashes that recover cannot catch.
In this part:
- What go vet and the race detector catch, reproduced
- Crashes that recover cannot catch, and one that it can
6 runnable examples (Go, Python, lock_order.go, rlock_twice.go, unlock_twice.go) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Once when setup fails, a worked leaderboard, and choosing the right tool Pro: opens with a Pro, Premium or Ultimate pass
Setup can fail, shared state comes in several shapes, and the right tool depends on both. This part shows what each Once does when its function panics, builds a leaderboard that 64 goroutines share safely, and puts the choices side by side.
In this part:
- Once, OnceFunc and OnceValues when the loader panics
- A leaderboard for 64 goroutines: an RWMutex, OnceValues and no copied lock
- sync.Map and sync.Pool, the two specialised types
- Choosing the tool for the shared state
3 runnable examples (Go) · about 9 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Work through questions on WaitGroups, Mutexes, RWMutexes and Once, one at a time, beginning with the concept your answers show you know least:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is a critical section, and how does a Mutex protect one?
A critical section is code that reads or writes shared data and must not run in two goroutines at once. A Mutex
protects it: each goroutine calls Lock before the section and Unlock after it, so while one goroutine is inside,
every other one waits in Lock.
Three longer questions of the module bank build on this lesson:
-
Fresher to mid
A batch job starts one goroutine per file, and each goroutine calls wg.Add(1) as its first line, then defer wg.Done(). Tests pass on a laptop, but in production the report sometimes misses files. Explain why, fix it, and say which tools would have caught it.
-
Mid to senior
A cache's Get method takes the read lock of an RWMutex and calls stats(), which takes the read lock again. Refresh, which runs every minute, takes the write lock. The service passes every test, then freezes under production load. Explain the freeze and fix it.
-
Mid to senior
A payments client is created lazily with sync.Once. One morning the network was down when the first request came in, the creation failed, and every request since has used a nil client and crashed, although the network is back. Explain why, and redesign the initialisation.
Key takeaways
- A WaitGroup counts tasks:
wg.Gostarts and counts a goroutine,Waitreturns when the count is zero, and the olderAddbelongs before thegostatement. - An unguarded
counter++from many goroutines loses updates; a Mutex makes the critical section run in one goroutine at a time. - An RWMutex lets readers hold the lock together and gives a writer the lock alone.
sync.Once,OnceFunc,OnceValueandOnceValuesrun setup once, however many goroutines ask at the same time.
Terms in this lesson
- critical section
- Code that reads or writes shared data and must not run in two goroutines at once, such as the code a goroutine runs while it holds a Mutex.
- data race
- A write to a memory location at the same time as another read or write of it in another goroutine, with nothing ordering the two. Its result cannot be predicted, a lost update for example, and the Go memory model treats it as a bug.
- lazy initialisation
- Creating a value on first use instead of when the program starts. In Go, sync.Once, OnceFunc, OnceValue and OnceValues do it safely from any number of goroutines, running the work exactly once.
- mutex
- A mutual exclusion lock, sync.Mutex. Lock waits until no other goroutine holds it, and releasing it lets the next one in, so the code in between runs in one goroutine at a time. Its zero value is a lock nobody holds.
- RWMutex
- A reader/writer lock, sync.RWMutex: any number of goroutines can hold its read lock together, taken with RLock, or a single goroutine its write lock, taken with Lock.
- WaitGroup
- A sync.WaitGroup counts tasks, usually goroutines: wg.Go or Add raises the count, a finished task lowers it, and Wait blocks until the count is zero.
References
- Package sync (The Go Authors)
- Package sync: WaitGroup.Go (The Go Authors)
- Package sync: RWMutex (The Go Authors)
- Package sync: Once (The Go Authors)
- Package sync: OnceValues (The Go Authors)
- Package sync: Pool (The Go Authors)
- Package sync: Map (The Go Authors)
- The Go Memory Model: Locks (The Go Authors)
- The Go Memory Model: Once (The Go Authors)
- Go 1.25 Release Notes: sync (The Go Authors)
- Go 1.25 Release Notes: Vet (The Go Authors)
- Go 1.21 Release Notes: sync (The Go Authors)
- Go 1.27 Release Notes: go fix (The Go Authors)
- Command vet (The Go Authors)
- Package modernize: the waitgroupgo analyzer (The Go Authors)
- Data Race Detector (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress