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:
- Concurrency Concepts - Understanding concurrent vs parallel programming
- Goroutine Basics - Creating and managing lightweight threads
- Go Scheduler - How Go manages goroutines efficiently
- Synchronization - Coordinating goroutines with WaitGroups and mutexes
- Shared Data - Safe data sharing and race condition prevention
- Concurrent Patterns - Common patterns for concurrent programming
- Performance - Profiling and optimizing concurrent applications
- 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
- Creation: Started with
gokeyword - Scheduling: Managed by Go scheduler
- Execution: Runs concurrently with other goroutines
- Termination: Exits when function returns
- 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
-
Misconception: More goroutines always means better performance Reality: Too many goroutines can cause overhead and contention
-
Misconception: Goroutines are free and have no overhead Reality: While lightweight, they still consume memory and CPU
-
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:
- Create a program that processes multiple text files concurrently
- Count words, lines, and characters in each file
- Use a worker pool to limit concurrent file operations
- 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:
- Design pipeline stages as separate goroutines
- Use channels for communication between stages
- Implement proper error handling and cancellation
- 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
-
Problem: Goroutine leaks causing memory growth Cause: Goroutines waiting indefinitely on channels or operations Solution: Use context for cancellation and proper channel closing
-
Problem: Race conditions in shared data access Cause: Multiple goroutines accessing data without synchronization Solution: Use mutexes, atomic operations, or channel communication
-
Problem: Deadlocks from circular waiting Cause: Goroutines waiting for each other in circular dependency Solution: Consistent lock ordering and timeout mechanisms
-
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 -raceto detect race conditions - Profiling: Use
go tool pprofto 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
- Goroutines: Lightweight, efficiently scheduled by Go runtime
- Synchronization: Use WaitGroups, mutexes, and channels for coordination
- Patterns: Producer-consumer, fan-out/fan-in, pipeline patterns
- Safety: Always consider race conditions and use proper synchronization
Quick Review Questions
- What’s the difference between concurrency and parallelism?
- How do you wait for multiple goroutines to complete?
- When should you use mutex vs atomic operations?
- How can you detect race conditions in your Go programs?
Answers:
- Concurrency is about dealing with multiple things at once; parallelism is doing multiple things at once
- Use sync.WaitGroup to wait for multiple goroutines
- Use atomic for simple operations on single values; mutex for protecting complex data structures
- Use the
-raceflag 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