Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 014 / 100

sync.WaitGroup and Mutex: a hands-on guide for engineers

Basic synchronization tools.

Expona Admin8 min read

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.

go
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.

go
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:

go
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.

go
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:

go
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:

go
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.WaitGroup is your tool for waiting until a batch of goroutines completes. Increment with Add(), decrement with Done(), and wait with Wait().
  • sync.Mutex protects shared state from concurrent corruption. Always use defer Unlock() and keep critical sections brief.
  • sync.RWMutex optimizes 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles