Skip to content

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 select loop, set a channel to nil once you have seen it close, and loop while any channel is still non-nil. A for range over a single channel does this for you (Closing a channel ends a range). select is 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 nil until there is something to send. The talk Advanced Go Concurrency Patterns ↗ builds a loop on this idea.
  • Never close a 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 Receiver always belongs to a channel. For a select! case that is switched off, crossbeam provides never(), a receiver that never delivers. tokio::select! switches a branch off with an if precondition. The Rust library's Channels ↗ page shows the other half: a for x in rx loop ends when every Sender has been dropped, which is Rust's counterpart to Go's close.
  • The Concurrency library's chapter 05, Message passing, which covers closing a channel and select across languages, is planned.

Sources