Menu Close

nil channels in Go

Channels are a powerful tool for communication and concurrent processing in Go. Every type in Go has an appropriate default value. For channels the default is nil. In general, channels support three operations.

  • receiving values <-c
  • sending values c <- v
  • closing the channel close(c)

In this article, we’re going to cover how nil channels react to those operations and how you can use this behavior to write better Go programs.

nil channel operations

A nil channel reacts to actions on it as follows:

  • Reading from a nil channel blocks forever
  • Sending to a nil channel blocks forever
  • closing a nil channel panics
package main

func main() {
	var c chan int
	v := <-c // blocks forever
	c <- v // blocks forever
	close(c) // panics
}

nil channel in action

At first glance, there seems to be not much useful about the behavior of nil channels. Some people even argue nil channels exist just for completeness. But sometimes they can come handy. Let’s look at some code with a subtle problem, that we can fix with nil channels afterward.

Example without nil channels

package main

import (
	"fmt"
	"time"
)

func sendWithDelay(nums []int, delay time.Duration) chan int {
	c := make(chan int)

	go func() {
		for _, n := range nums {
			time.Sleep(delay)
			c <- n
		}

		close(c)
	}()

	return c
}

func main() {
	nums := []int{1, 2, 3, 4, 5}
	chanA := sendWithDelay(nums, time.Duration(500)*time.Millisecond)
	chanB := sendWithDelay(nums, time.Duration(1000)*time.Millisecond)

	chanADone := false
	chanBDone := false

	for !chanADone || !chanBDone {
		select {
		case v, ok := <-chanA:
			if !ok {
				chanADone = true
				continue
			}
			fmt.Println("From a: ", v)
		case v, ok := <-chanB:
			if !ok {
				chanBDone = true
				continue
			}
			fmt.Println("From b: ", v)
		}
	}
}

This program prints the values of an int slice written to different channels to the console.

The sendWithDelay function starts a Goroutine, which for each value in the given slice waits for the given duration and sends it to a channel. The channel is returned to the caller.

The main function calls sendWithDelay twice with different durations. It then selects values from the appropriate channels as long as both channels were not closed.

Running this program should output something like this:

From a: 1
From b: 1
From a: 2
From a: 3
From b: 2
From a: 4
From a: 5
From b: 3
From b: 4
From b: 5

The program is working as expected. But if we would create a CPU profile for this code, it would yield a very bad result. We can see why by removing the “continue” statement for the chanA inside the loop:

...
		case v, ok := <-chanA:
			if !ok {
				chanADone = true
				// continue
			}
			fmt.Println("From a: ", v)

...

Running the program again should yield something like this:

From a: 1
From b: 1
From a: 2
From a: 3
From b: 2
From a: 4
From a: 5
From a: 0
From a: 0
From a: 0
From a: 0
...

As soon as chanA is closed, the select is no longer blocking because a read on a closed channel always returns the default value of the channel’s type. This implementation runs the case for receiving from chanA for every iteration as long as chanB gets closed too, and this produces the extensive CPU load.

Fix it with nil channels

Read-operations on a nil channel block forever. This is exactly what we need to prevent a case in a select statement from being executed. So instead of tracking if a channel is done by the flags chanADone and chanBDone, we simply set each channel to nil when it was closed. Then we change the loop to end if both channels are equal to nil.

package main

import (
	"fmt"
	"time"
)

func sendWithDelay(nums []int, delay time.Duration) chan int {
	c := make(chan int)

	go func() {
		for _, n := range nums {
			time.Sleep(delay)
			c <- n
		}

		close(c)
	}()

	return c
}

func main() {
	nums := []int{1, 2, 3, 4, 5}
	chanA := sendWithDelay(nums, time.Duration(500)*time.Millisecond)
	chanB := sendWithDelay(nums, time.Duration(1000)*time.Millisecond)

	for chanA != nil || chanB != nil {
		select {
		case v, ok := <-chanA:
			if !ok {
				chanA = nil
				// continue
			}
			fmt.Println("From a: ", v)
		case v, ok := <-chanB:
			if !ok {
				chanB = nil
				continue
			}
			fmt.Println("From b: ", v)
		}
	}
}

This is the output of the program above:

From a: 1
From a: 2
From b: 1
From a: 3
From b: 2
From a: 4
From a: 5
From a: 0 // <- right before this line chanA is set to nil
From b: 3
From b: 4
From b: 5

You can see that the new version of the program executes the case for chanA only once after it has been closed. In this execution, chanA is set to nil and every subsequent attempt to receive something from chanA will block.

Uncomment the “continue” we commented earlier and the program will print the same output as in the beginning. But this time without the unnecessary CPU load.

Further Reading

Here some more examples for idomatic channel usage in Go:

Leave a Reply

Your email address will not be published.