Theory: Understanding Goroutines
Why Goroutines Matters
The Problem: Threads cost megabytes of stack each — you can't have 100,000 of them.
The Solution: Goroutines are user-space threads multiplexed onto OS threads by the Go runtime. They start at ~2KB stack and grow as needed — so 100,000 concurrent goroutines on one machine is routine.
Real Impact: Concurrency in Go is cheap, idiomatic, and built into the language — not a library bolted on after the fact.
Real-World Analogy
Think of goroutines as runners on a track:
- Goroutine = a runner — lightweight, many can share the field
- Go scheduler = the coach assigning runners to lanes (OS threads)
- go keyword = the starting pistol — fire one off and move on
- Stack growth = runners carry small backpacks that expand if they need more gear
- Leak = a runner who never crosses the finish line — keeps consuming resources
Goroutines are lightweight threads managed by the Go runtime. They're the foundation of Go's concurrency model, enabling you to write efficient concurrent programs with minimal overhead.
🔍 Key Concepts
- G (Goroutine): Lightweight thread with its own stack (initially 2KB)
- M (Machine): OS thread that executes goroutines
- P (Processor): Scheduling context, holds local run queue
- GOMAXPROCS: Number of P's (default = number of CPU cores)
- Work Stealing: Idle P's can steal work from other P's queues
Creating and Managing Goroutines
Basic Goroutine Creation
// Basic goroutine syntax
package main
import (
"fmt"
"time"
)
func main() {
// Starting a goroutine with named function
go sayHello("World")
// Starting goroutine with anonymous function
go func() {
fmt.Println("Anonymous goroutine")
}()
// Starting goroutine with closure
message := "Closure variable"
go func() {
fmt.Println(message) // Accessing outer variable
}()
// Wait for goroutines to complete
time.Sleep(100 * time.Millisecond)
}
func sayHello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
WaitGroup for Synchronization
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when done
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Increment counter
go worker(i, &wg)
}
wg.Wait() // Block until counter reaches 0
fmt.Println("All workers completed")
}
Concurrency Patterns
Worker Pool Pattern
Distribute work among fixed number of goroutines to control resource usage.
Fan-In/Fan-Out
Merge multiple channels into one (fan-in) or distribute work to multiple goroutines (fan-out).
Pipeline Pattern
Chain goroutines together where output of one is input to another.
Worker Pool Implementation
package main
import (
"fmt"
"sync"
"time"
)
type Job struct {
ID int
Data string
}
type Result struct {
JobID int
Output string
Error error
}
func workerPool(numWorkers int, jobs <-chan Job, results chan<- Result) {
var wg sync.WaitGroup
// Start workers
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", workerID, job.ID)
// Simulate work
time.Sleep(100 * time.Millisecond)
result := Result{
JobID: job.ID,
Output: fmt.Sprintf("Processed: %s", job.Data),
}
results <- result
}
}(w)
}
// Wait for all workers to finish
go func() {
wg.Wait()
close(results)
}()
}
func main() {
numJobs := 10
numWorkers := 3
jobs := make(chan Job, numJobs)
results := make(chan Result, numJobs)
// Start worker pool
workerPool(numWorkers, jobs, results)
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- Job{ID: j, Data: fmt.Sprintf("data-%d", j)}
}
close(jobs)
// Collect results
for result := range results {
fmt.Printf("Result: %+v\n", result)
}
}
Context for Goroutine Management
Using Context for Cancellation
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Task %d cancelled: %v\n", id, ctx.Err())
return
default:
// Simulate work
fmt.Printf("Task %d working...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
// Start goroutines
for i := 1; i <= 3; i++ {
go longRunningTask(ctx, i)
}
// Let them run for 2 seconds
time.Sleep(2 * time.Second)
// Cancel all goroutines
fmt.Println("Cancelling all tasks...")
cancel()
// Give time for cleanup
time.Sleep(time.Second)
}
// Context with timeout
func timeoutExample() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
fmt.Println("Operation completed")
case <-ctx.Done():
fmt.Println("Operation timed out")
}
}
Common Pitfalls and Solutions
| Problem | Cause | Solution |
|---|---|---|
| Goroutine Leaks | Goroutines blocked forever on channels or I/O | Use context for cancellation, timeouts |
| Race Conditions | Multiple goroutines accessing shared state | Use channels or sync.Mutex for synchronization |
| Deadlocks | Circular dependencies in channel operations | Careful design, use select with default |
| Too Many Goroutines | Creating goroutines without limit | Use worker pools, semaphores |
| Closure Variable Capture | Loop variables captured by reference | Pass as parameters or create local copy |
Preventing Goroutine Leaks
// BAD: Goroutine leak
func leakyFunction() {
ch := make(chan int)
go func() {
val := <-ch // Blocks forever if no one sends
fmt.Println(val)
}()
// Function returns, channel never written to
}
// GOOD: With proper cleanup
func properFunction(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
fmt.Println("Goroutine cancelled")
return
}
}()
}
// GOOD: Using buffered channel
func bufferedExample() error {
errCh := make(chan error, 1) // Buffer prevents blocking
go func() {
// Do work...
errCh <- nil // Won't block even if no receiver
}()
select {
case err := <-errCh:
return err
case <-time.After(time.Second):
return fmt.Errorf("timeout")
}
}
️ Performance and Debugging
GOMAXPROCS and CPU Affinity
package main
import (
"fmt"
"runtime"
)
func main() {
// Get current GOMAXPROCS value
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
fmt.Printf("NumCPU: %d\n", runtime.NumCPU())
fmt.Printf("NumGoroutine: %d\n", runtime.NumGoroutine())
// Set GOMAXPROCS
runtime.GOMAXPROCS(2) // Limit to 2 cores
// Force garbage collection
runtime.GC()
// Get memory stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %v MB\n", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc: %v MB\n", m.TotalAlloc/1024/1024)
fmt.Printf("Sys: %v MB\n", m.Sys/1024/1024)
fmt.Printf("NumGC: %v\n", m.NumGC)
}
Race Detection
⚠️ Testing for Race Conditions
Always test concurrent code with the race detector:
go test -race ./...
go run -race main.go
Best Practices
✅ DO's
- ✓ Use goroutines for I/O bound operations
- ✓ Limit goroutine creation with worker pools
- ✓ Always know when and how goroutines exit
- ✓ Use context for cancellation and timeouts
- ✓ Test with
-raceflag - ✓ Profile your concurrent code
- ✓ Keep goroutines simple and focused
❌ DON'Ts
- ✗ Don't create goroutines in libraries without need
- ✗ Don't ignore goroutine leaks
- ✗ Don't use
time.Sleepfor synchronization - ✗ Don't share memory without synchronization
- ✗ Don't start goroutines in a loop without limiting
- ✗ Don't panic in goroutines without recovery
Practice Exercises
Exercise 1: Parallel Web Scraper
Build a concurrent web scraper that fetches multiple URLs in parallel using a worker pool. Include rate limiting and timeout handling.
Exercise 2: Pipeline Processing
Create a data processing pipeline with stages: read → transform → filter → write. Each stage should run in its own goroutine.
Exercise 3: Graceful Shutdown
Implement a service with multiple goroutines that can be gracefully shut down using context and signal handling.
Exercise 4: Fan-Out/Fan-In Pattern
Build a system that distributes work to multiple workers (fan-out) and collects results into a single channel (fan-in).
Challenge: Concurrent Cache
Design a thread-safe cache with TTL support, concurrent reads/writes, and automatic cleanup goroutine for expired entries.