GoConcurrency

Go Select & WaitGroups: Concurrency Orchestration Guide

TT
TopicTrick Team
Go Select & WaitGroups: Concurrency Orchestration Guide

Go Select & WaitGroups: Concurrency Orchestration

What are WaitGroups and Select in Go?

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.

Orchestration vs. Communication

    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.

    go

    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.

    go

    Advanced Concurrency Patterns

    Timeoutstime.After()

    Use select with time.After to prevent your program from hanging indefinitely if a channel never receives data.

    Non-blockingdefault case

    Adding a 'default' case to a select makes the channel check non-blocking. If no data is ready, it runs the default logic instantly.

    Mutexessync.Mutex

    For simple counters or shared variables where channels are overkill, use a Mutex to lock access to a specific piece of memory.

    Patterns Comparison

    Task / FeatureChannelsSync Mutex / WaitGroups
    Primary PurposeTransferring ownership of dataManaging state and synchronization
    Code StyleFunctional / DecoupledImperative / Tightly coupled
    RiskDeadlocks if not closed correctlyRace conditions if not locked correctly

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

    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:

    go

    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:

    go

    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.


    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?

    go

    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.