Overview

Appendix E. Concurrent programming

Concurrency is presented as a way to structure programs so many tasks can make progress independently, distinct from parallelism, which is about running tasks simultaneously. Go does this with goroutines—lightweight, independently scheduled functions—starting with the main goroutine and extended via the go keyword. Because goroutines don’t automatically wait for each other, synchronization is explicit: sync.WaitGroup provides a counting semaphore pattern through Add, Done, and Wait. The text highlights common pitfalls (like calling Add inside a goroutine and racing with Wait) and shows how to encapsulate safe usage with a SafeGroup wrapper that increments before launching and defers Done, while also encouraging separation of work from synchronization and caution with closures that capture changing variables.

Channels are introduced as typed conduits that coordinate goroutines and reduce the need for locks. Unbuffered channels synchronize precisely by blocking both sender and receiver until an exchange occurs, enabling clear patterns such as fan-out/fan-in accumulation while emphasizing the need to match sends and receives to avoid deadlocks and goroutine leaks. Closing a channel is strictly a signaling mechanism owned by the sender: receivers can detect closure via the comma-ok idiom or by ranging over the channel to consume all values until completion. Closing can also coordinate groups of goroutines (e.g., barrier-style release), and using channels of empty structs communicates pure signaling intent without payload overhead.

The select statement lets a goroutine wait on multiple channel operations, fairly choosing among those ready and enabling cancellation, barrier releases, and timeouts. A default case provides non-blocking probes, but using it in a loop can cause busy spinning and points to design issues. Buffered channels act like concurrent queues, decoupling sender and receiver until the buffer fills, and are used to prevent leaks (e.g., with time.After-based timeouts) and to cap concurrency via a counting-semaphore pattern implemented with a token channel. Directional channel types (send-only, receive-only) help encode intent and catch misuse at compile time. Throughout, the guidance stresses picking channels or mutexes judiciously, planning exit strategies to avoid leaks, and resisting unnecessary concurrency when a direct call is faster and simpler.

Figure E.1 The channel synchronizes goroutines by blocking them until both are ready. Here, the sender waits until the receiver takes the value from the channel.
Figure E.2 A buffered channel with a buffer capacity of two blocks the sender until a receiver is ready to receive from the channel when the channel buffer is full.

E.7 Exercises

  1. Write a function that sends one hundred values to an unbuffered channel. The main goroutine receives these values as they come and prints them individually.
  2. Write a function that receives an unknown number of values from an unbuffered channel. The function should stop receiving after a timeout. Use: select.
  3. Write a function with multiple sender goroutines sending an unknown number of values to an unbuffered channel, and a separate receiver goroutine that collects these values. Once all values are received, the receiver goroutine sends the total result to the main goroutine, which prints it. Hint: Use WaitGroup and close.
  4. In the "Limiting concurrency" example, use the same buffered channel instead of SafeGroup or WaitGroup to wait for goroutines to finish. Hints: The buffer is empty when they finish. Being able to fill the buffer fully indicates that all have finished.
  5. Add a SetLimit method to syncx.SafeGroup to limit concurrency. The Go method should block when the limit is reached, while not calling SetLimit imposes no restriction. Hint: SetLimit sets the new channel field to a buffered channel. Hint 2: Check if the channel field is nil before sending and receiving to lift off the limit.

FAQ

What’s the difference between concurrency and parallelism in Go?Concurrency is about structuring a program to handle many tasks that may overlap in time; parallelism is about running multiple tasks at the same instant on different CPUs. Go’s design (goroutines and channels) helps you write concurrent code that may or may not run in parallel depending on available cores.
How do I start a goroutine, and why can my program exit before it finishes?Use the go keyword to run a function concurrently: go work(). The main goroutine does not automatically wait for others; when main returns, the program exits, possibly before other goroutines complete. Use synchronization (for example, WaitGroup) to wait for them.
How do I wait for several goroutines to finish correctly?- Use sync.WaitGroup. Typical pattern: call wg.Add(1) before launching a goroutine, defer wg.Done() inside it, then wg.Wait() in main. - Never let the internal counter go negative (Add panics). - Call Add outside the goroutine to avoid a race where Wait observes zero and returns too early.
What is syncx.SafeGroup and when is it useful?It’s a small wrapper that provides Go(fn) and Wait(). Go increments the internal WaitGroup before starting the goroutine and defers Done inside the wrapper, removing a common source of races and boilerplate. It simplifies “spawn and wait” patterns and prevents calling Add inside a goroutine by mistake.
Why can closures be tricky with goroutines, and how do I avoid capture bugs?Closures capture variables by reference, so loop variables (like id) may change before the goroutine runs, causing races or wrong values. Remedies: - Pass values as parameters to the goroutine function. - Compute expressions (e.g., id+1) before launching the goroutine. - Avoid capturing mutable state; copy or pass only what you need.
How do unbuffered channels synchronize goroutines? Why would send/receive in one goroutine deadlock?Unbuffered channels block both sides until the other is ready: the sender pauses until a receiver takes the value, and the receiver pauses until a sender provides one. If a single goroutine tries to send and then receive on the same unbuffered channel with no other goroutine involved, both operations wait forever, causing a deadlock.
Who should close a channel, and how do receivers know when to stop?- The sender closes the channel to signal “no more values.” Don’t close from the receiver side, and never send to a closed channel (both cause panics). - Receivers can use: - Comma-ok: v, ok := <-ch (ok is false if closed). - for-range: for v := range ch { ... } loops until the channel is closed. - Closing is for signaling, not for memory cleanup (GC handles that).
How does select help with multiple channels, and what about non-blocking checks?select waits until any of its cases (channel ops) is ready and executes one at random among the ready ones, helping avoid starvation. Common patterns: - Wait for either a “go” signal or a “stop” signal. - Timeouts: include a case <-time.After(d):. - Non-blocking probe: add a default case; if nothing is ready, default runs. Caution: using select{... default: ...} inside a tight loop can busy-spin and indicates a design smell.
When should I use buffered channels, and what are the trade-offs?- Buffered channels queue values up to their capacity; sends block only when full. This can decouple producers from consumers and reduce unnecessary blocking. - Trade-offs: buffers allocate memory (size wisely), and large buffers can hide backpressure. - A one-element buffer is handy to avoid a goroutine leak in timeout patterns (e.g., producer sends even if the receiver timed out). - Directional channels (e.g., <-chan T or chan<- T) document intent and prevent misuse at compile time (e.g., time.After returns <-chan time.Time).
How can I limit concurrency without a worker pool?Use a buffered channel as a counting semaphore. Its capacity is the maximum parallelism. Example:
type token struct{}
sem := make(chan token, 10) // allow up to 10 concurrent tasks

for i := 0; i < N; i++ {
    sem <- token{}          // acquire
    go func(i int) {
        defer func() { <-sem }() // release
        doWork(i)
    }(i)
}
// Wait for all goroutines via a WaitGroup or SafeGroup
This pattern keeps goroutines lightweight and concurrency bounded.

pro $24.99 per month

  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose one free eBook per month to keep
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime

lite $19.99 per month

  • access to all Manning books, including MEAPs!

team

5, 10 or 20 seats+ for your team - learn more


choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free