Go Goroutines Fundamentals - Concurrent Programming Made Simple

Learning Objectives: By the end of this lesson, you will be able to:

  • Understand Go’s concurrency model and goroutine fundamentals
  • Create and manage goroutines for concurrent execution
  • Understand the Go scheduler and goroutine lifecycle
  • Apply synchronization techniques to coordinate goroutines
  • Handle shared data safely with mutexes and atomic operations
  • Design concurrent algorithms and parallel processing patterns
  • Debug and profile concurrent Go applications
  • Avoid common concurrency pitfalls and race conditions

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Imagine you’re running a restaurant. You could have one chef who takes an order, cooks it completely, serves it, then takes the next order. Or you could have multiple chefs working simultaneously - one prepping ingredients, another cooking, another plating - all coordinating seamlessly. Goroutines work like that second restaurant: lightweight “chefs” that can work concurrently, making your program much more efficient and responsive.

Lesson Overview

  • Duration: 8-10 hours
  • Prerequisites: Go packages, error handling, and basic programming concepts
  • Materials Needed: Go development environment, understanding of concurrent programming concepts

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Concurrency Concepts - Understanding concurrent vs parallel programming
  2. Goroutine Basics - Creating and managing lightweight threads
  3. Go Scheduler - How Go manages goroutines efficiently
  4. Synchronization - Coordinating goroutines with WaitGroups and mutexes
  5. Shared Data - Safe data sharing and race condition prevention
  6. Concurrent Patterns - Common patterns for concurrent programming
  7. Performance - Profiling and optimizing concurrent applications
  8. Best Practices - Writing maintainable concurrent code

Success Criteria:

By the end of this session, you should be able to write efficient concurrent Go programs that safely coordinate multiple goroutines and avoid common concurrency pitfalls.

3. Foundation Knowledge (15-20 minutes)

Understanding Concurrency in Go

Go was designed from the ground up with concurrency in mind. Its approach differs from traditional threading models by providing lightweight goroutines that are managed by the Go runtime.

Real-World Analogy: Traditional threads are like full-size cars - powerful but expensive to create and manage. Goroutines are like bicycles - lightweight, easy to create, and you can have thousands of them moving efficiently through the city (your program).

Why This Matters: Go’s concurrency model enables:

  • Scalability: Handle thousands of concurrent operations efficiently
  • Responsiveness: Keep applications responsive during long-running tasks
  • Resource Efficiency: Minimal memory overhead per goroutine
  • Simplicity: Easier to reason about than traditional threading
  • Performance: Excellent performance on multi-core systems

Core Concepts

Concurrency vs Parallelism

  • Concurrency: Dealing with multiple things at once (composition)
  • Parallelism: Doing multiple things at once (execution)
  • Go’s Approach: Provides concurrency primitives that can run in parallel

Goroutines

  • Lightweight Threads: Managed by Go runtime, not OS
  • Stack Size: Start with 2KB, grow/shrink as needed
  • Creation Cost: Much cheaper than OS threads
  • Multiplexing: Many goroutines can run on few OS threads

Go Scheduler

  • M:N Scheduler: Maps M goroutines to N OS threads
  • Work Stealing: Efficient load balancing between threads
  • Preemptive: Can interrupt long-running goroutines
  • GOMAXPROCS: Controls number of OS threads

Key Terminology

  • Goroutine: Lightweight thread managed by Go runtime
  • Scheduler: Go runtime component that manages goroutine execution
  • Race Condition: Unsynchronized access to shared data
  • Mutex: Mutual exclusion lock for protecting shared data
  • WaitGroup: Synchronization primitive for waiting on goroutines
  • Atomic Operations: Low-level synchronization primitives
  • Deadlock: Situation where goroutines wait for each other indefinitely
  • GOMAXPROCS: Environment variable controlling parallelism level

4. Detailed Explanation and Examples (25-30 minutes)

Understanding Go’s Concurrency Model

Go’s concurrency is built on the principle of “Don’t communicate by sharing memory; share memory by communicating.” This philosophy leads to more maintainable and less error-prone concurrent programs.

Goroutine Lifecycle

  1. Creation: Started with go keyword
  2. Scheduling: Managed by Go scheduler
  3. Execution: Runs concurrently with other goroutines
  4. Termination: Exits when function returns
  5. Cleanup: Stack memory reclaimed automatically

Coordination Challenges

  • Race Conditions: Multiple goroutines accessing shared data
  • Deadlocks: Circular waiting between goroutines
  • Resource Leaks: Goroutines that never terminate
  • Synchronization: Coordinating goroutine execution order

Practical Applications

Goroutines are essential for:

  • Web Servers: Handling multiple requests concurrently
  • Data Processing: Parallel processing of large datasets
  • I/O Operations: Non-blocking file and network operations
  • Background Tasks: Periodic cleanup, monitoring, logging
  • Fan-out/Fan-in: Distributing work and collecting results

Common Misconceptions

  1. Misconception: More goroutines always means better performance Reality: Too many goroutines can cause overhead and contention

  2. Misconception: Goroutines are free and have no overhead Reality: While lightweight, they still consume memory and CPU

  3. Misconception: Go handles all synchronization automatically Reality: Developers must explicitly coordinate shared data access

5. Live Coding Demonstration (20-25 minutes)

Example 1: Basic Goroutine Creation and Management

package main

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

// Simple goroutine demonstration
func simpleTask(id int, duration time.Duration) {
    fmt.Printf("Task %d starting\n", id)
    time.Sleep(duration)
    fmt.Printf("Task %d completed after %v\n", id, duration)
}

// Goroutine with return value through channel
func calculateSquare(num int, result chan<- int) {
    time.Sleep(time.Millisecond * 100) // Simulate work
    result <- num * num
}

// Worker function for demonstrating multiple goroutines
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(time.Millisecond * 500) // Simulate work
        results <- job * job
    }
    
    fmt.Printf("Worker %d finished\n", id)
}

// Function to demonstrate goroutine scheduling
func cpuIntensiveTask(id int, iterations int) {
    start := time.Now()
    count := 0
    
    for i := 0; i < iterations; i++ {
        count++
        // Yield occasionally to allow other goroutines to run
        if i%1000000 == 0 {
            runtime.Gosched()
        }
    }
    
    duration := time.Since(start)
    fmt.Printf("CPU Task %d completed %d iterations in %v\n", id, count, duration)
}

func main() {
    fmt.Println("=== Goroutine Fundamentals Demonstration ===")
    
    // Show initial runtime information
    fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
    fmt.Printf("NumCPU: %d\n", runtime.NumCPU())
    fmt.Printf("NumGoroutine: %d\n", runtime.NumGoroutine())
    
    // Example 1: Basic goroutine creation
    fmt.Println("\n--- Basic Goroutine Creation ---")
    
    // Start multiple goroutines
    go simpleTask(1, time.Millisecond*300)
    go simpleTask(2, time.Millisecond*100)
    go simpleTask(3, time.Millisecond*200)
    
    // Give goroutines time to complete
    time.Sleep(time.Millisecond * 500)
    fmt.Printf("After simple tasks, NumGoroutine: %d\n", runtime.NumGoroutine())
    
    // Example 2: Goroutines with channels for results
    fmt.Println("\n--- Goroutines with Channels ---")
    
    resultChan := make(chan int, 5)
    
    // Start multiple calculation goroutines
    numbers := []int{1, 2, 3, 4, 5}
    for _, num := range numbers {
        go calculateSquare(num, resultChan)
    }
    
    // Collect results
    fmt.Println("Collecting results:")
    for i := 0; i < len(numbers); i++ {
        result := <-resultChan
        fmt.Printf("Received result: %d\n", result)
    }
    
    close(resultChan)
    
    // Example 3: Worker pool pattern
    fmt.Println("\n--- Worker Pool Pattern ---")
    
    const numWorkers = 3
    const numJobs = 10
    
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup
    
    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }
    
    // Send jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)
    
    // Wait for workers to complete
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // Collect results
    fmt.Println("Worker pool results:")
    for result := range results {
        fmt.Printf("Result: %d\n", result)
    }
    
    // Example 4: CPU-intensive tasks and scheduling
    fmt.Println("\n--- CPU-Intensive Tasks ---")
    
    const numTasks = 4
    const iterations = 10000000
    
    var cpuWg sync.WaitGroup
    
    start := time.Now()
    
    for i := 1; i <= numTasks; i++ {
        cpuWg.Add(1)
        go func(taskID int) {
            defer cpuWg.Done()
            cpuIntensiveTask(taskID, iterations)
        }(i)
    }
    
    cpuWg.Wait()
    totalDuration := time.Since(start)
    
    fmt.Printf("All CPU tasks completed in %v\n", totalDuration)
    fmt.Printf("Final NumGoroutine: %d\n", runtime.NumGoroutine())
    
    // Example 5: Goroutine scheduling demonstration
    fmt.Println("\n--- Scheduling Demonstration ---")
    
    // Show how goroutines can be scheduled
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Printf("Goroutine A: %d\n", i)
            runtime.Gosched() // Yield to other goroutines
        }
    }()
    
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Printf("Goroutine B: %d\n", i)
            runtime.Gosched() // Yield to other goroutines
        }
    }()
    
    time.Sleep(time.Millisecond * 100)
    
    fmt.Println("\nDemonstration complete!")
}

Explanation:

  • Lines 8-12: Basic goroutine function with timing
  • Lines 15-18: Goroutine that returns result via channel
  • Lines 21-31: Worker function demonstrating goroutine coordination
  • Lines 49-54: CPU-intensive task showing scheduler yielding
  • Lines 83-90: Multiple goroutines processing concurrently

Example 2: Synchronization with WaitGroups and Mutexes

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "sync/atomic"
    "time"
)

// Shared counter without synchronization (unsafe)
type UnsafeCounter struct {
    value int
}

func (c *UnsafeCounter) Increment() {
    c.value++
}

func (c *UnsafeCounter) Value() int {
    return c.value
}

// Shared counter with mutex synchronization (safe)
type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

// Atomic counter (lock-free, safe)
type AtomicCounter struct {
    value int64
}

func (c *AtomicCounter) Increment() {
    atomic.AddInt64(&c.value, 1)
}

func (c *AtomicCounter) Value() int64 {
    return atomic.LoadInt64(&c.value)
}

// Bank account with mutex for thread-safe operations
type BankAccount struct {
    mu      sync.RWMutex
    balance float64
    name    string
}

func NewBankAccount(name string, initialBalance float64) *BankAccount {
    return &BankAccount{
        balance: initialBalance,
        name:    name,
    }
}

func (acc *BankAccount) Deposit(amount float64) {
    acc.mu.Lock()
    defer acc.mu.Unlock()
    
    fmt.Printf("Depositing %.2f to %s\n", amount, acc.name)
    time.Sleep(time.Millisecond * 10) // Simulate processing time
    acc.balance += amount
    fmt.Printf("New balance for %s: %.2f\n", acc.name, acc.balance)
}

func (acc *BankAccount) Withdraw(amount float64) bool {
    acc.mu.Lock()
    defer acc.mu.Unlock()
    
    fmt.Printf("Attempting to withdraw %.2f from %s\n", amount, acc.name)
    time.Sleep(time.Millisecond * 10) // Simulate processing time
    
    if acc.balance >= amount {
        acc.balance -= amount
        fmt.Printf("Withdrawal successful. New balance for %s: %.2f\n", acc.name, acc.balance)
        return true
    }
    
    fmt.Printf("Insufficient funds in %s. Current balance: %.2f\n", acc.name, acc.balance)
    return false
}

func (acc *BankAccount) Balance() float64 {
    acc.mu.RLock()
    defer acc.mu.RUnlock()
    return acc.balance
}

// Transfer between accounts (demonstrates avoiding deadlock)
func transfer(from, to *BankAccount, amount float64) {
    // Always acquire locks in the same order to prevent deadlock
    var first, second *BankAccount
    if from.name < to.name {
        first, second = from, to
    } else {
        first, second = to, from
    }
    
    first.mu.Lock()
    defer first.mu.Unlock()
    
    second.mu.Lock()
    defer second.mu.Unlock()
    
    fmt.Printf("Transferring %.2f from %s to %s\n", amount, from.name, to.name)
    time.Sleep(time.Millisecond * 20) // Simulate processing time
    
    if from.balance >= amount {
        from.balance -= amount
        to.balance += amount
        fmt.Printf("Transfer successful. %s: %.2f, %s: %.2f\n", 
            from.name, from.balance, to.name, to.balance)
    } else {
        fmt.Printf("Transfer failed - insufficient funds in %s\n", from.name)
    }
}

// Demonstrate data races
func demonstrateDataRace() {
    fmt.Println("=== Data Race Demonstration ===")
    
    const numGoroutines = 1000
    const incrementsPerGoroutine = 1000
    
    // Unsafe counter (will have race conditions)
    unsafeCounter := &UnsafeCounter{}
    var unsafeWg sync.WaitGroup
    
    start := time.Now()
    
    for i := 0; i < numGoroutines; i++ {
        unsafeWg.Add(1)
        go func() {
            defer unsafeWg.Done()
            for j := 0; j < incrementsPerGoroutine; j++ {
                unsafeCounter.Increment()
            }
        }()
    }
    
    unsafeWg.Wait()
    unsafeDuration := time.Since(start)
    
    fmt.Printf("Unsafe counter result: %d (expected: %d) in %v\n", 
        unsafeCounter.Value(), numGoroutines*incrementsPerGoroutine, unsafeDuration)
    
    // Safe counter with mutex
    safeCounter := &SafeCounter{}
    var safeWg sync.WaitGroup
    
    start = time.Now()
    
    for i := 0; i < numGoroutines; i++ {
        safeWg.Add(1)
        go func() {
            defer safeWg.Done()
            for j := 0; j < incrementsPerGoroutine; j++ {
                safeCounter.Increment()
            }
        }()
    }
    
    safeWg.Wait()
    safeDuration := time.Since(start)
    
    fmt.Printf("Safe counter result: %d (expected: %d) in %v\n", 
        safeCounter.Value(), numGoroutines*incrementsPerGoroutine, safeDuration)
    
    // Atomic counter
    atomicCounter := &AtomicCounter{}
    var atomicWg sync.WaitGroup
    
    start = time.Now()
    
    for i := 0; i < numGoroutines; i++ {
        atomicWg.Add(1)
        go func() {
            defer atomicWg.Done()
            for j := 0; j < incrementsPerGoroutine; j++ {
                atomicCounter.Increment()
            }
        }()
    }
    
    atomicWg.Wait()
    atomicDuration := time.Since(start)
    
    fmt.Printf("Atomic counter result: %d (expected: %d) in %v\n", 
        atomicCounter.Value(), numGoroutines*incrementsPerGoroutine, atomicDuration)
}

// Demonstrate bank account operations
func demonstrateBankOperations() {
    fmt.Println("\n=== Bank Account Operations ===")
    
    account1 := NewBankAccount("Alice", 1000.0)
    account2 := NewBankAccount("Bob", 500.0)
    
    var wg sync.WaitGroup
    
    // Simulate concurrent banking operations
    operations := []func(){
        func() { account1.Deposit(100) },
        func() { account1.Withdraw(50) },
        func() { account2.Deposit(200) },
        func() { account2.Withdraw(100) },
        func() { transfer(account1, account2, 150) },
        func() { transfer(account2, account1, 75) },
        func() { 
            fmt.Printf("Account balances - %s: %.2f, %s: %.2f\n", 
                account1.name, account1.Balance(), account2.name, account2.Balance()) 
        },
    }
    
    // Execute operations concurrently
    for _, op := range operations {
        wg.Add(1)
        go func(operation func()) {
            defer wg.Done()
            operation()
        }(op)
    }
    
    wg.Wait()
    
    fmt.Printf("Final balances - %s: %.2f, %s: %.2f\n", 
        account1.name, account1.Balance(), account2.name, account2.Balance())
}

// Demonstrate multiple readers, single writer pattern
func demonstrateReadersWriters() {
    fmt.Println("\n=== Readers-Writers Pattern ===")
    
    type SharedData struct {
        mu   sync.RWMutex
        data map[string]int
    }
    
    shared := &SharedData{
        data: make(map[string]int),
    }
    
    // Writer function
    writer := func(id int, wg *sync.WaitGroup) {
        defer wg.Done()
        
        for i := 0; i < 5; i++ {
            key := fmt.Sprintf("key-%d-%d", id, i)
            value := rand.Intn(100)
            
            shared.mu.Lock()
            shared.data[key] = value
            shared.mu.Unlock()
            
            fmt.Printf("Writer %d: wrote %s = %d\n", id, key, value)
            time.Sleep(time.Millisecond * 50)
        }
    }
    
    // Reader function
    reader := func(id int, wg *sync.WaitGroup) {
        defer wg.Done()
        
        for i := 0; i < 10; i++ {
            shared.mu.RLock()
            count := len(shared.data)
            // Read some data
            for key, value := range shared.data {
                if rand.Float32() < 0.1 { // Randomly read some entries
                    fmt.Printf("Reader %d: read %s = %d\n", id, key, value)
                }
                break // Just read one for demo
            }
            shared.mu.RUnlock()
            
            fmt.Printf("Reader %d: data size = %d\n", id, count)
            time.Sleep(time.Millisecond * 25)
        }
    }
    
    var wg sync.WaitGroup
    
    // Start writers
    for i := 1; i <= 2; i++ {
        wg.Add(1)
        go writer(i, &wg)
    }
    
    // Start readers
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go reader(i, &wg)
    }
    
    wg.Wait()
    
    fmt.Printf("Final data size: %d\n", len(shared.data))
}

func main() {
    // Seed random number generator
    rand.Seed(time.Now().UnixNano())
    
    demonstrateDataRace()
    demonstrateBankOperations()
    demonstrateReadersWriters()
    
    fmt.Println("\nSynchronization demonstration complete!")
}

Example 3: Advanced Goroutine Patterns

package main

import (
    "context"
    "fmt"
    "math/rand"
    "runtime"
    "sync"
    "time"
)

// Producer-Consumer pattern
func producerConsumerDemo() {
    fmt.Println("=== Producer-Consumer Pattern ===")
    
    const bufferSize = 5
    const numProducers = 2
    const numConsumers = 3
    const itemsPerProducer = 10
    
    buffer := make(chan int, bufferSize)
    var wg sync.WaitGroup
    
    // Producer function
    producer := func(id int) {
        defer wg.Done()
        for i := 0; i < itemsPerProducer; i++ {
            item := id*100 + i
            buffer <- item
            fmt.Printf("Producer %d produced item %d\n", id, item)
            time.Sleep(time.Millisecond * time.Duration(rand.Intn(100)))
        }
        fmt.Printf("Producer %d finished\n", id)
    }
    
    // Consumer function
    consumer := func(id int) {
        defer wg.Done()
        for {
            select {
            case item, ok := <-buffer:
                if !ok {
                    fmt.Printf("Consumer %d: buffer closed\n", id)
                    return
                }
                fmt.Printf("Consumer %d consumed item %d\n", id, item)
                time.Sleep(time.Millisecond * time.Duration(rand.Intn(150)))
            case <-time.After(time.Second * 2):
                fmt.Printf("Consumer %d: timeout, exiting\n", id)
                return
            }
        }
    }
    
    // Start producers
    for i := 1; i <= numProducers; i++ {
        wg.Add(1)
        go producer(i)
    }
    
    // Start consumers
    for i := 1; i <= numConsumers; i++ {
        wg.Add(1)
        go consumer(i)
    }
    
    // Wait for producers to finish, then close buffer
    go func() {
        // Wait for all producers to finish
        time.Sleep(time.Second * 3)
        close(buffer)
    }()
    
    wg.Wait()
    fmt.Println("Producer-Consumer demo completed")
}

// Fan-out/Fan-in pattern
func fanOutFanInDemo() {
    fmt.Println("\n=== Fan-Out/Fan-In Pattern ===")
    
    // Input generator
    generator := func(nums ...int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for _, n := range nums {
                out <- n
            }
        }()
        return out
    }
    
    // Worker function (squares numbers)
    squarer := func(in <-chan int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for n := range in {
                time.Sleep(time.Millisecond * 100) // Simulate work
                out <- n * n
            }
        }()
        return out
    }
    
    // Fan-in function (merge multiple channels)
    fanIn := func(inputs ...<-chan int) <-chan int {
        var wg sync.WaitGroup
        out := make(chan int)
        
        // Start goroutine for each input channel
        for _, input := range inputs {
            wg.Add(1)
            go func(ch <-chan int) {
                defer wg.Done()
                for n := range ch {
                    out <- n
                }
            }(input)
        }
        
        // Close output channel when all inputs are closed
        go func() {
            wg.Wait()
            close(out)
        }()
        
        return out
    }
    
    // Set up pipeline
    input := generator(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    
    // Fan-out: distribute work to multiple workers
    worker1 := squarer(input)
    worker2 := squarer(input)
    worker3 := squarer(input)
    
    // Fan-in: merge results
    results := fanIn(worker1, worker2, worker3)
    
    // Collect results
    fmt.Println("Results from fan-out/fan-in:")
    for result := range results {
        fmt.Printf("Result: %d\n", result)
    }
}

// Pipeline pattern
func pipelineDemo() {
    fmt.Println("\n=== Pipeline Pattern ===")
    
    // Stage 1: Generate numbers
    generate := func(nums ...int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for _, n := range nums {
                out <- n
            }
        }()
        return out
    }
    
    // Stage 2: Square numbers
    square := func(in <-chan int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for n := range in {
                out <- n * n
            }
        }()
        return out
    }
    
    // Stage 3: Add one
    addOne := func(in <-chan int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for n := range in {
                out <- n + 1
            }
        }()
        return out
    }
    
    // Stage 4: Format as string
    toString := func(in <-chan int) <-chan string {
        out := make(chan string)
        go func() {
            defer close(out)
            for n := range in {
                out <- fmt.Sprintf("Result: %d", n)
            }
        }()
        return out
    }
    
    // Build pipeline
    numbers := generate(1, 2, 3, 4, 5)
    squared := square(numbers)
    incremented := addOne(squared)
    formatted := toString(incremented)
    
    // Consume results
    fmt.Println("Pipeline results:")
    for result := range formatted {
        fmt.Println(result)
    }
}

// Worker pool with job distribution
func workerPoolDemo() {
    fmt.Println("\n=== Advanced Worker Pool ===")
    
    type Job struct {
        ID   int
        Data int
    }
    
    type Result struct {
        JobID  int
        Output int
        Worker int
    }
    
    // Worker function
    worker := func(id int, jobs <-chan Job, results chan<- Result) {
        for job := range jobs {
            // Simulate work
            time.Sleep(time.Millisecond * time.Duration(rand.Intn(200)))
            
            // Process job
            output := job.Data * job.Data
            
            results <- Result{
                JobID:  job.ID,
                Output: output,
                Worker: id,
            }
            
            fmt.Printf("Worker %d processed job %d\n", id, job.ID)
        }
        fmt.Printf("Worker %d finished\n", id)
    }
    
    const numWorkers = 3
    const numJobs = 10
    
    jobs := make(chan Job, numJobs)
    results := make(chan Result, numJobs)
    
    // Start workers
    var wg sync.WaitGroup
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            worker(workerID, jobs, results)
        }(i)
    }
    
    // Send jobs
    for i := 1; i <= numJobs; i++ {
        jobs <- Job{ID: i, Data: i * 10}
    }
    close(jobs)
    
    // Close results channel after all workers finish
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // Collect results
    fmt.Println("Worker pool results:")
    for result := range results {
        fmt.Printf("Job %d: %d (processed by worker %d)\n", 
            result.JobID, result.Output, result.Worker)
    }
}

// Context-based cancellation
func contextCancellationDemo() {
    fmt.Println("\n=== Context Cancellation ===")
    
    // Long-running worker
    worker := func(ctx context.Context, id int, results chan<- string) {
        defer close(results)
        
        for i := 0; i < 100; i++ {
            select {
            case <-ctx.Done():
                fmt.Printf("Worker %d cancelled: %v\n", id, ctx.Err())
                return
            default:
                // Simulate work
                time.Sleep(time.Millisecond * 100)
                
                result := fmt.Sprintf("Worker %d: result %d", id, i)
                
                select {
                case results <- result:
                case <-ctx.Done():
                    fmt.Printf("Worker %d cancelled while sending: %v\n", id, ctx.Err())
                    return
                }
            }
        }
        
        fmt.Printf("Worker %d completed all work\n", id)
    }
    
    // Create context with timeout
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
    defer cancel()
    
    results := make(chan string, 10)
    
    // Start worker
    go worker(1, ctx, results)
    
    // Collect results until context is cancelled or worker finishes
    for {
        select {
        case result, ok := <-results:
            if !ok {
                fmt.Println("Worker finished or results channel closed")
                return
            }
            fmt.Println(result)
        case <-ctx.Done():
            fmt.Printf("Main: context cancelled: %v\n", ctx.Err())
            return
        }
    }
}

// Race condition detection demonstration
func raceConditionDemo() {
    fmt.Println("\n=== Race Condition Detection ===")
    fmt.Println("Run with: go run -race main.go")
    
    var counter int
    var wg sync.WaitGroup
    const numGoroutines = 100
    
    // This will create a race condition
    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            
            // Race condition: multiple goroutines accessing counter
            temp := counter
            runtime.Gosched() // Increase chance of race
            counter = temp + 1
        }()
    }
    
    wg.Wait()
    fmt.Printf("Final counter value: %d (expected: %d)\n", counter, numGoroutines)
    fmt.Println("Note: Run with -race flag to detect race conditions")
}

func main() {
    rand.Seed(time.Now().UnixNano())
    
    producerConsumerDemo()
    fanOutFanInDemo()
    pipelineDemo()
    workerPoolDemo()
    contextCancellationDemo()
    raceConditionDemo()
    
    fmt.Println("\nAdvanced goroutine patterns demonstration complete!")
}

6. Hands-On Practice (30-40 minutes)

Exercise 1: Parallel File Processing (10 minutes)

Objective: Build a concurrent file processor that handles multiple files simultaneously

Instructions:

  1. Create a program that processes multiple text files concurrently
  2. Count words, lines, and characters in each file
  3. Use a worker pool to limit concurrent file operations
  4. Aggregate results from all files

Starter Code:

package main

import (
    "fmt"
    "sync"
)

type FileStats struct {
    Filename   string
    Lines      int
    Words      int
    Characters int
}

// TODO: Implement processFile function
// TODO: Create worker pool for file processing
// TODO: Aggregate results from multiple files
// TODO: Handle errors gracefully

func main() {
    files := []string{"file1.txt", "file2.txt", "file3.txt"}
    
    // Process files concurrently
    fmt.Println("Processing files concurrently...")
}

Exercise 2: Real-time Data Pipeline (15 minutes)

Objective: Create a data processing pipeline with multiple stages

Requirements:

  • Generate streaming data (simulate sensor readings)
  • Filter invalid data points
  • Transform data (calculate moving averages)
  • Store results (simulate database writes)
  • Handle backpressure and flow control

Instructions:

  1. Design pipeline stages as separate goroutines
  2. Use channels for communication between stages
  3. Implement proper error handling and cancellation
  4. Add monitoring and statistics collection

Exercise 3: Concurrent Web Scraper (15 minutes)

Objective: Build a web scraper that fetches multiple URLs concurrently

Challenge: Create a scraper that respects rate limits and handles failures

Requirements:

  • Fetch multiple URLs concurrently with rate limiting
  • Parse and extract specific data from responses
  • Implement retry logic for failed requests
  • Use context for timeout and cancellation
  • Pool HTTP connections for efficiency

Extension Ideas:

  • Add caching to avoid duplicate requests
  • Implement exponential backoff for retries
  • Add metrics collection and monitoring
  • Support for different content types and parsers

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Goroutine leaks causing memory growth Cause: Goroutines waiting indefinitely on channels or operations Solution: Use context for cancellation and proper channel closing

  2. Problem: Race conditions in shared data access Cause: Multiple goroutines accessing data without synchronization Solution: Use mutexes, atomic operations, or channel communication

  3. Problem: Deadlocks from circular waiting Cause: Goroutines waiting for each other in circular dependency Solution: Consistent lock ordering and timeout mechanisms

  4. Problem: Excessive goroutine creation causing performance issues Cause: Creating too many goroutines without limits Solution: Use worker pools and bounded concurrency

Debugging Tips

  • Race Detection: Use go run -race to detect race conditions
  • Profiling: Use go tool pprof to analyze goroutine usage
  • Deadlock Detection: Go runtime detects and reports deadlocks
  • Monitoring: Track goroutine count with runtime.NumGoroutine()

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Graceful Shutdown: Always provide ways to stop goroutines cleanly
  • Error Handling: Propagate errors through channels or return values
  • Resource Management: Use defer statements for cleanup
  • Context Usage: Use context for cancellation and timeouts

Code Organization

// Good: Clear goroutine lifecycle management
func (s *Server) Start(ctx context.Context) error {
    go func() {
        <-ctx.Done()
        s.shutdown()
    }()
    
    return s.serve()
}

Performance Considerations

  • GOMAXPROCS: Usually set automatically, rarely needs tuning
  • Goroutine Pool Size: Match to available CPU cores for CPU-bound work
  • Channel Buffering: Use buffered channels to reduce blocking
  • Memory Usage: Monitor goroutine stack usage in long-running programs

Next Steps in Your Learning Journey

  • Channels: Deep dive into channel patterns and communication
  • Context Package: Advanced cancellation and timeout patterns
  • Sync Package: Advanced synchronization primitives
  • Performance Tuning: Profiling and optimizing concurrent programs

9. Summary and Review (5 minutes)

Key Takeaways

  1. Goroutines: Lightweight, efficiently scheduled by Go runtime
  2. Synchronization: Use WaitGroups, mutexes, and channels for coordination
  3. Patterns: Producer-consumer, fan-out/fan-in, pipeline patterns
  4. Safety: Always consider race conditions and use proper synchronization

Quick Review Questions

  1. What’s the difference between concurrency and parallelism?
  2. How do you wait for multiple goroutines to complete?
  3. When should you use mutex vs atomic operations?
  4. How can you detect race conditions in your Go programs?

Answers:

  1. Concurrency is about dealing with multiple things at once; parallelism is doing multiple things at once
  2. Use sync.WaitGroup to wait for multiple goroutines
  3. Use atomic for simple operations on single values; mutex for protecting complex data structures
  4. Use the -race flag when running or testing Go programs

For Instructors

Teaching Tips

  • Visual Aids: Use diagrams to show goroutine scheduling and communication
  • Live Demos: Show race conditions and their fixes in real-time
  • Practical Examples: Use real-world scenarios like web servers and data processing
  • Performance Comparison: Demonstrate sequential vs concurrent performance

Assessment Ideas

  • Concurrency Debugging: Give code with race conditions to fix
  • Pattern Implementation: Have students implement worker pools and pipelines
  • Performance Analysis: Analyze and optimize concurrent programs
  • Real-world Projects: Build concurrent applications like web scrapers or data processors

Adaptation Notes

  • Beginner Focus: Start with basic goroutines, add complexity gradually
  • Advanced Students: Cover advanced patterns and performance optimization
  • System Programming: Emphasize low-level synchronization primitives
  • Time Management: Concurrency concepts require hands-on practice to master