Introduction
While channels are Go's preferred way to coordinate goroutines through communication, sometimes you need lower-level synchronization primitives to protect shared memory directly or wait for a set of tasks to finish. The standard library's sync package provides these tools: sync.WaitGroup for waiting on a group of goroutines, sync.Mutex and sync.RWMutex for guarding shared data from concurrent access, and sync.Once for guaranteeing that initialization code runs exactly one time.
Cricket analogy: While channels are like passing the ball between fielders to communicate, sometimes you need a direct lock on the scoreboard itself so two scorers don't write over each other; sync.Mutex guards that shared scoreboard, sync.WaitGroup waits for all fielders to reach position, and sync.Once ensures the pitch is only rolled once before play.
Syntax
var wg sync.WaitGroup
wg.Add(1) // register one goroutine to wait for
go func() {
defer wg.Done() // signal completion
}()
wg.Wait() // block until all Add'd goroutines call Done
var mu sync.Mutex
mu.Lock()
// critical section: access shared data
mu.Unlock()
var once sync.Once
once.Do(func() {
// runs only on the first call, ever
})Explanation
sync.WaitGroup maintains an internal counter: Add(n) increases it, Done() decrements it (typically via defer), and Wait() blocks until the counter reaches zero — a clean way to wait for many goroutines without polling. sync.Mutex provides mutual exclusion: Lock() acquires exclusive access and Unlock() releases it, ensuring only one goroutine at a time executes the critical section between them; sync.RWMutex extends this by allowing many concurrent readers (RLock/RUnlock) but only one writer (Lock/Unlock) at a time, which helps read-heavy workloads. sync.Once guarantees that a function passed to Do executes exactly once across all goroutines, no matter how many times Do is called — ideal for lazy, thread-safe initialization such as setting up a singleton or loading configuration.
Cricket analogy: sync.WaitGroup is like a captain counting fielders back to the pavilion, Add for each one sent out, Done as each returns, Wait blocking until all are in; sync.Mutex is like one scorer allowed to write at a time, RWMutex lets many spectators read the scoreboard but only one scorer edit it, and sync.Once is like the pitch being rolled exactly once before the match no matter how many groundstaff ask.
Example
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}Output
Final counter: 100
Without the mutex protecting counter++, concurrent increments from 100 goroutines could race and produce a value less than 100 (a data race). The mutex ensures each increment is atomic relative to the others, so the final result is always correct.
Cricket analogy: Just as a match without a designated scorer could let two people write conflicting totals on the board, a race between 100 unguarded increments could undercount; the mutex is like appointing one official scorer so every run is recorded correctly, giving the true final count of 100.
Key Takeaways
- sync.WaitGroup coordinates waiting for a group of goroutines via Add, Done, and Wait.
- sync.Mutex guards shared state with Lock/Unlock so only one goroutine accesses it at a time.
- sync.RWMutex allows multiple concurrent readers but exclusive writers.
- sync.Once ensures initialization code runs exactly once, safely across goroutines.
- Always call Done() (often via defer) to avoid a WaitGroup that never unblocks.
- Run 'go run -race' to detect unprotected concurrent access to shared data.
Practice what you learned
1. What is the purpose of sync.WaitGroup?
2. What does sync.Mutex's Lock() and Unlock() pair guarantee?
3. What is sync.Once used for?
4. How does sync.RWMutex differ from a plain sync.Mutex?
Was this page helpful?
You May Also Like
Goroutines in Go
Goroutines are lightweight, runtime-managed threads that let Go programs run functions concurrently with minimal overhead.
Channels in Go
Channels are typed conduits that let goroutines safely send and receive values, embodying Go's share-memory-by-communicating philosophy.
Worker Pools in Go
A worker pool spawns a fixed number of goroutines that consume jobs from a channel, an idiomatic pattern for bounded concurrent processing.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics