LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 3520 min read

Sync Package (Mutex & WaitGroup)

Learn how sync.Mutex protects shared state from concurrent access and how sync.WaitGroup waits for a group of goroutines to finish.

Introduction

Channels are the idiomatic way to pass data between goroutines, but sometimes you need something simpler: several goroutines all reading and writing the same variable, and a guarantee that they never trip over each other. That is exactly what the sync package is for. Its two most important tools are sync.Mutex, which protects shared state, and sync.WaitGroup, which waits for a group of goroutines to complete.

What You Will Learn
  • What a race condition is and why concurrent access to shared state is dangerous.
  • How sync.Mutex locks and unlocks access to protect shared data.
  • How sync.WaitGroup tracks and waits for a group of goroutines.
  • How to combine both to build a safe, coordinated concurrent program.

Why Shared State Needs Protection

When two or more goroutines read and write the same variable at the same time without coordination, you get a race condition — the outcome depends on unpredictable timing, and results can be silently wrong. The classic example is an unprotected counter incremented by many goroutines at once.

package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // NOT safe: read-modify-write without protection
}()
}
wg.Wait()
fmt.Println("counter:", counter) // often NOT 1000
}
Output (varies between runs, often incorrect)

Click Run to see what this code prints.

counter++ is actually three steps — read, increment, write — and when two goroutines interleave those steps, updates get lost. Go's race detector (go run -race) can catch bugs like this automatically during development.

sync.Mutex

A sync.Mutex (mutual exclusion lock) ensures only one goroutine can hold the lock at a time. Call Lock() before touching shared data and Unlock() when you are done — deferring the Unlock is the standard, safest pattern.

package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
counter := SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println("counter:", counter.Value())
}
Output

Click Run to see what this code prints.

Now every increment is protected by the mutex, so no update is ever lost — the result is deterministic no matter how the goroutines are scheduled.

sync.WaitGroup

sync.WaitGroup tracks a count of outstanding goroutines. Call Add(n) before launching goroutines, have each goroutine call Done() when it finishes (usually via defer), and call Wait() to block until the count reaches zero.

package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("worker %d starting\n", id)
fmt.Printf("worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("all workers finished")
}
Output (worker order may vary)

Click Run to see what this code prints.

Combining Mutex and WaitGroup

In real programs, WaitGroup and Mutex are frequently used together: WaitGroup waits for a batch of goroutines to finish their work, while Mutex protects any shared result they write to along the way.

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
results := make(map[int]int)
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
square := n * n
mu.Lock()
results[n] = square
mu.Unlock()
}(i)
}
wg.Wait()
for i := 1; i <= 5; i++ {
fmt.Printf("%d squared is %d\n", i, results[i])
}
}
Output

Click Run to see what this code prints.

Note that n is passed as a parameter into the goroutine closure (func(n int) { ... }(i)) rather than captured directly — this avoids the classic loop-variable capture bug.

Common Mistakes

Avoid These Mistakes
  • Forgetting to call wg.Add before launching a goroutine, causing a race between Add and Wait.
  • Copying a sync.Mutex or sync.WaitGroup by value instead of using a pointer, which breaks its guarantees.
  • Locking a mutex but forgetting to unlock it on every code path (defer avoids this).
  • Holding a lock while doing slow work like network calls, blocking other goroutines unnecessarily.
  • Using a Mutex when a channel would express the intent more clearly, or vice versa.

Best Practices

  • Always call wg.Add(1) before starting the goroutine it corresponds to, not inside it.
  • Pair Lock() with defer Unlock() immediately, so the unlock is never accidentally skipped.
  • Pass a *sync.WaitGroup or *sync.Mutex by pointer, never copy them.
  • Keep the code inside a locked section as small and fast as possible.
  • Run go test -race or go run -race during development to catch data races automatically.

Frequently Asked Questions

Use a Mutex when multiple goroutines need to safely read and write shared state directly, like a counter or a map. Use channels when you are passing ownership of data or coordinating a pipeline of work.

If the count is already zero, Wait() returns immediately. The danger is calling Add() concurrently with Wait() in a way that races — always call Add() before starting the corresponding goroutines.

No. Regular maps are not safe for concurrent read/write access; you must protect them with a Mutex, or use sync.Map for specific concurrent-access patterns.

Key Takeaways

  • A race condition happens when goroutines access shared state without coordination.
  • sync.Mutex's Lock/Unlock ensures only one goroutine touches protected data at a time.
  • sync.WaitGroup tracks outstanding goroutines with Add, Done, and Wait.
  • Mutex and WaitGroup are commonly combined: WaitGroup for completion, Mutex for shared data safety.
  • Always pass sync types by pointer and use go run -race to catch races early.

Summary

The sync package gives you precise, low-level control for protecting shared state and waiting on groups of goroutines, complementing the higher-level coordination that channels provide. With Mutex and WaitGroup in your toolkit, you can write concurrent Go code that is both correct and easy to reason about. Next, you will move from concurrency into practical I/O, starting with reading and writing files.

Next Lesson →

File Handling