Go, End to End
The sync Package and Data Races
Go's official concurrency slogan is "don't communicate by sharing memory, share memory by communicating", meaning: prefer passing data through channels over multiple goroutines poking at the same shared variable. But shared mutable state is sometimes genuinely the right tool, and the `sync` package is what you reach for when it is.
sync.Mutex
A mutex (mutual exclusion lock) guarantees only one goroutine at a time can be inside the section of code between `Lock()` and `Unlock()`. It's the direct, low-level way to protect shared state, the exact thing channels exist to often let you avoid needing.
Always defer the Unlock, right after Lock, on the very next line. It's tempting to Unlock manually right before a return to 'be precise', but the moment that function grows a second return path or an early error return, a manual Unlock is one of the easiest things to forget, and a forgotten Unlock means every future Lock on that mutex blocks forever.
sync.RWMutex
`RWMutex` splits locking into read locks and write locks: any number of readers can hold a read lock (`RLock`/`RUnlock`) simultaneously, but a writer (`Lock`/`Unlock`) needs exclusive access, blocking out both other writers and all readers. Use it when reads vastly outnumber writes and you want concurrent readers not to block each other unnecessarily.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}sync/atomic
For a single counter or flag, `sync/atomic` gives you lock-free atomic operations, often cheaper than a full mutex for that narrow case. Go 1.19 added typed atomic wrappers (`atomic.Int64`, `atomic.Bool`, and friends) that are much less error-prone than the older raw-pointer-based `atomic.AddInt64(&n, 1)` style.
What a data race actually is
A data race is two or more goroutines accessing the same memory at the same time, with at least one of them writing, and no synchronization between them. The outcome is officially undefined by the language spec, not just "probably fine", genuinely undefined: the compiler is permitted to do things you wouldn't expect, because Go's memory model assumes races don't happen and optimizes accordingly.
Run this enough times and you'll often see a number less than 1000, because `counter++` isn't atomic, it's a read, an increment, and a write, and two goroutines can interleave those three steps and stomp on each other's update. The specific wrong number you get isn't even deterministic across runs, which is exactly what makes data races so miserable to debug from symptoms alone.
The race detector
Go ships a built-in race detector: run any program or test with `-race` (`go run -race main.go`, `go test -race ./...`) and the runtime instruments every memory access, flagging races as they actually happen during that specific execution, with a stack trace pointing at both conflicting accesses.
The race detector only catches races that actually occur during that run, it can't prove their absence, only their presence when they happen to trigger. Run your test suite with -race routinely, not just once, especially for anything touching goroutines, and treat any race it reports as a real bug to fix immediately, never as a false positive to silence.
Choosing between channels and sync
- Default to channels when the goal is passing ownership of data between goroutines, a pipeline, a worker pool, a result being handed off. That's exactly what "share memory by communicating" means in practice.
- Reach for a mutex when several goroutines all need to read and write the same long-lived state (a cache, an in-memory counter, connection pool bookkeeping) and there's no natural 'ownership handoff' to model with a channel.
- Reach for sync/atomic only for a single simple counter or flag, the moment you need to update more than one related value together consistently, you need a mutex instead, atomics don't compose across multiple fields.
Try it yourself: fix the data race with a mutex›
Take the racy counter++ example above and fix it properly with a Mutex, then confirm the final count is reliably 1000 across several runs: