Skip to content

Closing a channel ends a range

Level: 101 · anyone whose for v := range ch never ended

One line: close(ch) is how a sender says "no more values": a range over the channel ends after the last one, a receive from a closed and empty channel returns the zero value at once with ok == false, and sending on a closed channel or closing it twice panics — so only the sender closes.

A sensor goroutine sends three readings and closes the channel; main ranges over it, then receives twice more. Next the program closes a buffered channel that still holds two orders. Last, it does the three things close does not allow, and recovers each panic in the goroutine that raised it.

Verified output of closing_a_channel_ends_a_range_go.go — regenerated by tools/run_examples.py, never hand-typed.

range: received 21
range: received 19
range: received 23
range: ended, because the sensor closed the channel
receive after close: r = 0, ok = false
receive after close: r = 0, ok = false
closed with 2 in the buffer: "order-1", ok = true
closed with 2 in the buffer: "order-2", ok = true
closed with 2 in the buffer: "", ok = false
send on a closed channel: panic: send on closed channel
close a closed channel:   panic: close of closed channel
close a nil channel:      panic: close of nil channel

Reading the output

The range ended because the sensor closed the channel. The spec ↗ defines a range over a channel as the successive values sent on it until it is closed. The readings arrive in the order they were sent, because one goroutine sent them all. Without the close, the loop would have waited for a fourth reading — and with no goroutine left to send one, the runtime would have stopped the program, as in All goroutines are asleep.

A receive after the close does not wait. Both receives returned at once; had either one waited, nothing was left to wake it. Each returned 0, the zero value of int, and ok is the only way to tell that 0 from a real reading of 0: the spec ↗ makes ok false only for a zero value generated because the channel is closed and empty.

A close does not throw away the buffer. orders was closed with two values inside, and both came out with ok == true before the first zero value. close records that no more values will be sent (spec ↗); what was sent already is still delivered.

The three panics. Sending on a closed channel, closing it again and closing a nil channel each cause a run-time panic, and the messages are the runtime's own, from chan.go. None of them is about receiving: a receive cannot misuse a channel in this way.

Nothing recovers a panic unless the program asks. The same send, with no recover, ends the program with exit status 2:

Verified output of send_on_a_closed_channel_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build closed_send.go
$ ./closed_send
main: closed the channel, now sending on it
panic: send on closed channel
exit status 2

A goroutine other than main that does this takes the whole program down in the same way — A panic ends the whole program.

Only the sender closes

A receiver cannot know whether a sender is about to send again, and a close under a sender that is still running turns that sender's next send into the panic above. The documentation of close says it should be executed only by the sender, never the receiver, and the type system can hold a function to that. A parameter of type <-chan T is receive-only, and the spec ↗ makes closing a receive-only channel an error:

Verified output of closing_a_receive_only_channel_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build receiver_closes.go
# command-line-arguments
./receiver_closes.go:12:8: invalid operation: cannot close receive-only channel orders (variable of type <-chan string)
exit status 1

With several senders, none of them can close the channel either, because none of them knows when the others are done. The usual answer is one more goroutine that waits for all of the senders and then closes it; Fan-out, fan-in builds it.

closing_a_channel_ends_a_range_go.go, send_on_a_closed_channel_sh.sh and closing_a_receive_only_channel_sh.sh

closing_a_channel_ends_a_range_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// close is how a sender says "no more values". A range over the channel ends,
// and every receive after the last value returns at once with the zero value
// and ok == false. Sending on a closed channel, or closing it again, panics.
//
//  go build closing_a_channel_ends_a_range_go.go && ./closing_a_channel_ends_a_range_go
package main

import "fmt"

func main() {
    readings := make(chan int)
    go sensor(readings, []int{21, 19, 23})

    for r := range readings {
        fmt.Println("range: received", r)
    }
    fmt.Println("range: ended, because the sensor closed the channel")

    for range 2 {
        r, ok := <-readings
        fmt.Printf("receive after close: r = %d, ok = %t\n", r, ok)
    }

    // close does not throw away values already in a buffer.
    orders := make(chan string, 2)
    orders <- "order-1"
    orders <- "order-2"
    close(orders)
    for range 3 {
        order, ok := <-orders
        fmt.Printf("closed with 2 in the buffer: %q, ok = %t\n", order, ok)
    }

    fmt.Println("send on a closed channel: panic:", panicOf(func() { orders <- "order-3" }))
    fmt.Println("close a closed channel:   panic:", panicOf(func() { close(orders) }))
    var unmade chan string
    fmt.Println("close a nil channel:      panic:", panicOf(func() { close(unmade) }))
}

// sensor is the only sender on out, so it is the one that closes it.
func sensor(out chan<- int, values []int) {
    defer close(out)
    for _, v := range values {
        out <- v
    }
}

// panicOf runs f and returns the value it panicked with, recovered in this goroutine.
func panicOf(f func()) (value any) {
    defer func() { value = recover() }()
    f()
    return "none"
}

send_on_a_closed_channel_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# A send on a closed channel, with nothing to recover the panic: the program
# ends with exit status 2. Its stderr also carries a goroutine trace with a
# temporary path and code offsets, so this script keeps only the panic line.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/closed_send.go" <<'GO'
package main

import "fmt"

func main() {
    orders := make(chan string, 1)
    close(orders)
    fmt.Println("main: closed the channel, now sending on it")
    orders <- "order-18"
    fmt.Println("main: never printed")
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build closed_send.go'
(cd "$dir" && go build closed_send.go) || exit 1

say './closed_send'
(cd "$dir" && ./closed_send 2>stderr.txt)
status=$?
grep '^panic:' "$dir/stderr.txt"
echo "exit status $status"

closing_a_receive_only_channel_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# "Only the sender closes" can be written into a type. A function that takes a
# receive-only channel, <-chan T, cannot close it: the compiler refuses.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/receiver_closes.go" <<'GO'
package main

func main() {
    orders := make(chan string, 1)
    orders <- "order-19"
    pack(orders)
}

// pack only receives orders, and its parameter type says so.
func pack(orders <-chan string) {
    <-orders
    close(orders)
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build receiver_closes.go'
(cd "$dir" && go build receiver_closes.go) 2>&1
echo "exit status $?"

What to do

  • Close a channel when a receiver has to be told that no more values are coming — to end a range, or to let the next stage of a pipeline finish. A channel that nobody is waiting on to end needs no close: as the Tour of Go ↗ puts it, channels aren't like files.
  • Close in the goroutine that sends, and only once. defer close(ch) at the top of the sending function closes it on every way out.
  • Give receivers a <-chan T, so that a stray close is a compile error rather than a panic in a running program.
  • Receive with v, ok := <-ch when a zero value could be real data. In a select over several channels, a closed channel is ready every time round the loop; A nil channel disables a case shows how to switch it off.

In other languages

  • The Rust library's Channels ↗ — a Rust channel has no close: it ends when every Sender has been dropped, and that is when for x in rx ends, so the shutdown follows ownership instead of a call someone has to remember. Sending to a receiver that has been dropped is not a panic either: Sender::send hands the value back inside an Err.
  • The Concurrency library's chapter 05, Message passing, which will put closing a channel to all of its languages, is planned.

Sources