A nil channel disables a case¶
Level: 201 · anyone merging channels that close at different times
One line: A send or receive on a nil channel blocks forever, so a select case on a nil channel can never be chosen. Set a channel variable to nil and its case is switched off, which is how a loop merges two channels until both are closed.
Verified output of a_nil_channel_disables_a_case_go.go — regenerated by tools/run_examples.py, never hand-typed.
1. A closed channel is always ready; a nil channel never is
closed channel: case chosen 1000 of 1000 times
nil channel: case chosen 0 of 1000 times
2. Merging two channels until both are closed
humidity reading 1
humidity reading 2
humidity reading 3
humidity reading 4
temperature reading 1
temperature reading 2
temperature reading 3
selects: 9 = 7 readings + 2 closes
Reading the output¶
1. Closed and nil are opposites. A receive from a closed channel can always proceed immediately ↗ and yields the zero value. A nil channel is never ready ↗. So in a thousand non-blocking selects on the closed channel, its case was chosen every time and default never ran. After orders = nil, the same select chose that case none of the thousand times.
The first number is the problem this lesson solves. A loop that keeps a closed channel in its select never waits on it again. That case is ready on every pass, so the loop comes straight back round instead of waiting on the channels that still have something to say.
2. Merging until both are closed. Two goroutines each send readings and close their channel when they are done. The loop receives from both. When a receive reports ok == false, the loop sets that channel variable to nil. From then on its case is never chosen, and the select waits on the other channel only. The condition temperatures != nil || humidities != nil ends the loop when both are off. The last line shows that nothing spun: 9 selects for 7 readings and 2 closes, so every select did one useful thing. The readings are printed sorted, because the order they arrive in varies.
3. When every case is nil. A select whose channels are all nil and that has no default blocks forever ↗, and so does a plain receive from a nil channel. This script builds both and runs them:
Verified output of a_nil_channel_blocks_forever_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go build nil_receive.go && ./nil_receive
main: receiving from a nil channel
fatal error: all goroutines are asleep - deadlock!
main goroutine: [chan receive (nil chan)]
exit status 2
$ go build nil_select.go && ./nil_select
main: select over two nil channels, no default
fatal error: all goroutines are asleep - deadlock!
main goroutine: [select]
exit status 2
With no other goroutine left to wake main, the runtime reports a deadlock and exits with status 2, the same as any program whose goroutines are all blocked (All goroutines are asleep). The goroutine dump even gives the reason for the plain receive, chan receive (nil chan). A channel declared and never made with make is nil, so that phrase in a crash usually means a missing make. The select over nil channels shows only as select.
a_nil_channel_disables_a_case_go.go and a_nil_channel_blocks_forever_sh.sh
a_nil_channel_disables_a_case_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// A send or receive on a nil channel blocks forever, so a select case on a nil
// channel can never be chosen. Setting a channel variable to nil switches its
// case off -- which is how a loop merges two channels until both are closed.
//
// go run a_nil_channel_disables_a_case_go.go
package main
import (
"fmt"
"slices"
)
func main() {
fmt.Println("1. A closed channel is always ready; a nil channel never is")
orders := make(chan string)
close(orders)
fmt.Println(" closed channel: case chosen", chosen(orders), "of 1000 times")
orders = nil
fmt.Println(" nil channel: case chosen", chosen(orders), "of 1000 times")
fmt.Println()
fmt.Println("2. Merging two channels until both are closed")
temperatures := readings("temperature", 3)
humidities := readings("humidity", 4)
var merged []string
selects := 0
for temperatures != nil || humidities != nil {
selects++
select {
case reading, ok := <-temperatures:
if !ok {
temperatures = nil // closed: switch this case off
continue
}
merged = append(merged, reading)
case reading, ok := <-humidities:
if !ok {
humidities = nil
continue
}
merged = append(merged, reading)
}
}
slices.Sort(merged) // arrival order varies; print in an order that does not
for _, reading := range merged {
fmt.Println(" ", reading)
}
fmt.Println(" selects:", selects, "=", len(merged), "readings + 2 closes")
}
// chosen runs 1000 non-blocking selects on ch and counts how often its case won.
func chosen(ch chan string) int {
count := 0
for range 1000 {
select {
case <-ch:
count++
default:
}
}
return count
}
// readings sends n readings of one kind, then closes the channel.
func readings(kind string, n int) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for i := range n {
out <- fmt.Sprintf("%s reading %d", kind, i+1)
}
}()
return out
}
a_nil_channel_blocks_forever_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# A receive from a nil channel blocks forever, and so does a select whose every
# channel is nil. With no other goroutine left to wake main, the runtime ends
# the program with a fatal error. The goroutine dump after it names files and
# addresses that vary, so this script keeps the fatal error, the state the dump
# gives main, and the exit status.
set -u
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cat >"$dir/nil_receive.go" <<'GO'
package main
import "fmt"
func main() {
var readings chan string // declared, never made: nil
fmt.Println("main: receiving from a nil channel")
<-readings
}
GO
cat >"$dir/nil_select.go" <<'GO'
package main
import "fmt"
func main() {
var readings, alarms chan string // both nil
fmt.Println("main: select over two nil channels, no default")
select {
case <-readings:
case <-alarms:
}
}
GO
say() { printf '$ %s\n' "$*"; }
for program in nil_receive nil_select; do
say "go build $program.go && ./$program"
(cd "$dir" && GOTOOLCHAIN=local go build "$program.go") || exit 1
(cd "$dir" && "./$program") 2>"$dir/stderr"
status=$?
grep '^fatal error' "$dir/stderr"
# "goroutine 1 [select]:" -- keep the bracket, drop the goroutine number
grep -m 1 '^goroutine ' "$dir/stderr" | sed -e 's/^goroutine [0-9]* /main goroutine: /' -e 's/:$//'
echo "exit status $status"
done
What to do¶
- In a
selectloop, set a channel tonilonce you have seen it close, and loop while any channel is still non-nil. Afor rangeover a single channel does this for you (Closing a channel ends a range).selectis where you do it yourself, as in the merge step of Fan-out, fan-in. - Use the same switch for a case that should be off for a while. For example, keep a send case's channel variable
niluntil there is something to send. The talk Advanced Go Concurrency Patterns ↗ builds a loop on this idea. - Never
closea nil channel. Closing one is a run-time panic ↗, unlike sending to or receiving from one, which just blocks.
In other languages¶
- Rust has no nil channel, because a
Receiveralways belongs to a channel. For aselect!case that is switched off, crossbeam providesnever()↗, a receiver that never delivers.tokio::select!↗ switches a branch off with anifprecondition. The Rust library's Channels ↗ page shows the other half: afor x in rxloop ends when everySenderhas been dropped, which is Rust's counterpart to Go'sclose. - The Concurrency library's chapter 05, Message passing, which covers closing a channel and
selectacross languages, is planned.
Sources¶
- The Go specification: Channel types ↗, Receive operator ↗, Select statements ↗ and Close ↗.
- Advanced Go Concurrency Patterns ↗ (2013), the slide "Select and nil channels".
- James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 8, "Selecting channels", the section on disabling select cases with nil channels.
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 12, "Concurrency in Go", the section "Turn Off a case in a select".