Go Select & WaitGroups: Concurrency Orchestration Guide

Go Select & WaitGroups: The Orchestration Mirror
sync.WaitGroup is a counter-based synchronisation primitive that blocks your main goroutine until all tracked goroutines have finished. The select statement is a channel multiplexer that blocks until one of several channel operations is ready, then executes that case. Together, these two tools let you build complex concurrent workflows — waiting for parallel tasks to complete and routing based on which channel delivers data first.
Concurrency 3: Select & WaitGroups
By now, you can launch Goroutines and move data between them using Channels. But how do you handle complex scenarios? What if you need to wait for ten different background tasks to finish before proceeding? Or what if you want to perform a specific action based on which of three channels returns data first?
Go provides two essential tools for coordinating these workflows: WaitGroups and the Select statement.
1. The Select Mirror: Randomized Execution Physics
When multiple channels are ready to send or receive data simultaneously, the select statement must make a choice.
The Selection Physics
- The Random Seer: Unlike a standard
switch(which executes from top to bottom), theselectstatement picks a ready case at random. - The Starvation Mirror: If
selectwas priority-based (top-to-bottom), a fast-firing channel at the top could "Starve" every other channel below it, effectively blocking the rest of your system. - The Result: By randomizing selection, Go ensures a fair distribution of CPU time across all your communication signals, maintaining steady throughput for every concurrent task.
2. Synchronizing Completion: sync.WaitGroup
A WaitGroup is a simple counter that keeps track of how many concurrent tasks are currently running. You increment the counter when starting a task and decrement it when the task finishes. The main program then "waits" until the counter returns to zero.
func worker(id int, wg *sync.WaitGroup) {
// Decrement the counter when the function finishes
defer wg.Done()
fmt.Printf("Worker %d starting...\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done!\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // Increment counter
go worker(i, &wg)
}
wg.Wait()
fmt.Println("All workers finished.")
}3. The WaitGroup Mirror: Atomic Counter Mechanics
Under the hood, a WaitGroup is a masterclass in Atomic Operation Physics.
The Barrier Physics
- The Pointer Mirror: A
WaitGroupdoesn't use heavy OS locks. It uses an Atomic Counter residing in a specific 12-byte block of memory (on 64-bit systems). - The Add/Done Cycle: When you call
Add(), Go uses the CPU'sLOCKinstruction to increment the counter directly in hardware.Done()performs the mirror decrement. - The Wait Barrier:
Wait()polls this atomic state. If the counter is not zero, the Goroutine is put into a "Sleeper Queue" and its execution is paused until the exact nanosecond the counter hits zero.
4. Multiplexing Communication: select
The select statement lets a Goroutine wait on multiple communication operations. It's like a switch statement, but specifically for channels. It blocks until one of its cases can run, then executes that case. If multiple are ready, it picks one at random.
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
c1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
c2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("Received", msg1)
case msg2 := <-c2:
fmt.Println("Received", msg2)
case <-time.After(3 * time.Second): // A common timeout pattern
fmt.Println("Timeout reached")
}
}
}Advanced Concurrency Patterns
Patterns Comparison
| Task / Feature | Channels | Sync Mutex / WaitGroups |
|---|---|---|
| No comparison data available | ||
Real-World Pattern: Fan-Out and Fan-In
One of the most powerful concurrency patterns in Go combines WaitGroups and channels in a fan-out / fan-in architecture:
- Fan-out: Distribute work across multiple goroutines.
- Fan-in: Collect results from all goroutines into a single channel.
func fanOutFanIn(inputs []string) []string {
results := make(chan string, len(inputs))
var wg sync.WaitGroup
// Fan out: process each input concurrently
for _, input := range inputs {
wg.Add(1)
go func(s string) {
defer wg.Done()
results <- process(s) // expensive operation
}(input)
}
// Fan in: close results when all goroutines finish
go func() {
wg.Wait()
close(results)
}()
// Collect all results
var output []string
for r := range results {
output = append(output, r)
}
return output
}This pattern replaces a sequential for loop with parallel processing, often reducing latency by the number of concurrent goroutines launched.
Done Channel Pattern: Cancellation Without Context
Before the context package became idiomatic, Go developers used a "done channel" to signal goroutines to stop:
func worker(done <-chan struct{}) {
for {
select {
case <-done:
fmt.Println("Worker: shutting down")
return
default:
// Continue working
doWork()
}
}
}
func main() {
done := make(chan struct{})
go worker(done)
time.Sleep(3 * time.Second)
close(done) // Signal all workers to stop
}Closing a channel broadcasts to all receivers simultaneously — every goroutine waiting on <-done unblocks immediately. This is more efficient than sending individual stop signals. For modern Go code, the context package replaces this pattern, but understanding it helps you read existing codebases.
errgroup: WaitGroup With Error Propagation
The standard sync.WaitGroup doesn't handle errors from goroutines. The golang.org/x/sync/errgroup package solves this elegantly:
import "golang.org/x/sync/errgroup"
func fetchAll(urls []string) error {
g := new(errgroup.Group)
for _, url := range urls {
url := url // capture
g.Go(func() error {
_, err := http.Get(url)
return err
})
}
// Wait returns the first non-nil error
return g.Wait()
}errgroup.Group launches goroutines with g.Go() and g.Wait() blocks until all complete, returning the first error encountered. It is the recommended replacement for WaitGroup in any code that can fail.
Further Reading
This is module 3 of the concurrency series. See the previous posts on goroutines and channels. For the next step — cancelling entire trees of goroutines — see the Go Context API. For a complete view of Go's error handling philosophy, see Go error handling patterns.
Phase 13: Orchestration Architecture Mastery Checklist
- Verify WaitGroup Symmetry: Audit every
Add(1)to ensure it is paired with adefer Done()inside the goroutine to prevent permanent deadlocks. - Audit Select Randomization: Identify if your logic depends on channel priority and refactor those parts to use nested
selectblocks if specific order is architecturally required. - Implement Non-Blocking Defaults: Use the
defaultcase inselectfor non-critical telemetry or health checks to prevent system-wide blocking. - Test Closed Channel Signals: Remember that closing a channel satisfies all
selectcases listening to it. Ensure your logic handles "Post-Close" data correctly. - Use
errgroupfor Propagation: If your parallel tasks return errors, replace standardWaitGroupswitherrgroup.Groupto unify error signaling.
Read next: Go Context API: The Propagation Mirror →
Common Mistakes and How to Avoid Them
sync.WaitGroup and select are simple to use but easy to misuse. These five mistakes account for most concurrency bugs in real Go code.
1. Calling wg.Add inside the goroutine. If you write go func() { wg.Add(1); ... }(), there is a race condition: the goroutine may not start before wg.Wait() is called, causing the program to return before all work is done. Always call wg.Add(1) in the launching goroutine, before the go keyword.
2. Not using defer wg.Done(). If a goroutine returns early due to an error without calling wg.Done(), the WaitGroup counter never reaches zero and wg.Wait() blocks forever — a deadlock. Using defer wg.Done() as the very first statement in the goroutine body guarantees Done is always called, regardless of how the function exits.
3. Reusing a WaitGroup before it reaches zero. Calling wg.Add while wg.Wait is still blocking in another goroutine causes a panic. Complete the full wait-cycle before reusing the same WaitGroup. For recurring work, create a new WaitGroup per batch.
4. Missing a default case when you need non-blocking behavior. Without a default case, select blocks until one of its channels is ready. If you want to check channel availability without blocking (e.g., in a polling loop), add a default case. Without it, the loop blocks and the poll never fires.
5. Forgetting that select picks a random case when multiple are ready. If two channels have data simultaneously, select chooses one case at random. Do not write logic that assumes a specific priority between channels. Use explicit prioritisation logic (a nested select or a loop) if the order of consumption matters. The Go sync package documentation and Go concurrency patterns blog post cover these patterns in depth.
FAQ
Q: When should I use errgroup instead of a plain WaitGroup?
Use errgroup from golang.org/x/sync/errgroup whenever your goroutines can return errors. A plain WaitGroup has no error channel — you must handle errors separately via a channel or a shared variable protected by a mutex. errgroup.Group.Wait() returns the first non-nil error, and its optional context variant cancels remaining goroutines the moment one fails.
Q: How do I limit the number of concurrent goroutines in a fan-out?
Use a buffered channel as a semaphore. Create sem := make(chan struct{}, maxConcurrency). Before each goroutine launch, send to sem to acquire a slot. At the end of the goroutine, receive from sem to release it. This pattern bounds the maximum number of goroutines running simultaneously without a dedicated worker pool.
Q: Can select wait on both a channel and a timer simultaneously?
Yes. Include a case <-time.After(d): branch in your select. If no other channel delivers within the duration d, the timeout case runs. For repeated timeouts (e.g., health check loops), use time.NewTicker instead of time.After to avoid allocating a new timer channel on every iteration — time.After leaks the timer if another channel wins first.
Next Steps
Orchestrating concurrent tasks is the hallmark of a senior Go developer. But there's one more piece to the puzzle: How do you cancel an entire tree of Goroutines when a user disconnects or a timeout occurs? In our next tutorial, we will explore the Context API, the standard way to handle cancellation in production Go.
Common Mistakes with Select and WaitGroups
1. Forgetting wg.Add before launching the goroutine
Calling wg.Add(1) inside the goroutine creates a race — wg.Wait() may return before the goroutine increments the counter. Always call wg.Add(1) in the parent goroutine before the go statement.
2. Not calling wg.Done() on all code paths
If a goroutine returns early due to an error without calling wg.Done(), the WaitGroup counter never reaches zero and wg.Wait() blocks forever. Use defer wg.Done() as the first statement inside the goroutine to guarantee it runs on every exit path.
3. select with no default and all channels blocked
A select with no default case and no ready channels blocks forever. If you want a non-blocking select, add a default case. If you want a timeout, add a case <-time.After(d) case. See the Go spec on select statements.
4. Reusing a WaitGroup while it is still counting
Calling wg.Add while another goroutine is inside wg.Wait() causes undefined behaviour. Finish one batch completely before reusing the WaitGroup for the next.
5. Assuming select is fair
When multiple cases are ready simultaneously, Go's select picks one at random. Do not write logic that assumes a specific channel will always win — starvation is possible if one channel is always ready.
Frequently Asked Questions
What is the difference between sync.WaitGroup and a done channel?
A WaitGroup is simpler for "wait for N goroutines to finish" patterns. A done channel is more flexible — it can be selected alongside other channels, supports broadcast (closing notifies all listeners), and integrates naturally with context.Context. Use WaitGroup for simple fan-out/fan-in; use a done channel or context for cancellation. The Go blog on context explains the context approach.
Can I use select with only one case?
Yes, but it is equivalent to a plain channel send/receive. select is most useful with two or more cases. A single-case select with a default is the idiom for a non-blocking channel operation.
How do I implement a timeout using select?
select {
case result := <-ch:
// use result
case <-time.After(5 * time.Second):
// handle timeout
}time.After returns a channel that receives a value after the duration. For long-lived code, prefer time.NewTimer and call timer.Stop() to avoid goroutine leaks.
