The sync package gives us the classic low-level concurrency tools — locks, wait groups, one-time initializers. Channels are great, but sometimes we just need to guard a shared variable or wait for a bunch of goroutines to finish. That’s what sync is for.
Mutex — guarding shared state
A sync.Mutex is a lock. Only one goroutine can hold it at a time. We Lock() before touching shared data and Unlock() after.
In simple language, think of it like a single bathroom key. Whoever has the key gets in; everyone else waits in line.
var (
mu sync.Mutex
counter int
)
func increment() {
mu.Lock() // grab the key
defer mu.Unlock() // always release it, even if we panic
counter++ // safe: only one goroutine here at a time
}
The defer mu.Unlock() is the idiomatic move — it guarantees we release the lock no matter how the function exits.
RWMutex — many readers, one writer
A sync.RWMutex is smarter when reads vastly outnumber writes. Many goroutines can hold the read lock at once, but a write lock is exclusive.
var rw sync.RWMutex
rw.RLock() // multiple readers allowed together
// ... read shared data ...
rw.RUnlock()
rw.Lock() // exclusive — blocks all readers and writers
// ... write shared data ...
rw.Unlock()
Use it when reads are frequent and writes are rare, like a config that’s read constantly but updated occasionally.
WaitGroup — wait for goroutines to finish
This is the canonical pattern, and it shows up in basically every Go interview. A sync.WaitGroup counts running goroutines and lets us block until they’re all done.
The recipe is Add, Done, Wait:
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // tell the group "one more goroutine"
go func(id int) {
defer wg.Done() // signal "this one finished"
fmt.Println("worker", id, "done")
}(i)
}
wg.Wait() // block here until the counter hits zero
fmt.Println("all workers finished")
Three rules to remember: call Add before launching the goroutine (not inside it), always defer wg.Done(), and Wait in the goroutine that needs everything finished.
Once — run something exactly once
sync.Once guarantees a function runs only one time, even if many goroutines call it. Perfect for lazy initialization (setting up a connection, loading config).
var once sync.Once
func setup() {
once.Do(func() {
fmt.Println("initializing — runs only once") // safe under concurrency
})
}
Call setup() from a hundred goroutines and the init block still runs exactly once.
sync.Map — a concurrent map (sometimes)
A regular Go map is not safe for concurrent writes — it’ll panic. sync.Map is a ready-made concurrent map, but it’s specialized. It shines in two cases: keys are written once and read many times, or different goroutines touch disjoint keys.
For the common case, a plain map guarded by a sync.Mutex is usually clearer and faster. Reach for sync.Map only when profiling says so.
atomic — the lightweight teaser
For simple counters and flags, sync/atomic does lock-free updates that are faster than a mutex.
var n int64
atomic.AddInt64(&n, 1) // atomic increment, no lock
v := atomic.LoadInt64(&n) // atomic read
Great for a single counter; not a replacement for a mutex when you need to guard multiple fields together.
Mutex vs channels — when to use which
The rule of thumb: use a mutex to protect shared state (a counter, a cache, a struct field). Use a channel to transfer ownership of data or coordinate goroutines (passing work, signaling done). If you’re sharing memory, lock it. If you’re communicating, channel it.
Interview soundbite
The sync package is the lock-based toolkit: Mutex/RWMutex guard shared state, WaitGroup waits for goroutines via the Add/Done/Wait pattern, and Once runs init exactly once. The rule of thumb: use a mutex to protect shared memory, use a channel to communicate and transfer ownership. For simple counters, sync/atomic is faster than a mutex.