go labs

Channels in Go: Beyond the Basics

In our last post, we looked at the basic concurrency building blocks in Go: goroutines and channels. Goroutines are independently executing functions which use channels to communicate. In this post, we’ll take a deeper look at a few other ways of using channels.

Unidirectional Channels

By default, channels are bidirectional. However, unidirectional channels can be created in order to better communicate their intended usage. The compiler will enforce the unidirectional nature of the channel, prohibiting any reads or writes.


package main

import (
  "fmt"
)

func main() {
  ch := numbers()

  for i := 0; i < 4; i++ {
    fmt.Println(<-ch)
  }
}

func numbers() <-chan int {
  ch := make(chan int)

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

  return ch
}

numbers creates a local channel, and then launches a goroutine that will send incrementing numbers on the channel. The local channel is bidirectional, allowing numbers to write to it, but it’s returned as a unidirectional, read-only channel, allowing callers to only read from it.

Buffered Channels

By default, channels are unbuffered; a write will block until a corresponding read occurs. However, buffered channels can be created by specifying a capacity at creation.


func timeout(duration time.Duration) <-chan bool {
  ch := make(chan bool, 1)
  go func() {
    time.Sleep(duration)
    ch <- true
  }()
  return ch
}

timeout creates a local channel with a capacity of 1, and then launches a goroutine that will send a boolean value on the channel after the given duration. By specifying a capacity, we’ve created a buffered channel. This ensures that after the specified time has passed, the goroutine will send a value and immediately exit. It won’t wait for another goroutine to receive the value.

Iterating Over Channels

A channel can be iterated over using a for ... range loop. During each iteration, the loop blocks until a value is received. The loop terminates when the channel is closed.


package main

import (
  "fmt"
)

func main() {
  tasks := []string{"foo", "bar", "baz"}
  results := process(tasks)

  for result := range results {
    fmt.Println(result)
  }
}

func process(tasks []string) <-chan string {
  ch := make(chan string)
  go func() {
    for index, task := range tasks {
      ch <- fmt.Sprintf("processed task %d: %s", index, task)
    }
    close(ch)
  }()
  return ch
}

process creates a local channel, and then launches a goroutine to process each task, sending results on the channel. After processing all the tasks, the anonymous goroutine closes the channel. The main goroutine iterates over the channel until it’s closed.

Flexible Concurrency

Go’s channels can be used in a number of different ways: bidirectional or unidirectional, unbuffered or buffered, even iterated over like a collection. Channels help make concurrency in Go easy, yet still highly flexible.