Module lessons (4/5)
`sync.Mutex` and `sync.WaitGroup`
Channels are Go's primary idiom, but sometimes more traditional
primitives are needed. The sync package provides mutexes, wait
groups and other utilities to coordinate shared state.
sync.Mutex: exclusive lock
import "sync"
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Only one goroutine at a time enters between Lock() and Unlock().
Mandatory pattern: defer mu.Unlock() right after Lock().
sync.RWMutex: many readers, one writer
For read-heavy state:
var mu sync.RWMutex
mu.RLock() // read lock: più goroutine simultanee OK
v := data
mu.RUnlock()
mu.Lock() // write lock: esclusivo
data = newValue
mu.Unlock()Useful only if reads really dominate over writes; otherwise a regular
sync.Mutex is simpler and often faster thanks to lower internal
contention.
sync.WaitGroup: wait for N goroutines
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
work(id)
}(i)
}
wg.Wait() // blocca finché tutti i Done() sono arrivatiWorkflow:
wg.Add(N)increments the counter by N (before launching the goroutines).- Each goroutine calls
wg.Done()when it finishes (ideally viadefer). wg.Wait()blocks until the counter goes back to 0.
sync.Once: thread-safe lazy initialization
var (
once sync.Once
config *Config
)
func GetConfig() *Config {
once.Do(func() {
config = loadConfig()
})
return config
}The callback in once.Do runs exactly once, even under concurrent
calls. Singleton / lazy initialization pattern.
Mutex vs channel: when to use what
Guidelines (also from the official Go FAQ):
| Case | Preferred tool |
|---|---|
| Passing ownership of data | channel |
| Distributing work (work queue) | channel |
| Coordinating independent goroutines | channel |
| Protecting a struct field | Mutex |
| Cache / counter | Mutex or atomic |
| Singleton reference | sync.Once |
"Channels to orchestrate, mutexes for shared data" is a good rule of thumb.
Race detector
Run tests with -race to spot data races:
go test -race ./...
go run -race main.goThe race detector instruments the binary and logs every unsynchronized access to shared memory. It is tool #1 to validate concurrent code before production.
Try it
Protect the increment of count with mu.Lock() + defer mu.Unlock().
Show hint
defer mu.Unlock() right after Lock() guarantees release even on panic.
Solution available after 3 attempts
Launch 3 goroutines and wait for all of them using sync.WaitGroup (Add(3), Done() in each goroutine, Wait()).
Show hint
Add BEFORE launching the goroutine; Done INSIDE the goroutine with defer.
Solution available after 3 attempts
What is the recommended pattern to guarantee the mutex is released?
mu.Lock()
// ?Recap
sync.Mutexprotects shared state; alwaysdefer mu.Unlock().- Use a pointer receiver to avoid copying the mutex.
sync.RWMutexfor many-readers/one-writer patterns (only if it helps).sync.WaitGroup:Addbeforego,Donewithdefer,Waitto wait.sync.Once: thread-safe lazy initialization.- "Channels to orchestrate, mutexes for shared data".
go test -race: tool #1 to spot data races.