Skip to main content
Go Language Fundamentals for Beginners to Advanced
CHAPTER 21 Beginner

Advanced Concurrency Patterns

Updated: May 17, 2026
5 min read

# CHAPTER 21

Advanced Concurrency Patterns

1. Introduction

While single Goroutines and Channels are powerful, enterprise applications require complex orchestration. You might need to query 3 databases simultaneously and return whichever responds first, or spawn 100 worker threads to process a massive CSV file and wait for all of them to finish. To achieve this, Go provides the sync package and the select statement.

2. Learning Objectives

By the end of this chapter, you will be able to:
  • Use sync.WaitGroup to wait for a fleet of Goroutines to finish.
  • Listen to multiple channels simultaneously using select.
  • Understand the Worker Pool pattern.
  • Identify Race Conditions.
  • Use sync.Mutex to lock shared memory safely.

3. Synchronizing with sync.WaitGroup

If you spawn 10 Goroutines, main() will exit before they finish. Using time.Sleep is a terrible idea. Using 10 channels just to wait is messy. sync.WaitGroup acts like a counter. You add to the counter before spawning a Goroutine, subtract from it when the Goroutine finishes, and tell main to wait until the counter hits zero.
go
12345678910111213141516171819202122232425262728293031
package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    // 3. Mark this Goroutine as 'Done' (subtracts 1 from counter) right before exiting
    defer wg.Done() 
    
    fmt.Printf("Worker %d starting...\n", id)
    time.Sleep(time.Second) // Simulate work
    fmt.Printf("Worker %d done.\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        // 1. Add 1 to the counter
        wg.Add(1) 
        // 2. Spawn Goroutine (CRITICAL: Pass WaitGroup as a pointer!)
        go worker(i, &wg) 
    }

    // 4. Block (freeze) main until the counter hits 0
    wg.Wait() 
    fmt.Println("All workers finished!")
}

4. Multiplexing with the select Statement

Imagine you make requests to two external APIs (API 1 and API 2) concurrently. You want to proceed as soon as the *first* one responds, ignoring the slower one. The select statement looks like a switch, but it exclusively monitors Channels. It blocks until *one* of its cases is ready.
go
12345678910111213141516171819202122
func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        api1 <- "Response from API 1"
    }()

    go func() {
        time.Sleep(1 * time.Second) // This one is faster!
        api2 <- "Response from API 2"
    }()

    // select will block until EITHER api1 or api2 sends data
    select {
    case res1 := <-api1:
        fmt.Println(res1)
    case res2 := <-api2:
        fmt.Println(res2) // This will print because it arrives first!
    }
}

5. Race Conditions

A Race Condition occurs when two Goroutines try to read and write to the *exact same variable in memory* at the exact same time. The data gets corrupted.

*Scenario:* Two bank transactions try to deposit $5 into an account that has $10 simultaneously. Both read $10, both add $5, both write $15. The final balance should be $20, but it is corrupted to $15!

6. Memory Locking with sync.Mutex

While Go prefers Channels, sometimes you just need to update a shared variable. A Mutex (Mutual Exclusion) acts like a lock on a bathroom door. A Goroutine locks the door, updates the variable, and unlocks it. If another Goroutine arrives while the door is locked, it waits in line.
go
123456789101112131415161718192021222324252627
type SafeCounter struct {
    mu    sync.Mutex // The Lock
    value int        // The shared variable
}

func (c *SafeCounter) Increment(wg *sync.WaitGroup) {
    defer wg.Done()
    
    c.mu.Lock()   // 1. Lock the door
    c.value++     // 2. Safely modify data (No other Goroutine can touch this right now)
    c.mu.Unlock() // 3. Unlock the door
}

func main() {
    counter := SafeCounter{}
    var wg sync.WaitGroup

    // Spawn 1000 Goroutines trying to increment the counter at the exact same time
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go counter.Increment(&wg)
    }

    wg.Wait()
    // Because of the Mutex, the value will safely be exactly 1000.
    fmt.Println("Final Counter Value:", counter.value) 
}

7. Common Mistakes

  • Passing WaitGroup by Value: If you write go worker(i, wg) instead of go worker(i, &wg), you pass a *copy* of the WaitGroup. When the worker calls .Done(), it decrements the copy, not the original. main will Wait() forever (Deadlock).
  • Forgetting to Unlock a Mutex: If a Goroutine panics or returns early before calling c.mu.Unlock(), the lock remains closed forever, and all other Goroutines will freeze indefinitely. Always use defer c.mu.Unlock()!

8. Best Practices

  • Test for Race Conditions: Go has a built-in tool to detect memory corruption. Run your code using go run -race main.go. If there are any race conditions, the compiler will aggressively warn you!

9. Exercises

  1. 1. Write a program that uses a sync.WaitGroup.
  1. 2. Spawn 5 Goroutines that simply print "Processing...".
  1. 3. Ensure main waits for all 5 to finish before printing "Done".

10. MCQs with Answers & Explanations

Question 1

What is the purpose of sync.WaitGroup?

Question 2

What are the three methods used with a WaitGroup?

Question 3

How must a WaitGroup be passed into a Goroutine?

Question 4

What does the select statement do?

Question 5

If multiple cases in a select statement become ready at the exact same millisecond, what happens?

Question 6

What is a Race Condition?

Question 7

What does sync.Mutex stand for?

Question 8

How does a Mutex solve race conditions?

Question 9

Which terminal command flags your code to check for memory corruption bugs?

Question 10

What is the safest way to ensure a Mutex unlocks, even if the function encounters an error?

11. Interview Preparation

Interview Questions:
  1. 1. You have a slice of URLs and you want to download all of them concurrently, but you need main to wait until they are all downloaded. How do you architect this? (Answer: WaitGroup).
  1. 2. Explain the difference between using Channels for synchronization vs using a Mutex. (Answer: Channels pass data ownership. Mutex locks shared state).

12. Summary

Enterprise Go code relies heavily on the sync package. WaitGroups orchestrate fleets of Goroutines cleanly. The select statement handles complex network timeouts and multi-channel routing. Finally, understanding Race Conditions and sync.Mutex ensures that your high-speed concurrent applications don't silently corrupt customer data.

13. Next Chapter Recommendation

We have mastered memory, concurrency, and OOP. It is time to step into the world of Web Development! Almost all modern APIs communicate using JSON. In Chapter 22: JSON Handling in Go, we will learn how to convert our Go Structs into JSON strings to send across the internet.

Finish this Chapter

Save your progress on your learning path and prepare for coding interview challenges.

Discussion

Join the discussion

Log in or create a free account to participate.

Sort: ·