Skip to content

Channels

Johnny Boursiquot edited this page May 31, 2024 · 1 revision

Time to explore a key primitive in Go's concurrency arsenal: channels.

Key Concepts

  • Channels are excellent for designing concurrent programs that communicate via message passing.
  • Channels help prevent data races by ensuring only one goroutine accesses data at a time.
  • Channels provide a higher level of abstraction for coordinating goroutines, making code easier to reason about in complex concurrent scenarios.
  • Channels are great for implementing worker pools and handling concurrent tasks that need to communicate results.

Channels Basics

Channels are how goroutines communicate with each other. Consider the following example:

package main

import (
	"fmt"
	"time"
)

// sum calculates and prints the sum of numbers
func sum(nums []int) {
	sum := 0
	for _, v := range nums {
		sum += v
	}
	fmt.Println("Result:", sum)
}

func main() {
	nums := []int{1, 2, 3, 4, 5}

	// invoke the sum function as a goroutine
	go sum(nums)

	// force main thread to sleep
	time.Sleep(100 * time.Millisecond)
}

Rather than using a time.Sleep here, it would be better if the goroutine running the sum function could communicate when it's done with the main goroutine. We'll modify the program to make use of channels that facilitate this communication.

func sum(nums []int, ch chan int) {
    sum := 0
    for _, num := range nums {
        sum += num
    }
    ch <- sum
}

func main() {
	nums := []int{1, 2, 3, 4, 5}

	// create a channel
	ch := make(chan int)

	// invoke sum as a goroutine and pass the channel
	go sum(nums, ch)

	// receive the result from the channel
	result := <-ch // blocking operation

	fmt.Println("Result:", result)

	// create a buffered channel
	ch2 := make(chan string, 2)

	// add two values to the channel
	ch2 <- "James"
	ch2 <- "Toni"

	// receive the result from the channel
	fmt.Println(<-ch2)
	fmt.Println(<-ch2)
}

Beware of Deadlocks

func main() {
    nums := []int{1, 2, 3, 4, 5}

    // create a channel
    ch := make(chan int)

    // invoke sum as a goroutine and pass the channel
    go sum(nums, ch)

    // receive the result from the channel
    result := <-ch // blocking operation

    fmt.Println("Result:", result)

    // create a buffered channel
    ch2 := make(chan string, 2)

    // add two values to the channel
    ch2 <- "James"
    ch2 <- "Toni"

    // receive the result from the channel
	// fmt.Println(<-ch2)
	// fmt.Println(<-ch2)

    // add another value to the channel which will block
    // because the channel is full
    ch2 <- "Maya"
}

Another deadlock example:

func main() {
    nums := []int{1, 2, 3, 4, 5}

    // create a channel
    ch := make(chan int)

    // invoke sum as a goroutine and pass the channel
    go sum(nums, ch)

    // receive the result from the channel
    result := <-ch // blocking operation

    fmt.Println("Result:", result)

    // create a buffered channel
    ch2 := make(chan string, 2)

    // add two values to the channel
    ch2 <- "James"
    ch2 <- "Toni"

    // receive the result from the channel
	// fmt.Println(<-ch2)
	// fmt.Println(<-ch2)

    wg := sync.WaitGroup{}
	wg.Add(1)
	go func() {
		defer wg.Done()
		ch2 <- "Maya"
	}()
	wg.Wait() // this blocks forever
}

Channel Ranging & Closing

Channels support an interesting feature that allows us to range over them, reading their values as they are sent. Let's see it in action.

package main

import (
	"fmt"
	"math/rand/v2"
)

// fill the channel with ints up to max
func fill(ch chan<- int, max int) {
	// loop and fill the channel up to its capacity with random numbersyy
	for i := 0; i < cap(ch); i++ {
		ch <- rand.IntN(max)
	}

	// close the channel and signal that no more values will be sent
	close(ch) // not closing leads to deadlock
}

func main() {
	// create a channel with a buffer size
	numChan := make(chan int, 5)

	// invoke the fill function as a goroutine
	go fill(numChan, 100)

	// range over the channel and print out the values
	for num := range numChan {
		fmt.Println(num)
	}
}

Channel Selects

Sometimes, we need to wait on multiple channel operations at the same time. That's where the select statement comes in. Let's see it in action.

package main

import (
	"fmt"
	"time"
)

func main() {
	// declare an empty struct channel for signaling
	sigChan := make(chan struct{})

	// declare a timer channel
	timeChan := time.After(1 * time.Second)

	// launch a goroutine to signal after 1 second
	go func() {
		time.Sleep(2 * time.Second)
		sigChan <- struct{}{}
	}()

	// wait for a signal on either channel
	select {
	case <-sigChan:
		fmt.Println("received from sigChan")
	case <-timeChan:
		fmt.Println("received from timeChan")
	}
}

The select statement is like a switch statement designed just for channels. It will wait on both channels and go into the case that is able to obtain a value first.

Update timers to get the other channel to be read from first and see how the results differ.

Non-Blocking Channel Selects

When we want to avoid blocking on sending on or receiving from a channel we can use the default case of a select statement. Let's see that in action.

package main

import (
	"fmt"
	"time"
)

func main() {
	// declare a signal channel to read from
	readChan := make(chan struct{})

	// use a default case in select to prevent blocking
	select {
	case <-readChan:
		fmt.Println("received from sigChan")
	default:
		fmt.Println("no signal received")
	}

	// declare two timer channels
	timeChan1 := time.After(200 * time.Millisecond)
	timeChan2 := time.After(400 * time.Millisecond)

    // common pattern to prevent blocking
    // but ensure you have loop exit conditions
	for {
		select {
		case <-timeChan1:
			fmt.Println("timeChan1")
			return
		case <-timeChan2:
			fmt.Println("timeChan2")
			return
		default:
			fmt.Println("default")
			time.Sleep(250 * time.Millisecond)
		}
	}
}

Mutex or Channel?

It depends.

Here are some guidelines to help you decide:

Prefer channels for coordination: Use channels for coordinating goroutines and managing concurrency when possible, as they fit well with Go's design philosophy.

Use mutexes for simple protection: Use mutexes for straightforward cases of protecting shared memory to keep the implementation simple and efficient.

Resources