Skip to content
All essays
CraftMarch 18, 202414 min

Go Channels for Communication

Master channels for safe communication between goroutines

Ü
Ümit Uz
Mobile & Full Stack Developer

Channels are the primary way goroutines communicate with each other. They enable safe data transfer between concurrent operations, following Go's philosophy: "Don't communicate by sharing memory; share memory by communicating."

Understanding Channels

Channels are typed conduits through which you can send and receive values.

Creating Channels

go
// Unbuffered channel
ch := make(chan int)

// Buffered channel with capacity 3
ch := make(chan int, 3)

Sending and Receiving

go
ch := make(chan string)

// Send (blocks until someone receives)
go func() {
    ch <- "hello"
}()

// Receive (blocks until someone sends)
msg := <-ch
fmt.Println(msg)

The <- operator is used for both sending and receiving.

Buffered vs Unbuffered Channels

Unbuffered Channels

Unbuffered channels block on both send and receive:

go
ch := make(chan int)
go func() {
    ch <- 42 // Blocks until received
}()
val := <-ch // Blocks until sent

Provides synchronization guarantee.

Buffered Channels

Buffered channels don't block until buffer is full:

go
ch := make(chan int, 2)
ch <- 1  // Doesn't block
ch <- 2  // Doesn't block
// ch <- 3 // Would block, buffer full

Useful for producer-consumer patterns.

Choosing Between Them

  • Unbuffered: Strong synchronization, handoff
  • Buffered: Decoupling, throughput

Channel Directions

You can specify channel direction in function signatures.

Send-Only Channels

go
func sender(ch chan<- int) {
    ch <- 42
    // val := <-ch // Compile error!
}

Receive-Only Channels

go
func receiver(ch <-chan int) {
    val := <-ch
    fmt.Println(val)
    // ch <- 42 // Compile error!
}

Bidirectional Channels

go
func inout(ch chan int) {
    ch <- 42
    val := <-ch
}

Directional types prevent accidental misuse.

Closing Channels

Close a channel to indicate no more values will be sent.

Basic Closing

go
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

// Receive remaining values
for val := range ch {
    fmt.Println(val)
}

Detecting Closed Channel

go
val, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}

Closing Best Practices

go
// Only sender should close
func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // Signal completion
}

func consumer(ch <-chan int) {
    for val := range ch {
        fmt.Println(val)
    }
}

Never close from receiver side.

Range Over Channels

Iterate over channels until closed.

go
ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}()

for val := range ch {
    fmt.Println(val)
}

Select Statement

Select waits on multiple channel operations.

Basic Select

go
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
    time.Sleep(1 * time.Second)
    ch1 <- "one"
}()

go func() {
    time.Sleep(2 * time.Second)
    ch2 <- "two"
}()

for i := 0; i < 2; i++ {
    select {
    case msg1 := <-ch1:
        fmt.Println("Received", msg1)
    case msg2 := <-ch2:
        fmt.Println("Received", msg2)
    }
}

Default Case

Default runs when no channel is ready:

go
select {
case msg := <-ch:
    fmt.Println(msg)
default:
    fmt.Println("No message ready")
}

Timeout with Select

go
select {
case res := <-result:
    fmt.Println(res)
case <-time.After(2 * time.Second):
    fmt.Println("Timeout")
}

Channel Patterns

Generator Pattern

go
func generator(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

ch := generator(1, 2, 3)
for n := range ch {
    fmt.Println(n)
}

Fan-Out

Multiple goroutines reading from same channel:

go
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("Worker %d job %d\n", id, j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // 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)

    // Collect results
    for a := 1; a <= 5; a++ {
        <-results
    }
}

Fan-In

Multiple goroutines writing to same channel:

go
func fanIn(input1, input2 <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        for {
            select {
            case s := <-input1:
                out <- s
            case s := <-input2:
                out <- s
            }
        }
    }()
    return out
}

Pipeline

Connect stages with channels:

go
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    // Set up pipeline
    c := gen(2, 3)
    out := sq(c)

    // Consume output
    fmt.Println(<-out) // 4
    fmt.Println(<-out) // 9
}

Quit Channel

Signal goroutines to exit:

go
func worker quit chan struct{}) {
    for {
        select {
        case <-quit:
            fmt.Println("Worker quitting")
            return
        default:
            fmt.Println("Working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    quit := make(chan struct{})
    go worker(quit)

    time.Sleep(2 * time.Second)
    close(quit) // Signal worker to stop
    time.Sleep(time.Second)
}

Context and Channels

Context integrates with channels for cancellation.

With Context

go
import "context"

func worker(ctx context.Context, ch chan<- int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker cancelled")
            return
        case ch <- time.Now().Unix():
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    ch := make(chan int)
    go worker(ctx, ch)

    for val := range ch {
        fmt.Println(val)
    }
}

Batching

Batch channel items:

go
func batch(in <-chan int, size int) <-chan []int {
    out := make(chan []int)
    go func() {
        defer close(out)
        batch := make([]int, 0, size)
        for val := range in {
            batch = append(batch, val)
            if len(batch) == size {
                out <- batch
                batch = make([]int, 0, size)
            }
        }
        if len(batch) > 0 {
            out <- batch
        }
    }()
    return out
}

Semaphores with Channels

Limit concurrency:

go
func process(items []string, concurrency int) {
    sem := make(chan struct{}, concurrency)
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{} // Acquire

        go func(i string) {
            defer wg.Done()
            defer func() { <-sem }() // Release

            // Process item
            fmt.Println(i)
            time.Sleep(time.Second)
        }(item)
    }

    wg.Wait()
}

Common Pitfalls

Forgetting to Close

go
// WRONG: Deadlock
ch := make(chan int)
go func() {
    for val := range ch { // Blocks forever
        fmt.Println(val)
    }
}()
ch <- 1

// RIGHT: Close channel
go func() {
    for val := range ch {
        fmt.Println(val)
    }
}()
ch <- 1
close(ch)

Closing from Receiver

go
// WRONG: Panic if closed multiple times
func consumer(ch <-chan int) {
    for val := range ch {
        if val == 0 {
            close(ch) // Panic!
        }
    }
}

// RIGHT: Sender closes
func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

Sending to Closed Channel

go
ch := make(chan int, 1)
close(ch)
ch <- 1 // Panic!

Best Practices

  1. 1Close channels from sender side only
  2. 2Use range for receiving until closed
  3. 3Check ok value when receiving
  4. 4Use select for multiple operations
  5. 5Provide quit channels for cancellation
  6. 6Keep channel ownership clear
  7. 7Use directional types in function signatures

Performance Tips

Channels have overhead:

go
// For simple signaling, prefer sync.Cond
// For data transfer, channels are fine

// Buffered channels can reduce contention
ch := make(chan int, 100)

// But don't make buffers too large
ch := make(chan int, 1000000) // Wastes memory

Debugging Channels

Use goroutine leak detection:

go
func monitor() {
    ticker := time.NewTicker(time.Second)
    for range ticker.C {
        fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
    }
}

Conclusion

Channels are Go's elegant solution to concurrent communication. They enable safe data transfer between goroutines while providing powerful composition patterns through select and range.

Master channels, and you'll write Go programs that are both concurrent and clear. The channel-based approach to concurrency avoids the pitfalls of shared memory while maintaining efficiency.

Practice with different channel patterns, understand when to use buffered vs unbuffered channels, and leverage select for complex synchronization. Channels are one of Go's most distinctive features and a key reason why concurrent programming in Go feels so natural.

Enjoyed this essay? Share it.

Related essays

Next essay
Implementing Effective API Rate Limiting Strategies