Introduction
Goroutines are Go's lightweight concurrency primitive, but without coordination they become a liability: one goroutine might read data while another writes it, or the main function exits before background tasks complete. That's where sync.WaitGroup and Mutex: a hands-on guide for engineers becomes essential. These two synchronization tools from the standard library let you coordinate goroutines safely and predictably—critical skills for any production API serving multiple concurrent requests.
In this guide, we'll walk through real scenarios you'll encounter building REST APIs, background job processors, and data pipelines. We'll show when to reach for WaitGroup, when Mutex is the right choice, and when channels might be better still.
WaitGroup waits for goroutines
A sync.WaitGroup is a counter that blocks until all tracked goroutines signal completion. Think of it as a checkpoint: the main thread (or coordinating goroutine) can't proceed until N workers finish their tasks. This is foundational for patterns like fan-out/fan-in processing.
Here's a concrete example: imagine a bulk email service that processes user segments in parallel.
package main
import (
"fmt"
"sync"
"time"
)
func sendEmailsToSegment(userID int, delay time.Duration) {
time.Sleep(delay)
fmt.Printf("Email sent to user %d\n", userID)
}
func main() {
var wg sync.WaitGroup
segments := []struct {
userID int
delay time.Duration
}{
{1, 100 * time.Millisecond},
{2, 50 * time.Millisecond},
{3, 150 * time.Millisecond},
}
for _, seg := range segments {
wg.Add(1)
go func(uid int, d time.Duration) {
defer wg.Done()
sendEmailsToSegment(uid, d)
}(seg.userID, seg.delay)
}
wg.Wait()
fmt.Println("All emails sent")
}
Key points here:
wg.Add(1)increments the counter before launching each goroutine.defer wg.Done()decrements the counter when the goroutine exits, even if it panics.wg.Wait()blocks until the counter reaches zero.
A critical production pattern is using WaitGroup for graceful shutdown. When your API server receives a termination signal, you want ongoing requests to complete (up to a timeout) rather than killing them mid-flight.
package main
import (
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func handleRequest(id int) {
time.Sleep(time.Second)
fmt.Printf("Request %d completed\n", id)
}
func main() {
var wg sync.WaitGroup
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Simulate incoming requests
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(reqID int) {
defer wg.Done()
handleRequest(reqID)
}(i)
}
// Wait for signal or all requests done
go func() {
<-sigChan
fmt.Println("Shutdown signal received")
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
fmt.Println("All requests completed gracefully")
case <-time.After(5 * time.Second):
fmt.Println("Shutdown timeout exceeded")
}
}
WaitGroup is simple but powerful: use it when you need to wait for an unknown number of goroutines (determined at runtime) to finish a discrete batch of work.
Mutex protects shared data
A sync.Mutex is a mutual exclusion lock: only one goroutine can hold it at a time. You use it to guard access to shared memory so concurrent reads and writes don't corrupt state. Without it, a data race can silently corrupt your data in production.
Consider a simple counter service that multiple API handlers increment:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Printf("Final count: %d\n", counter.Get())
}
Without the mutex, multiple goroutines reading and writing c.value simultaneously could produce incorrect results. The pattern is straightforward:
- Lock before accessing the protected data.
- Use
defer Unlock()so you always release the lock, even if the code panics. - Keep the critical section as short as possible—compute outside the lock, acquire it, update, release it.
A real production example: a request-scoped metrics aggregator that multiple handler goroutines update in parallel.
package main
import (
"fmt"
"sync"
"time"
)
type RequestMetrics struct {
mu sync.Mutex
requestCount int
totalLatency time.Duration
}
func (m *RequestMetrics) RecordRequest(latency time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.requestCount++
m.totalLatency += latency
}
func (m *RequestMetrics) GetAvgLatency() time.Duration {
m.mu.Lock()
defer m.mu.Unlock()
if m.requestCount == 0 {
return 0
}
return m.totalLatency / time.Duration(m.requestCount)
}
func main() {
metrics := &RequestMetrics{}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
latency := time.Duration((id+1)*10) * time.Millisecond
metrics.RecordRequest(latency)
}(i)
}
wg.Wait()
fmt.Printf("Average latency: %v\n", metrics.GetAvgLatency())
}
RWMutex for read-heavy data
A sync.RWMutex (reader-writer mutex) is an optimization for workloads where you have many readers but few writers. Multiple goroutines can hold read locks simultaneously, but a write lock is exclusive. This dramatically improves throughput when reads vastly outnumber writes.
Imagine a configuration service that many API handlers read from but only occasionally write to:
package main
import (
"fmt"
"sync"
"time"
)
type Config struct {
mu sync.RWMutex
data map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func main() {
cfg := &Config{data: make(map[string]string)}
var wg sync.WaitGroup
// Simulate many reader goroutines
for i := 0; i < 50; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
_ = cfg.Get("key")
time.Sleep(10 * time.Millisecond)
}
}(i)
}
// Occasional writer
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
cfg.Set("key", "value")
}()
wg.Wait()
fmt.Println("Done")
}
Use RLock() for read-only access and Lock() for mutations. The performance gain is substantial when reads significantly outnumber writes. However, don't over-optimize: a plain Mutex is simpler and often sufficient. Measure first.
Prefer channels for ownership transfer
Go's concurrency philosophy emphasizes sharing memory by communicating rather than communicating by sharing memory. Channels excel when you want to transfer ownership of data from one goroutine to another or coordinate work across boundaries.
Consider a work queue pattern:
package main
import (
"fmt"
)
type Job struct {
ID int
}
func worker(id int, jobs <-chan Job, done chan<- struct{}) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job.ID)
}
done <- struct{}{}
}
func main() {
jobs := make(chan Job, 10)
done := make(chan struct{})
// Start 3 workers
for i := 1; i <= 3; i++ {
go worker(i, jobs, done)
}
// Send jobs
for j := 1; j <= 10; j++ {
jobs <- Job{ID: j}
}
close(jobs)
// Wait for all workers
for i := 0; i < 3; i++ {
<-done
}
fmt.Println("All jobs complete")
}
Notice that jobs own the data—they're passed through the channel, and only one receiver gets each one. This is clearer and safer than having a shared slice protected by a mutex. Use channels when:
- You're transferring ownership of data between goroutines.
- You need to coordinate work across pipeline stages.
- Signaling completion or handoff is the primary concern.
Use Mutex when:
- Multiple goroutines read and update the same shared state repeatedly.
- The state is long-lived (not one-off items in a queue).
- Fine-grained control over access patterns is needed.
Common mistakes
- Forgetting
defer Unlock(): If your code panics or returns early, the lock stays held, deadlocking the entire system. Always use defer. - Holding locks during I/O or RPC calls: If you call an external API while holding a mutex, you starve other goroutines. Release the lock, do the I/O, then reacquire if needed.
- Lock ordering inversions: If goroutine A locks mutex X then Y, and goroutine B locks Y then X, you have a deadlock. Be consistent about lock acquisition order across your codebase.
- Using WaitGroup incorrectly: Call
Add()before launching the goroutine, never inside it. Otherwise,Wait()might return before the goroutine is even scheduled. - Shared mutexes across unrelated subsystems: A single mutex protecting disparate concerns becomes a bottleneck. Partition your locks by domain.
Key takeaways
sync.WaitGroupis your tool for waiting until a batch of goroutines completes. Increment withAdd(), decrement withDone(), and wait withWait().sync.Mutexprotects shared state from concurrent corruption. Always usedefer Unlock()and keep critical sections brief.sync.RWMutexoptimizes read-heavy workloads by allowing concurrent readers but exclusive writers. Don't use it unless profiling shows contention.- Prefer channels for transferring ownership and coordinating work across goroutine boundaries.
- Profile and measure before optimizing. Over-synchronization is as costly as insufficient synchronization.






Comments
Loading comments…