The coordination problem
You spawn five goroutines to download images. The main function prints a success message and exits. The downloads never finish. You add a shared map to collect results. Two goroutines write to the same key at the same time. The program panics with a concurrent map write error. You wrap everything in a time.Sleep(2) to wait. It works locally. It fails in production when the network is slow.
Concurrency introduces timing gaps. Goroutines run independently. The scheduler switches between them at unpredictable moments. Without explicit coordination, they step on each other. The sync package exists to close those gaps. It gives you lightweight tools to synchronize execution, protect shared memory, and guarantee one-time initialization.
What the sync package actually does
Think of goroutines as chefs in a busy kitchen. They can chop vegetables, boil water, and plate dishes at the same time. The kitchen works fast until two chefs reach for the same knife, or the head chef leaves before the appetizers are ready. The sync package provides the kitchen rules. It tells goroutines when to pause, when to proceed, and how to share resources without corrupting data.
The package ships with three core primitives. WaitGroup tracks a count of active tasks and blocks until that count reaches zero. Mutex stands for mutual exclusion. It lets only one goroutine access a critical section at a time. Once wraps a function and guarantees it runs exactly one time, no matter how many goroutines call it. These are low-level building blocks. They do not replace channels. They complement them. Use them when you need explicit coordination rather than message passing.
Waiting for goroutines with WaitGroup
Here is the simplest pattern for waiting on a batch of goroutines: spawn them, track the count, and block until they finish.
package main
import (
"fmt"
"sync"
)
// fetchURL simulates downloading a page.
func fetchURL(url string, wg *sync.WaitGroup) {
defer wg.Done() // decrement counter when the function returns
fmt.Println("fetching", url)
}
func main() {
var wg sync.WaitGroup
wg.Add(3) // set the expected number of tasks
go fetchURL("https://example.com", &wg)
go fetchURL("https://golang.org", &wg)
go fetchURL("https://go.dev", &wg)
wg.Wait() // block until counter reaches zero
fmt.Println("all downloads complete")
}
The Add method sets the target count. Each goroutine calls Done when it finishes. Done is just Add(-1). The Wait call blocks the calling goroutine until the internal counter drops to zero. The runtime handles the blocking efficiently. It does not spin or waste CPU cycles.
Always pass the WaitGroup by pointer. Structs containing WaitGroup must not be copied. The compiler will reject a copy with sync.WaitGroup is not comparable or a similar type mismatch if you try to assign it directly. The convention is to declare the variable in the parent scope, call Add before spawning, and use defer wg.Done() inside each goroutine. Deferring guarantees the counter decrements even if the goroutine panics.
Goroutines are cheap. WaitGroups are lighter than channels for simple fan-out patterns.
Protecting shared state with Mutex
Shared memory requires a gatekeeper. A Mutex ensures only one goroutine enters a critical section at a time. Other goroutines block until the lock is released.
package main
import (
"fmt"
"sync"
)
// Counter tracks a shared total safely.
type Counter struct {
mu sync.Mutex
value int
}
// Increment adds one to the shared total.
func (c *Counter) Increment() {
c.mu.Lock() // acquire exclusive access
c.value++ // mutate shared state
c.mu.Unlock() // release access for others
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
defer wg.Done()
c.Increment()
}()
}
wg.Wait()
fmt.Println("final count:", c.value)
}
The Lock call blocks if another goroutine already holds the mutex. Unlock releases it. The critical section between them should be as small as possible. Long locks create bottlenecks. The runtime tracks lock contention and will warn you if you hold a lock across a network call or a heavy computation.
Always pair Lock with defer mu.Unlock(). Forgetting to unlock causes a deadlock. The program hangs silently until the OS kills it. The Go community accepts the boilerplate because it makes the boundary explicit. You can also use sync.RWMutex when reads far outnumber writes. It allows multiple concurrent readers but still enforces exclusive access for writers.
Locks protect data. They do not replace design. Prefer passing data through channels when you can. Use a mutex when multiple goroutines must read and write the same in-memory structure.
Running something exactly once with Once
Initialization code often needs to run a single time. Configuration loading, database connection setup, or cache warming are common examples. sync.Once wraps a function and guarantees it executes exactly once, even if called concurrently from dozens of goroutines.
package main
import (
"fmt"
"sync"
)
var initOnce sync.Once
var config map[string]string
// loadConfig reads settings from disk.
func loadConfig() {
fmt.Println("loading configuration")
config = map[string]string{"env": "production"}
}
func main() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
defer wg.Done()
initOnce.Do(loadConfig) // runs exactly once across all goroutines
fmt.Println("using config:", config["env"])
}()
}
wg.Wait()
}
The Do method accepts a function with no arguments and no return values. The first call executes the function. Subsequent calls return immediately without running it again. The internal state is thread-safe. You do not need to wrap Do in a mutex.
Once is a one-way door. It does not support retrying on failure. If loadConfig panics, the Once instance marks itself as done. Future calls will skip execution and you will get a nil or zero-value configuration. Handle errors inside the wrapped function or use a separate initialization pattern.
Initialization happens once. Plan for it to fail gracefully.
Common traps and compiler complaints
The sync package is small but unforgiving. Misusing it leads to deadlocks, data races, or silent corruption.
Copying a WaitGroup or Mutex after it has been used triggers a runtime panic. The compiler will catch some cases with sync.WaitGroup is not comparable or invalid operation: cannot copy sync.Mutex. Always pass these types by pointer or keep them embedded in a struct that is never copied.
Forgetting to call Wait leaves goroutines running in the background. They may hold file handles, database connections, or open sockets. The program exits, the OS cleans up, and you leak resources in long-running services. The worst goroutine bug is the one that never logs.
Holding a mutex across a blocking call creates a bottleneck. If one goroutine locks a mutex and then makes an HTTP request, every other goroutine that needs that mutex will stall. The runtime does not enforce lock scope. You must track it yourself. Use the race detector (go run -race) to catch concurrent access to unprotected variables. It will print a detailed stack trace showing exactly which goroutines touched the same memory.
The compiler will also reject programs that try to compare sync primitives with ==. It responds with invalid operation: operator not defined on sync.Mutex. These types contain internal state that changes during execution. Comparing them makes no sense.
Trust the race detector. Run it in CI. Fix the first warning you see.
Picking the right primitive
Concurrency tools overlap. Choosing the wrong one adds latency or complexity. Match the tool to the shape of your problem.
Use a sync.WaitGroup when you fan out independent tasks and need to wait for all of them to finish before proceeding. Use a sync.Mutex when multiple goroutines must read and write the same in-memory data structure and you cannot restructure the code to use channels. Use a sync.Once when you need to guarantee that expensive initialization or setup runs exactly one time across concurrent callers. Use a channel when you need to pass data between goroutines, signal completion, or build a pipeline. Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.