16 — sync and the Race Detector — When Channels Are Not Enough
Channels are the headline act of Go concurrency, but a whole half of the story exists for when they're the wrong tool. The model that clicked: channels carry ownership and pass values between goroutines, but when several goroutines genuinely need to read and write the _same_ shared state, the sync package provides the explicit locks (Mutex, RWMutex) and coordination primitives (WaitGroup, Once) to do it safely, and the -race flag catches the data races you missed. [1][5] These are not a fallback to be ashamed of — they are the right answer for shared-state concurrency, used freely throughout the standard library.
Where sync fits — channels vs shared state
Go's slogan is "share memory by communicating," which steers you toward channels. But channels are fundamentally about _transferring_ ownership of data between goroutines. The moment the design is "many goroutines read and update one shared counter/cache/config map," transferring ownership through a channel gets awkward. That is where sync steps in: locks that protect a shared variable in place, and a WaitGroup that lets the main goroutine wait for several others to finish [1]. The two approaches are complementary, not competing — a well-designed Go program uses both.
Mutexes — protecting a critical section
A sync.Mutex is a lock with two operations: Lock() and Unlock() [2]. Only one goroutine can hold the lock at a time; others block at Lock() until it is released:
var (
mu sync.Mutex
count int
)
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}The pattern is rigid: Lock, then defer Unlock (so the unlock happens no matter how the function returns), then the critical section. defer is essential — forgetting to unlock on an early return is a classic deadlock source. The mutex protects the shared variable by ensuring only one goroutine touches it at a time.
The variant sync.RWMutex allows multiple concurrent readers _or_ one exclusive writer — RLock()/RUnlock() for readers, Lock()/Unlock() for writers [2]. Use it when reads vastly outnumber writes (a cache, a config snapshot), so readers do not block each other. For a counter that is mostly incremented, a plain Mutex is simpler and often faster.
One subtlety: a sync.Mutex is not re-entrant. A goroutine that holds the lock and tries to Lock() again will deadlock. The standard idiom of "zero-value mutex embedded in a struct" (mu sync.Mutex as a field with no initialization) works because its zero value is a valid unlocked mutex — copy a struct containing a mutex, though, and you copy the lock state, which is a bug; the linters flag mutex copies.
WaitGroups — waiting for goroutines to finish
A sync.WaitGroup is a counter that lets one goroutine block until N others have signaled completion [3]:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
doWork(n)
}(i)
}
wg.Wait() // blocks until all 5 have called DoneThree operations: Add(n) increments the counter (usually by 1 before launching a goroutine), Done() decrements it (always via defer), and Wait() blocks until the counter hits zero. The rule to avoid the common race: call Add _before_ launching the goroutine, not inside it. The whole WaitGroup exists so main does not exit before its workers finish — the gap between "go func" and "main returns" that a raw goroutine leaves open.
sync.Once is the third common primitive — it guarantees a function runs exactly once across all goroutines, which is the idiomatic way to implement lazy initialization of a singleton [1].
The race detector — catching what review misses
Data races are the scariest concurrency bug: two goroutines access the same variable, at least one writes, and there is no synchronization between them. The result is undefined behavior — values can be torn, reads can see half-written state, and the bug may not reproduce on every run. Go ships a built-in race detector that catches these at runtime [4][5]:
go test -race ./...
go run -race main.go
go build -race -o myappThe -race flag instruments the compiled code to track every memory access and reports any unsynchronized concurrent access with the two stack traces involved. The cost is roughly 2x slower execution and more memory, so you run it in tests and CI, not production [4]. The discipline: run go test -race in CI on every push. It catches races that human review and even stress tests miss, and a race found in CI is a bug fixed before deploy rather than a mystery crash at 3am.
The detector cannot find races that did not execute during the test run — it is not a static proof, it observes actual runs. So thorough tests matter: cover the concurrent paths, not just the happy path. But for the races it does observe, the report is precise and actionable.
How I use this
The decision tree is now automatic. If the design is "pass data from producer to consumer," use a channel. If it is "many goroutines update one shared counter, cache, or registry," reach for a sync.Mutex (or RWMutex for read-heavy) guarding the shared variable, with defer Unlock as a reflex. For "wait for N goroutines," a WaitGroup. And go test -race runs on every commit — it is the cheapest, highest-leverage tool in the concurrency toolbox, and skipping it is how races ship to production. The combination — channels for ownership, sync for shared state, -race for verification — is what makes Go concurrency trustworthy rather than merely powerful.
References
[1] The Go Authors, "sync package," pkg.go.dev, 2024. [Online]. Available: https://pkg.go.dev/sync
[2] L. Mathew, "What is Mutex and How to Use it in Golang?," dev.to, 2024. [Online]. Available: https://dev.to/lincemathew/what-is-mutex-and-how-to-use-it-in-golang-1m1i
[3] D. Misik, "WaitGroup in Go — How and when to use WaitGroup," Medium, 2024. [Online]. Available: https://medium.com/@dmytro.misik/waitgroup-in-go-df8f068e646f
[4] The Go Authors, "Data Race Detector," Go Documentation, 2024. [Online]. Available: https://go.dev/doc/articles/race_detector
[5] Sobyte, "Data Race Detection and Data Race Patterns in Golang," 2022. [Online]. Available: https://www.sobyte.net/post/2022-06/go-data-race/
Knowledge check · Question 1 of 5
When should you reach for a sync.Mutex instead of a channel?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!