# Go Concurrency: Goroutines and Channels - Complete Guide > Master Go concurrency with goroutines and channels. Advanced patterns, synchronization, select statements, and best practices with detailed code examples. - Published: 2026-02-21 - Updated: 2026-03-31 - Author: SharpSkill - Tags: go, golang, concurrency, goroutines, channels - Reading time: 14 min --- Concurrency stands as one of Go's greatest strengths. Unlike other languages where multithreading remains complex, Go provides an elegant model based on goroutines and channels that significantly simplifies concurrent application development. > **Go Philosophy** > > "Don't communicate by sharing memory; share memory by communicating." This fundamental principle guides all concurrency design in Go. ## Understanding Goroutines Goroutines are lightweight threads managed by the Go runtime. They consume roughly 2 KB of stack space (compared to several MB for OS threads) and enable thousands of concurrent tasks without system overhead. Launching a goroutine requires simply placing the `go` keyword before a function call. The runtime handles scheduling and distribution across available threads. ```go // goroutines_basic.go package main import ( "fmt" "time" ) // fetchData simulates a network request func fetchData(id int) { // Simulates network delay time.Sleep(100 * time.Millisecond) fmt.Printf("Data %d fetched\n", id) } func main() { // Sequential execution - 500ms total start := time.Now() for i := 1; i <= 5; i++ { fetchData(i) } fmt.Printf("Sequential: %v\n", time.Since(start)) // Concurrent execution - ~100ms total start = time.Now() for i := 1; i <= 5; i++ { go fetchData(i) // Execute as goroutine } time.Sleep(150 * time.Millisecond) // Wait for completion fmt.Printf("Concurrent: %v\n", time.Since(start)) } ``` Concurrent execution reduces total time from 500ms to approximately 100ms. However, using `time.Sleep` for goroutine synchronization is not a best practice. Channels offer an elegant solution. ## Channels: Communication Between Goroutines A channel is a typed conduit for sending and receiving values between goroutines. Channels ensure synchronization: a sending goroutine waits until another receives, and vice versa. Channel creation uses the `make` function. The `<-` operator sends and receives data depending on its placement relative to the channel. ```go // channels_basic.go package main import "fmt" // worker performs computation and returns result via channel func worker(id int, jobs <-chan int, results chan<- int) { // Receives jobs until channel closes for job := range jobs { result := job * 2 // Processing results <- result // Send result } } func main() { // Create channels jobs := make(chan int, 10) // Buffered channel results := make(chan int, 10) // Start 3 workers for w := 1; w <= 3; w++ { go worker(w, jobs, results) } // Send 5 jobs for j := 1; j <= 5; j++ { jobs <- j } close(jobs) // Signal end of jobs // Collect results for r := 1; r <= 5; r++ { result := <-results fmt.Printf("Result: %d\n", result) } } ``` Directional channels (`<-chan` for receive, `chan<-` for send) enhance code safety by limiting possible operations. ## Buffered vs Unbuffered Channels The distinction between these channel types directly affects goroutine synchronization behavior. Unbuffered channels block the sender until a receiver is ready. Buffered channels allow sending up to N values without blocking, where N represents the buffer capacity. ```go // buffered_channels.go package main import "fmt" func main() { // Unbuffered channel - strict synchronization unbuffered := make(chan string) go func() { unbuffered <- "message" // Blocks until received }() msg := <-unbuffered // Unblocks the send fmt.Println(msg) // Buffered channel - capacity of 2 buffered := make(chan int, 2) // These sends don't block buffered <- 1 buffered <- 2 // buffered <- 3 // Would block because buffer is full fmt.Println(<-buffered) // 1 fmt.Println(<-buffered) // 2 // Check capacity fmt.Printf("Length: %d, Capacity: %d\n", len(buffered), cap(buffered)) } ``` Buffered channels decouple producers and consumers, while unbuffered ones guarantee point-to-point synchronization. > **Watch Out for Deadlocks** > > A deadlock occurs when all goroutines are blocked waiting. The Go runtime detects this and terminates the program with an explicit error message. ## Select: Channel Multiplexing The `select` statement waits on multiple channel operations simultaneously. It resembles a switch statement for concurrent communications. This construct is essential for handling timeouts, cancellations, and multiple communications without blocking indefinitely on a single channel. ```go // select_example.go package main import ( "fmt" "time" ) // fetchAPI simulates an API call with variable delay func fetchAPI(name string, delay time.Duration, ch chan<- string) { time.Sleep(delay) ch <- fmt.Sprintf("%s: data received", name) } func main() { api1 := make(chan string) api2 := make(chan string) // Launch two API calls in parallel go fetchAPI("API-1", 100*time.Millisecond, api1) go fetchAPI("API-2", 200*time.Millisecond, api2) // Global timeout of 150ms timeout := time.After(150 * time.Millisecond) // Collect results with timeout for i := 0; i < 2; i++ { select { case result := <-api1: fmt.Println(result) case result := <-api2: fmt.Println(result) case <-timeout: fmt.Println("Timeout - operation cancelled") return } } } ``` The `select` chooses the first ready channel. If multiple are ready, the choice is pseudo-random to prevent starvation. ## Worker Pool Pattern The worker pool pattern distributes tasks among multiple workers, limiting concurrency and optimizing resource usage. This pattern proves indispensable for processing large amounts of data. The implementation relies on a shared jobs channel among workers and a results channel for collection. ```go // worker_pool.go package main import ( "fmt" "sync" "time" ) // Task represents a unit of work type Task struct { ID int Data string } // Result contains the processing result type Result struct { TaskID int Output string } // worker processes received tasks func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) { defer wg.Done() for task := range tasks { // Simulate processing time.Sleep(50 * time.Millisecond) results <- Result{ TaskID: task.ID, Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data), } } } func main() { const numWorkers = 3 const numTasks = 10 tasks := make(chan Task, numTasks) results := make(chan Result, numTasks) var wg sync.WaitGroup // Start workers for w := 1; w <= numWorkers; w++ { wg.Add(1) go worker(w, tasks, results, &wg) } // Send tasks for i := 1; i <= numTasks; i++ { tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)} } close(tasks) // Close results channel after workers finish go func() { wg.Wait() close(results) }() // Collect results for result := range results { fmt.Printf("Task %d: %s\n", result.TaskID, result.Output) } } ``` The `sync.WaitGroup` coordinates waiting for all workers to finish before closing the results channel. ## Fan-Out/Fan-In Pattern This pattern distributes work among multiple goroutines (fan-out) then aggregates results (fan-in). It maximizes parallelism while simplifying result collection. ```go // fan_out_fan_in.go package main import ( "fmt" "sync" ) // generate produces numbers on a channel func generate(nums ...int) <-chan int { out := make(chan int) go func() { for _, n := range nums { out <- n } close(out) }() return out } // square computes the square of received numbers func square(in <-chan int) <-chan int { out := make(chan int) go func() { for n := range in { out <- n * n } close(out) }() return out } // merge combines multiple channels into one (fan-in) func merge(channels ...<-chan int) <-chan int { out := make(chan int) var wg sync.WaitGroup // Output function for each channel output := func(c <-chan int) { defer wg.Done() for n := range c { out <- n } } // Launch a goroutine per channel wg.Add(len(channels)) for _, c := range channels { go output(c) } // Close after all goroutines finish go func() { wg.Wait() close(out) }() return out } func main() { // Generate data numbers := generate(1, 2, 3, 4, 5, 6, 7, 8) // Fan-out: distribute to 3 workers sq1 := square(numbers) sq2 := square(numbers) sq3 := square(numbers) // Fan-in: aggregate results for result := range merge(sq1, sq2, sq3) { fmt.Println(result) } } ``` This pattern excels for distributable CPU-bound operations and data processing pipelines. ## Context for Cancellation and Deadlines The `context` package standardizes cancellation, deadline, and value management across goroutines. Any long-running goroutine should accept a context as its first parameter. ```go // context_example.go package main import ( "context" "fmt" "time" ) // fetchWithContext simulates a cancellable request func fetchWithContext(ctx context.Context, url string) (string, error) { // Simulates a long operation select { case <-time.After(2 * time.Second): return fmt.Sprintf("Data from %s", url), nil case <-ctx.Done(): return "", ctx.Err() // context.Canceled or context.DeadlineExceeded } } func main() { // Context with 500ms timeout ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() // Release resources result, err := fetchWithContext(ctx, "https://api.example.com") if err != nil { fmt.Printf("Error: %v\n", err) return } fmt.Println(result) // Context with manual cancellation ctx2, cancel2 := context.WithCancel(context.Background()) go func() { time.Sleep(100 * time.Millisecond) cancel2() // Explicit cancellation }() result, err = fetchWithContext(ctx2, "https://api2.example.com") if err != nil { fmt.Printf("Request cancelled: %v\n", err) } } ``` > **Best Practice** > > Always call `defer cancel()` immediately after creating a context to avoid resource leaks. ## Synchronization with sync.Mutex While channels are preferred for communication, the `sync` package remains necessary for protecting concurrent access to shared data structures. ```go // mutex_example.go package main import ( "fmt" "sync" ) // SafeCounter is a thread-safe counter type SafeCounter struct { mu sync.Mutex value map[string]int } // Increment increments the value for a given key func (c *SafeCounter) Increment(key string) { c.mu.Lock() // Exclusive lock defer c.mu.Unlock() // Guaranteed unlock c.value[key]++ } // Value returns the current value func (c *SafeCounter) Value(key string) int { c.mu.Lock() defer c.mu.Unlock() return c.value[key] } func main() { counter := SafeCounter{value: make(map[string]int)} var wg sync.WaitGroup // 1000 concurrent increments for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter.Increment("visits") }() } wg.Wait() fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000 } ``` The `sync.RWMutex` optimizes concurrent reads with `RLock()`/`RUnlock()` for read-only operations. ## Common Mistakes and Solutions Go concurrency presents classic pitfalls. Here are the most common errors and how to avoid them. ```go // common_mistakes.go package main import ( "fmt" "sync" ) func main() { // ERROR: Loop variable capture // All goroutines would print the same value for i := 0; i < 3; i++ { go func() { fmt.Println(i) // Capture by reference - BUG }() } // SOLUTION: Pass value as parameter var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func(n int) { defer wg.Done() fmt.Println(n) // Local copy - CORRECT }(i) } wg.Wait() // ERROR: Send on nil channel var ch chan int // ch <- 1 // Blocks forever // SOLUTION: Always initialize with make ch = make(chan int, 1) ch <- 1 fmt.Println(<-ch) // ERROR: Send on closed channel done := make(chan bool) close(done) // done <- true // Panic! // SOLUTION: Check before send or use sync.Once select { case done <- true: fmt.Println("Sent") default: fmt.Println("Channel closed or full") } } ``` Data race detection uses the `-race` flag during compilation or testing: `go test -race ./...`. ## Conclusion Mastering Go concurrency relies on a few key concepts that, when well understood, enable building highly performant applications. **Key takeaways:** ✅ Goroutines are lightweight and cheap - creating thousands remains acceptable ✅ Channels synchronize and transfer data between goroutines ✅ The `select` statement handles multiple communications and timeouts ✅ The worker pool pattern limits concurrency and optimizes resources ✅ The `context` package standardizes cancellation and deadlines ✅ Mutexes protect shared data when channels are insufficient ✅ The `-race` flag detects data races during testing The "Share memory by communicating" philosophy guides toward safer and more maintainable designs than traditional multithreading with locks. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/go/go-concurrency-goroutines-channels