The sync Package: Mutex, RWMutex, WaitGroup, Once
Module 3: Concurrency | Lesson: m03-l04 | Level: Intermediate After this lesson you will be able to: use the core synchronization primitives in Go's
syncpackage correctly, understand lock contention and object reuse under shared-memory concurrency, and write race-free concurrent programs.
Learning Objectives
- Explain the difference between
MutexandRWMutex, and choose the right lock for different read/write ratios. - Use
WaitGroupcorrectly to coordinate the lifecycles of multiple goroutines, avoiding deadlocks and data races. - Use
Onceto implement thread-safe singletons or one-time initialization. - Use
Poolto optimize high-frequency object allocation/recycling, and understand its zeroing behavior.
Core Concepts
1.1 Shared Memory and Locks (Mutex & RWMutex)
In Go, shared memory is the core concurrency model. When multiple goroutines access the same memory, a synchronization mechanism must guarantee data consistency. Locks are the most basic synchronization primitive.
A Mutex is a mutual-exclusion lock: only one goroutine may hold it at a time. Its core methods are Lock() and Unlock().
An RWMutex is a reader/writer lock: multiple read locks may be held concurrently, but a write lock is exclusive. It provides RLock(), RUnlock(), Lock() and Unlock().
Why this design?
In a concurrent system, reads usually do not modify data, so allowing concurrent reads can significantly improve throughput. However, if the critical section is very short or the read/write ratio is close, the extra state-maintenance cost of RWMutex can make it slower than a plain Mutex.
How?
- Mutex fits write-heavy scenarios or longer critical sections.
- RWMutex fits read-mostly scenarios (such as a config cache).
- Atomic operations (such as the sync/atomic package) outperform locks in contention-free or simple counting scenarios, but locks are clearer for complex logic.
1.2 WaitGroup
A WaitGroup waits for a group of goroutines to finish. It has three methods:
- Add(delta int): increments the counter.
- Done(): decrements the counter by one, usually called at the end of a goroutine.
- Wait(): blocks until the counter reaches zero.
Pitfall: Add must be called before Wait, or you get a deadlock or panic. WaitGroup must not be copied; copying it leads to undefined behavior.
1.3 Once
Once guarantees that a function runs exactly once for the entire lifetime of the program. It is commonly used for singletons or resource initialization.
- Once.Do(f func()): if f has not run, it runs and is marked as done; otherwise it returns immediately.
1.4 Pool
sync.Pool caches temporary objects to reduce GC pressure.
- Get(): fetches an object from the pool; if the pool is empty, it calls the New function to create one.
- Put(obj any): returns an object to the pool.
Note: Pool does not guarantee objects are retained; the GC may clean up pooled objects. After fetching an object, you must reset its state yourself, because pooled objects may carry leftover data.
Example Code
Example 1: RWMutex Protecting a Shared Counter
This example uses an RWMutex to protect a shared counter, simulating concurrent reads and writes.
package main
import (
"fmt"
"sync"
)
var (
mu sync.RWMutex
counter int
)
func main() {
var wg sync.WaitGroup
// start 5 writer goroutines
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
mu.Lock()
counter++
fmt.Printf("Writer %d: counter is %d\n", id, counter)
mu.Unlock()
}(i)
}
// start 5 reader goroutines
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
mu.RLock()
fmt.Printf("Reader %d: counter is %d\n", id, counter)
mu.RUnlock()
}(i)
}
wg.Wait()
}
Expected output:
Writer 0: counter is 1
Reader 0: counter is 1
Writer 1: counter is 2
Reader 1: counter is 2
Writer 2: counter is 3
Reader 2: counter is 3
Writer 3: counter is 4
Reader 3: counter is 4
Writer 4: counter is 5
Reader 4: counter is 5
(Note: because goroutine scheduling order is nondeterministic, the interleaving of Writer and Reader lines may differ, but the counter value increases with each write.)
Example 2: sync.Pool Object Reuse and Reset
This example shows sync.Pool usage, focusing on the fact that you must reset state after fetching an object, because pooled objects may carry leftover data.
package main
import (
"fmt"
"sync"
)
type Buffer struct {
Data []byte
}
var bufPool = sync.Pool{
New: func() any {
return &Buffer{Data: make([]byte, 64)}
},
}
func main() {
// first Get: the New function is called
buf1 := bufPool.Get().(*Buffer)
buf1.Data[0] = 'A'
fmt.Printf("Buf1 content: %s\n", buf1.Data[:1])
// return it to the pool
bufPool.Put(buf1)
// second Get: may return the buf1 we just put back
buf2 := bufPool.Get().(*Buffer)
// Key point: must reset the slice, because buf2 may contain the old 'A'
buf2.Data[0] = 'B'
fmt.Printf("Buf2 content: %s\n", buf2.Data[:1])
// clear and return
buf2.Data[0] = 0
bufPool.Put(buf2)
}
Expected output:
Buf1 content: A
Buf2 content: B
Common Mistakes & Pitfalls
-
Copying a Mutex - Bad code:
var mu sync.Mutex; mu2 := mu; mu2.Lock()- Why:sync.Mutexcarries internal state; copying it makes two instances contend over the same lock or corrupt state, causing undefined behavior. - Correct approach: always pass a pointer, or make sure you use the original variable. -
Calling WaitGroup's Add after Wait - Bad code: calling
wg.Wait()first, then callingwg.Add(1)inside a goroutine. - Why:Waitblocks until the counter is zero. If youAddafterwards,Waitnever returns and you deadlock. - Correct approach: make sure allAddcalls happen beforeWait. -
Writing to a nil map panics - Bad code:
go var data map[string]string data["key"] = "value" // panic: assignment to entry in nil map- Why: a declared map that is not initialized is nil and cannot be written to. - Correct approach: initialize withmake(map[string]string), or usesync.Mapfor concurrent writes.
Best Practices
- Choose the right lock: if data is mostly read and the critical section has some length, use
RWMutex; otherwise preferMutex. When reads are under ~70% of operations,RWMutexcan be slower. - Lock granularity: keep critical sections as short as possible; avoid doing I/O or expensive computation while holding the lock.
- Using Pool: after fetching an object from
Pool, always reset its state (clear slices, reset fields), because pooled objects may carry leftover data. - Thread safety of Once:
Once.Dois thread-safe and fits singleton initialization, but do not callDoagain from inside the function passed toDo— it can deadlock.
Exercises
- Concept question: explain the performance difference between
sync.Mutexandsync.RWMutexunder concurrent reads, and the conditions under which each is appropriate. - Coding exercise: write a program that uses
WaitGroupto start 10 goroutines, each computing the sum from 1 to 100, then aggregates and prints the total. - Advanced/debugging exercise: the following code has a race condition. Find the problem and fix it. ```go package main
import ( "fmt" "sync" )
var counter int var mu sync.Mutex
func increment(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() counter++ mu.Unlock() }
func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Counter:", counter) } ``` (Note: the code above looks correct, but consider whether there is a latent problem? Actually, the code above is correct. To make it "advanced", we provide a buggy version for learners to fix.)
The buggy version: ```go package main
import ( "fmt" "sync" )
var counter int var mu sync.Mutex
func increment(wg *sync.WaitGroup) { defer wg.Done() // Bug: Lock and Unlock are not paired, and the logic is wrong mu.Lock() counter++ // forgot Unlock, so later goroutines deadlock }
func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Counter:", counter) } ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
RWMutex lets multiple reader goroutines hold the read lock simultaneously, improving read concurrency; Mutex allows only one goroutine at a time. When reads greatly outnumber writes and the critical section runs for a measurable time, RWMutex significantly improves throughput. But if the read/write ratio is close or the critical section is very short, RWMutex's extra overhead can make it slower than Mutex.
Question 2
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var total int
var totalMu sync.Mutex
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
sum := 0
for j := 1; j <= 100; j++ {
sum += j
}
totalMu.Lock()
total += sum
totalMu.Unlock()
}()
}
wg.Wait()
fmt.Println("Total sum:", total)
}
Question 3
Problem analysis: the increment function calls mu.Lock() and increments the counter but forgets to call mu.Unlock(). After the first goroutine takes the lock, every other goroutine blocks forever at mu.Lock(), and wg.Wait() never returns — the program deadlocks.
Fixed code:
package main
import (
"fmt"
"sync"
)
var counter int
var mu sync.Mutex
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter)
}
Lesson Summary
MutexandRWMutexare the core tools for protecting shared memory; choose by read/write ratio.WaitGroupwaits for a group of goroutines; watch the ordering ofAddandWait.Onceguarantees initialization runs exactly once and fits singletons.Poolreuses objects to reduce GC pressure, but you must reset state after fetching.
Next lesson preview: context — timeouts, cancellation and request-scoped data propagation.