Mutexes and WaitGroups
Channels are Go's headline act, but they aren't the whole story. The honest division of labor: channels are for handing data off between goroutines; mutexes are for guarding shared state that several goroutines read and write in place. A counter, a cache, a map of sessions — wrapping those in channel ceremony is more code and less clarity than a lock. Go ships both tools because both are right, in different places.
Data races
Here's the bug mutexes exist to kill:
counter := 0
for i := 0; i < 1000; i++ {
go func() { counter++ }()
}
counter++ is three steps — read, add, write — and two goroutines can
interleave them: both read 5, both write 6, one increment vanishes. That's a
data race: two goroutines touching the same memory at the same time, at
least one writing. The final count is anyone's guess; with maps it's worse —
the runtime detects concurrent map writes and panics.
Go can catch races for you: go test -race (or go run -race) builds with
the race detector, which watches memory accesses at runtime and reports
conflicts with stack traces. It only sees races that actually occur during
the run — so it's a smoke alarm, not a proof — but make -race a habit on
any concurrent code.
sync.Mutex
A mutex (mutual exclusion lock) lets one goroutine in at a time:
type SafeCounter struct {
mu sync.Mutex
counts map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.counts[key]++
}
Lock blocks until the mutex is free, Unlock releases it. Everything
between them is the critical section — only one goroutine runs it at a
time. defer c.mu.Unlock() right after Lock is the standard idiom: the
unlock can't be forgotten on any return path or panic.
The rules: guard every access to the shared data, reads included — a read racing a write is still a race. Keep critical sections short. And note the pointer receiver: copying a struct that contains a mutex copies the lock state, which breaks it, so a type with a mutex is passed by pointer.
When reads dominate: sync.RWMutex
"Guard the reads too" means a plain Mutex serialises readers against each
other, even though two goroutines only reading cannot corrupt anything.
sync.RWMutex splits the lock in two:
mu.RLock() // many goroutines may hold the read lock at once
_ = c.counts[key]
mu.RUnlock()
mu.Lock() // a write lock excludes everyone, readers included
c.counts[key]++
mu.Unlock()
This pays off only when reads genuinely dominate and the critical section is
long enough for the concurrency to matter — a config map read by every
request and rewritten once a minute is the classic fit. RWMutex is the
heavier lock of the two, so for a short critical section under a balanced
read/write mix, the plain Mutex is usually faster. Measure before
switching; do not reach for it reflexively.
sync.WaitGroup, precisely
You've been using WaitGroup since the goroutines lesson; here are its lifecycle rules, because getting them wrong is a classic race:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1) // BEFORE the go statement
go func(j Job) {
defer wg.Done()
process(j)
}(job)
}
wg.Wait()
Addbeforego. If the goroutine calledAdditself,Waitcould run while the counter is still 0 and return before any work starts.Doneexactly once perAdd(1)—deferit so panics still count down.- Don't copy a WaitGroup; like a mutex, it's pass-by-pointer.
One more member of the family, in one sentence: sync.Once runs a function
exactly once no matter how many goroutines call it — the standard tool for
lazy, race-free initialization.
Your exercise combines today's two ideas: a SafeCounter guarded by a
mutex, hammered by a hundred goroutines coordinated with a WaitGroup.
Practice
1. Safe Counter
IntermediateImplement the methods of
SafeCounter, a counter keyed by string that many goroutines can use at once:type SafeCounter struct { mu sync.Mutex counts map[string]int } func NewSafeCounter() *SafeCounter // provided — creates the map func (c *SafeCounter) Inc(key string) // add 1 to key's count func (c *SafeCounter) Value(key string) int // current count (0 if never incremented)Incincrements the count forkeyby one.Valuereturns the current count forkey; a key that was never incremented reports0.- Both methods must be safe to call from many goroutines simultaneously:
take the mutex before touching the map, release it after —
Lock+defer Unlock.
The hidden tests first check plain sequential behavior, then hammer one counter from 100 goroutines doing 100 increments each and require exactly 10,000. An unsynchronized map won't just miscount under that load — Go's runtime usually kills the program with
fatal error: concurrent map writes. The mutex is not optional.package main import ( "fmt" "sync" ) // SafeCounter counts occurrences per key, safely across goroutines. type SafeCounter struct { mu sync.Mutex counts map[string]int } // NewSafeCounter returns a ready-to-use SafeCounter. func NewSafeCounter() *SafeCounter { return &SafeCounter{counts: make(map[string]int)} } // Inc adds 1 to the count for key. func (c *SafeCounter) Inc(key string) { // TODO: increment the map entry — while holding the mutex. } // Value returns the current count for key (0 if never incremented). func (c *SafeCounter) Value(key string) int { // TODO: read the map entry — while holding the mutex. return 0 } func main() { c := NewSafeCounter() c.Inc("hits") c.Inc("hits") fmt.Println(c.Value("hits")) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.