Back to blog

Go Channels and Communication: Complete Guide

gogolangchannelsconcurrencybackend
Go Channels and Communication: Complete Guide

In the previous post on goroutines, we learned how to create lightweight concurrent tasks. Now we'll master channels - Go's elegant way of communicating between goroutines.

"Don't communicate by sharing memory; share memory by communicating." - Go proverb

What Are Channels?

A channel is a typed conduit through which you can send and receive values between goroutines. Channels enable safe communication without explicit locks or condition variables.

Key Characteristics:

  • Type-safe: Channels carry values of a specific type
  • Thread-safe: Multiple goroutines can safely send/receive
  • Blocking: Send/receive operations block until ready
  • First-class: Channels are values that can be passed around

Your First Channel

package main
 
import "fmt"
 
func main() {
    // Create a channel of integers
    ch := make(chan int)
 
    // Send value in goroutine
    go func() {
        ch <- 42 // Send 42 to channel
    }()
 
    // Receive value
    value := <-ch // Receive from channel
    fmt.Println("Received:", value)
}

Output:

Received: 42

Channel Basics

Creating Channels

// Make a channel
ch := make(chan int)        // Unbuffered channel
ch := make(chan string, 10) // Buffered channel (capacity 10)
 
// Channel types
var readOnly <-chan int  // Can only receive
var writeOnly chan<- int // Can only send
var readWrite chan int   // Can send and receive

Sending and Receiving

ch <- value   // Send value to channel (blocks until received)
value := <-ch // Receive value from channel (blocks until sent)
value, ok := <-ch // Receive with status (ok is false if channel closed)

Closing Channels

close(ch) // Close channel (no more sends allowed)
 
// Check if closed
value, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}

Important Rules:

  • ❌ Cannot send on a closed channel (panics)
  • ✅ Can receive from a closed channel (returns zero value)
  • ❌ Cannot close a nil channel (panics)
  • ❌ Cannot close an already-closed channel (panics)

Unbuffered vs Buffered Channels

Unbuffered Channels (Synchronous)

ch := make(chan int) // No buffer
 
go func() {
    ch <- 42 // Blocks until someone receives
}()
 
value := <-ch // Blocks until someone sends
fmt.Println(value)

Characteristics:

  • Send blocks until receive happens
  • Receive blocks until send happens
  • Synchronization point between goroutines

Use when: You need guaranteed synchronization

Buffered Channels (Asynchronous)

ch := make(chan int, 3) // Buffer size 3
 
// Send doesn't block until buffer is full
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // Would block here
 
// Receive doesn't block until buffer is empty
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3

Characteristics:

  • Send blocks only when buffer is full
  • Receive blocks only when buffer is empty
  • Decouples sender and receiver

Use when: You want to allow some asynchrony


Complete Example: Producer-Consumer

package main
 
import (
    "fmt"
    "time"
)
 
func producer(ch chan<- int) {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Producing %d\n", i)
        ch <- i
        time.Sleep(500 * time.Millisecond)
    }
    close(ch) // Signal completion
}
 
func consumer(ch <-chan int) {
    for value := range ch { // Iterate until closed
        fmt.Printf("Consuming %d\n", value)
        time.Sleep(1 * time.Second)
    }
}
 
func main() {
    ch := make(chan int, 2) // Buffer of 2
 
    go producer(ch)
    consumer(ch) // Blocks until channel closed
 
    fmt.Println("Done")
}

Output:

Producing 1
Producing 2
Consuming 1
Producing 3
Producing 4
Consuming 2
Producing 5
Consuming 3
Consuming 4
Consuming 5
Done

The Select Statement

select lets you wait on multiple channel operations, like a switch for channels.

Basic Select

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
 
    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "from ch1"
    }()
 
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "from ch2"
    }()
 
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("Received:", msg1)
        case msg2 := <-ch2:
            fmt.Println("Received:", msg2)
        }
    }
}

Output:

Received: from ch1
Received: from ch2

Select with Default (Non-Blocking)

select {
case msg := <-ch:
    fmt.Println("Received:", msg)
default:
    fmt.Println("No message available")
}

Select with Timeout

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    ch := make(chan string)
 
    go func() {
        time.Sleep(2 * time.Second)
        ch <- "result"
    }()
 
    select {
    case res := <-ch:
        fmt.Println("Got:", res)
    case <-time.After(1 * time.Second):
        fmt.Println("Timeout!")
    }
}

Output:

Timeout!

Channel Patterns

Pattern 1: Fan-Out (Distribute Work)

Multiple workers process from a single source:

package main
 
import (
    "fmt"
    "sync"
    "time"
)
 
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(500 * time.Millisecond)
    }
}
 
func main() {
    jobs := make(chan int, 10)
    var wg sync.WaitGroup
 
    // Start 3 workers
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go worker(i, jobs, &wg)
    }
 
    // Send jobs
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)
 
    wg.Wait()
    fmt.Println("All jobs completed")
}

Output:

Worker 1 processing job 1
Worker 2 processing job 2
Worker 3 processing job 3
Worker 1 processing job 4
Worker 2 processing job 5
...
All jobs completed

Pattern 2: Fan-In (Collect Results)

Merge multiple channels into one:

package main
 
import (
    "fmt"
    "sync"
)
 
func producer(id int, ch chan<- string) {
    for i := 0; i < 3; i++ {
        ch <- fmt.Sprintf("Producer %d: item %d", id, i)
    }
}
 
func fanIn(channels ...<-chan string) <-chan string {
    out := make(chan string)
    var wg sync.WaitGroup
 
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan string) {
            defer wg.Done()
            for msg := range c {
                out <- msg
            }
        }(ch)
    }
 
    go func() {
        wg.Wait()
        close(out)
    }()
 
    return out
}
 
func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    ch3 := make(chan string)
 
    go producer(1, ch1)
    go producer(2, ch2)
    go producer(3, ch3)
 
    // Close channels after production
    go func() {
        for i := 0; i < 3; i++ {
            // Wait for producers to finish
        }
        close(ch1)
        close(ch2)
        close(ch3)
    }()
 
    merged := fanIn(ch1, ch2, ch3)
    for msg := range merged {
        fmt.Println(msg)
    }
}

Pattern 3: Pipeline

Chain processing stages:

package main
 
import "fmt"
 
// Stage 1: Generate numbers
func generator(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}
 
// Stage 2: Square 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
}
 
// Stage 3: Filter even numbers
func filterEven(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            if n%2 == 0 {
                out <- n
            }
        }
        close(out)
    }()
    return out
}
 
func main() {
    // Build pipeline
    numbers := generator(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    squared := square(numbers)
    evens := filterEven(squared)
 
    // Consume
    for n := range evens {
        fmt.Println(n)
    }
}

Output:

4
16
36
64
100

Pattern 4: Worker Pool

Fixed number of workers processing jobs:

package main
 
import (
    "fmt"
    "time"
)
 
type Job struct {
    ID     int
    Data   string
}
 
type Result struct {
    Job    Job
    Output string
}
 
func worker(id int, jobs <-chan Job, results chan<- Result) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job.ID)
        time.Sleep(500 * time.Millisecond)
 
        results <- Result{
            Job:    job,
            Output: fmt.Sprintf("Processed %s", job.Data),
        }
    }
}
 
func main() {
    const numWorkers = 3
    const numJobs = 10
 
    jobs := make(chan Job, numJobs)
    results := make(chan Result, numJobs)
 
    // Start workers
    for w := 1; w <= numWorkers; w++ {
        go worker(w, jobs, results)
    }
 
    // Send jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- Job{ID: j, Data: fmt.Sprintf("task-%d", j)}
    }
    close(jobs)
 
    // Collect results
    for r := 1; r <= numJobs; r++ {
        result := <-results
        fmt.Printf("Job %d result: %s\n", result.Job.ID, result.Output)
    }
}

Context for Cancellation

Use context.Context to cancel goroutines gracefully:

package main
 
import (
    "context"
    "fmt"
    "time"
)
 
func worker(ctx context.Context, id int, results chan<- int) {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: Cancelled\n", id)
            return
        case results <- i:
            fmt.Printf("Worker %d: Sent %d\n", id, i)
            time.Sleep(500 * time.Millisecond)
        }
    }
}
 
func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
 
    results := make(chan int)
 
    go worker(ctx, 1, results)
 
    for {
        select {
        case result := <-results:
            fmt.Println("Received:", result)
        case <-ctx.Done():
            fmt.Println("Main: Context cancelled")
            return
        }
    }
}

Channels vs Mutexes: When to Use Each

Use Channels When:

✅ Passing ownership of data
✅ Distributing work to multiple workers
✅ Communicating results between goroutines
✅ Coordinating goroutine lifecycles

// GOOD: Passing ownership
func processOrders(orders <-chan Order) {
    for order := range orders {
        // Process order
    }
}

Use Mutexes When:

✅ Protecting shared state (caches, counters)
✅ Short critical sections
✅ Simple data access without coordination

// GOOD: Protecting shared cache
type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}
 
func (c *Cache) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

Common Channel Pitfalls

Pitfall 1: Sending on a Closed Channel

// ❌ BAD: Panics!
ch := make(chan int)
close(ch)
ch <- 42 // panic: send on closed channel

Pitfall 2: Closing a Channel Multiple Times

// ❌ BAD: Panics!
ch := make(chan int)
close(ch)
close(ch) // panic: close of closed channel

Pitfall 3: Deadlock (No Receiver)

// ❌ BAD: Deadlock!
ch := make(chan int)
ch <- 42 // Blocks forever (no receiver)

Pitfall 4: Goroutine Leak

// ❌ BAD: Goroutine leaks if channel never receives
func leak() <-chan int {
    ch := make(chan int)
    go func() {
        ch <- computeExpensiveValue() // Blocks forever if no receiver
    }()
    return ch
}

Fix with timeout:

// ✅ GOOD: Use select with timeout
func noLeak() <-chan int {
    ch := make(chan int)
    go func() {
        select {
        case ch <- computeExpensiveValue():
        case <-time.After(5 * time.Second):
            return // Prevent leak
        }
    }()
    return ch
}

Best Practices

1. Close Channels from the Sender

// ✅ GOOD
func producer(ch chan<- int) {
    defer close(ch) // Sender closes
    for i := 0; i < 10; i++ {
        ch <- i
    }
}

2. Use Directional Channels

// ✅ GOOD: Type safety
func send(ch chan<- int) { // Can only send
    ch <- 42
}
 
func receive(ch <-chan int) { // Can only receive
    value := <-ch
}

3. Range Over Channels

// ✅ GOOD: Automatically stops when closed
for value := range ch {
    fmt.Println(value)
}

4. Check if Channel is Closed

// ✅ GOOD
value, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
    return
}

5. Use Buffered Channels for Known Capacity

// ✅ GOOD: Prevents blocking when capacity is known
results := make(chan Result, numJobs)

Real-World Example: Web Scraper with Channels

package main
 
import (
    "fmt"
    "io"
    "net/http"
    "time"
)
 
type Result struct {
    URL    string
    Status int
    Size   int
    Err    error
}
 
func fetch(url string, results chan<- Result) {
    start := time.Now()
    resp, err := http.Get(url)
    if err != nil {
        results <- Result{URL: url, Err: err}
        return
    }
    defer resp.Body.Close()
 
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        results <- Result{URL: url, Status: resp.StatusCode, Err: err}
        return
    }
 
    results <- Result{
        URL:    url,
        Status: resp.StatusCode,
        Size:   len(body),
    }
 
    fmt.Printf("Fetched %s in %v\n", url, time.Since(start))
}
 
func main() {
    urls := []string{
        "https://golang.org",
        "https://github.com",
        "https://stackoverflow.com",
        "https://reddit.com",
        "https://news.ycombinator.com",
    }
 
    results := make(chan Result, len(urls))
 
    // Launch goroutines
    for _, url := range urls {
        go fetch(url, results)
    }
 
    // Collect results
    for i := 0; i < len(urls); i++ {
        result := <-results
        if result.Err != nil {
            fmt.Printf("❌ %s: %v\n", result.URL, result.Err)
        } else {
            fmt.Printf("✅ %s: %d (%d bytes)\n",
                result.URL, result.Status, result.Size)
        }
    }
}

Performance Considerations

Channel Overhead

Channels have overhead compared to direct memory access:

// Slower: Channel communication
func withChannel() {
    ch := make(chan int)
    go func() {
        for i := 0; i < 1000000; i++ {
            ch <- i
        }
        close(ch)
    }()
    for range ch {}
}
 
// Faster: Shared memory with mutex
func withMutex() {
    var mu sync.Mutex
    var counter int
    go func() {
        for i := 0; i < 1000000; i++ {
            mu.Lock()
            counter++
            mu.Unlock()
        }
    }()
}

Rule of Thumb: Use channels for coordination, mutexes for performance-critical shared state.


Summary and Key Takeaways

✅ Channels enable safe communication between goroutines
✅ Unbuffered channels provide synchronization (blocking send/receive)
✅ Buffered channels allow asynchronous communication
✅ Select statement multiplexes multiple channel operations
✅ Close channels from the sender, not the receiver
✅ Use range to iterate until channel is closed
✅ Context enables graceful cancellation
✅ Channels for coordination, mutexes for shared state
✅ Common patterns: Fan-out, fan-in, pipeline, worker pool
✅ Avoid: Sending on closed channels, closing multiple times, deadlocks


What's Next?

You've mastered Go's concurrency primitives! Next steps:

Continue Go Series:

  • GO-10: Building Web Services in Go
  • GO-11: Testing in Go

Deep Dives:

  • Context Package (cancellation, deadlines, values)
  • Advanced Concurrency Patterns (sync.Pool, atomic, advanced patterns)
  • Go Performance Optimization

Practice Exercises

Exercise 1: Rate Limiter

Implement a rate limiter using channels:

func rateLimiter(requests <-chan int, rate time.Duration) <-chan int {
    // TODO: Limit requests to one per `rate` duration
}

Exercise 2: Pipeline with Error Handling

Build a pipeline that:

  1. Generates numbers 1-100
  2. Filters primes
  3. Squares them
  4. Handles errors at each stage

Exercise 3: Concurrent Map-Reduce

Implement map-reduce pattern:

  • Map: Process items concurrently
  • Reduce: Aggregate results

Go Learning Roadmap:

Companion Posts:

Comparison:


Additional Resources

Official Documentation:

Advanced Topics:

Books:

  • "Concurrency in Go" by Katherine Cox-Buday (Chapter 3: Channels)

Have questions about channels or concurrency? Let me know in the comments!

Happy concurrent programming! 🚀

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.