Lesson 05 / 14
Channel Closing and Ranging
Closing a channel is a termination signal: range ends on a closed channel, and a direct receive from a closed channel returns the zero value and false. The answer given by four forms of receiving (open, closed-full, closed-empty, closed-empty again) measures this. The responsibility to close always belongs to the sender. Bounding measurement: sending to a closed channel and closing a channel a second time both panic; both are caught with recover and written only as panic=true/false.
Published
In this lesson
- Shows that a closed channel is immediately ready inside select, and that it returns the zero value and false
- Proves that closing is a termination signal by measuring the four distinct answers of an open, closed-full, closed-empty, and range receive
- Shows, by running it, that the responsibility to close belongs to the sender, and that sending to a closed channel and closing a channel a second time both result in a panic
Contents
The previous lesson measured select‘s deterministic selection and its random exception,
but left one question open: how does a closed channel behave inside select? This lesson
opens with that question and moves on to the topic’s last question — what closing a
channel announces, and whose responsibility that announcement is. This is the topic’s fifth
and final lesson: how goroutines stop, what channels carry, what the buffer defers, and how
select chooses have all been measured; a single question remains — when does a
communication end, and who declares that it has ended.
A Closed Channel Is Immediately Ready Inside select
This was the question left at the end of the previous lesson: does a closed channel make a
select branch ready, even when it holds no values at all? The answer is yes — a receive
from a closed channel never blocks, and so that branch is always ready to be chosen.
What it carries back, though, is not a value, it is the news that the channel has ended.
// selectclosed.go — a closed channel is immediately ready inside select, and returns the zero value and false
package main
import "fmt"
func main() {
closed := make(chan int)
close(closed)
waiting := make(chan int)
select {
case v, ok := <-closed:
fmt.Println("closed channel branch chosen, value=", v, "ok=", ok)
case v, ok := <-waiting:
fmt.Println("waiting channel branch chosen, value=", v, "ok=", ok)
}
}
closed channel branch chosen, value= 0 ok= false
GC18. — waiting is never filled and never closed; this branch would never have become
ready. closed, on the other hand, is always ready from the moment close is called —
this is why select chooses the closed branch between these two without any hesitation at
all, the randomness from the previous lesson does not come into play here because only one
branch is genuinely ready. v stays at its zero value (0), and ok returns false — this
two-value form is the channels’ counterpart of the same syntax seen in map access in the Go
Fundamentals course, but what it carries differs: in a map, ok says whether a key exists,
in a channel, it says whether the channel is still open.
This connects to the nil channel observation from the previous lesson: the way to make a
branch ineffective inside a select was to turn that branch’s channel into nil, because a
nil channel was never ready. A closed channel sits at the exact opposite end — instead of
never being ready, it is always ready. Both are boundary cases, but in opposite
directions: one makes you wait forever, the other never makes you wait at all.
These two boundary cases can also be used together: a goroutine that has finished a job can
turn its own progress channel into nil so that branch is never chosen again, or it can
close a cancellation channel so that every waiter wakes up at the same time. Both of these
patterns are the subject of the Concurrency Patterns topic; only the boundary cases
themselves were measured here.
Four Forms of Receiving
What closing carries changes depending on which state the channel is in at that moment. Four states are distinguished from one another: a value is received while the channel is open, a value still left in the buffer is received after the channel closes, a receive happens after the buffer has also emptied, and this last state repeats.
// fourreceives.go — the four forms of receiving from a closed channel: open, closed-full, closed-empty, range
package main
import "fmt"
func main() {
channel := make(chan int, 2)
channel <- 10
channel <- 20
v1, ok1 := <-channel
fmt.Println("received while open :", v1, ok1)
close(channel)
v2, ok2 := <-channel
fmt.Println("closed, buffer had a value:", v2, ok2)
v3, ok3 := <-channel
fmt.Println("closed and empty :", v3, ok3)
v4, ok4 := <-channel
fmt.Println("closed and empty again :", v4, ok4)
total, stepCount := 0, 0
channel2 := make(chan int, 3)
channel2 <- 1
channel2 <- 2
channel2 <- 3
close(channel2)
for value := range channel2 {
total += value
stepCount++
}
fmt.Println("range total and steps :", total, stepCount)
}
received while open : 10 true closed, buffer had a value: 20 true closed and empty : 0 false closed and empty again : 0 false range total and steps : 6 3
GC19. — The first receive happens before the channel is closed, and returns true like
an ordinary receive. When close(channel) is called, a value (20) is still sitting in the
buffer; closing does not erase this value — the second receive gives it back with true.
The third receive, after the buffer has emptied, now gives the zero value (0) and false;
the fourth receive repeats the same answer, because a receive from a closed and empty channel
gives this answer forever, it never blocks again. range turns this behavior into a
loop: once the channel closes and its buffer empties, the loop ends on its own, there is no
need to write a break — the channels’ counterpart of the “break that never needs to be
written” pattern seen in the Control Flow lesson in the Go Fundamentals course.
These four answers show exactly what closing announces: closing does not invalidate the values that already exist, it only announces that no new value will arrive. Values waiting in the buffer can still be received normally after closing; the signal only kicks in once the buffer is used up.
It matters that these four states were measured in sequence: the measurement is built up
step by step, inside a single goroutine, with no goroutine started. How many values are left
in the buffer at the moment of closing is known in advance (channel <- 10 and channel <- 20 placed two values, only one was received), which is why it is certain from the start
that the “closed but buffer still has a value” state will appear exactly once. Had the same
measurement been done on an unbuffered channel, the second state (closed but the buffer still
has a value) would never have happened at all — an unbuffered channel cannot have a value
waiting at the moment of closing, because every send only completed once a receiver was
ready.
The Responsibility to Close Belongs to the Sender
The authority to close a channel can technically be called by either the sender or the
receiver — close can be run by any side that holds a channel value. But there is only one
correct use of this authority: only the sending side should close it, because only the
sending side can have the information that “I have no more values left to send.” A receiver
closing the channel carries the risk of closing it while the sender still has a value it
plans to send — and a send to a closed channel panics, the subject of this lesson’s final
section.
This rule is not written into the channel’s own type. Even a parameter restricted with
chan<- int can call close — the direction restriction only separated sending from
receiving, closing is a separate operation from both and is defined in both directions. The
source of the rule is not the language, it is information: only the sender has any
possibility of knowing when it will finish sending, the receiver can never have that
information at all. A receiver can never know for certain, from the source code, whether it
will be sent to again.
Never closing at all also has a cost. A range waiting on a channel that is never closed
never stops, for as long as the channel stays unclosed — this is the “never stopped” class
measured in the course’s first lesson, showing up here again. One of the three examples that
fell into that class in that lesson was exactly this: a goroutine ranging over a channel that
never closes never finishes on its own, even if the rest of the program does.
// neverclosedrisk.go — range on a channel that never closes never stops, the leak shows as a boolean
package main
import (
"fmt"
"runtime"
)
func main() {
baseline := runtime.NumGoroutine()
neverClosed := make(chan int)
started := make(chan struct{})
go func() {
close(started)
for range neverClosed {
}
}()
<-started
fmt.Println("is there more than the baseline goroutine count:", runtime.NumGoroutine() > baseline)
}
is there more than the baseline goroutine count: true
GC20. — The started channel exists only to confirm the goroutine has started; what is
actually measured is that the range over neverClosed never finishes. Because
neverClosed is never closed, this goroutine stays in memory until the program ends. The
number runtime.NumGoroutine gives is never printed anywhere, it is only compared against
the baseline and reduced to true/false — the rule set up in the course’s first lesson
continues here exactly the same. This measurement shows that closing is not an optional
nicety, it is a requirement whenever a waiting party exists.
This measurement’s correctness rests on two things. First, the <-started receive
guarantees the goroutine has reached at least the close(started) line — which shows the
goroutine has not returned yet, and is therefore still countable. Second, the goroutine’s
body has exactly one line after close(started) (for range neverClosed), and this line
never finishes, because neverClosed is never closed; there is no possibility the goroutine
has returned after the <-started receive. Together, these two observations guarantee that
runtime.NumGoroutine gives the same answer (true) on every run — here too, no order was
left to the scheduler.
Bounding Measurement: Sending to a Closed Channel and Closing It Twice Both Panic
Closing has rules of its own, and the language does not stay silent when these rules are
broken. Trying to send to a closed channel panics; trying to close the same channel a second
time also panics. Both of these behaviors are caught with recover and written only as
panic=true/panic=false — the stack trace and error text are not printed.
// closeresponsibility.go — sending to a closed channel and closing it a second time both panic, caught with recover
package main
import "fmt"
func doesItPanic(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
func main() {
channel := make(chan int, 1)
close(channel)
sendPanicked := doesItPanic(func() { channel <- 1 })
fmt.Println("send to closed channel panic=", sendPanicked)
secondClosePanicked := doesItPanic(func() { close(channel) })
fmt.Println("closing the channel a second time panic=", secondClosePanicked)
}
send to closed channel panic= true closing the channel a second time panic= true
GC21. — doesItPanic runs the function it is given inside a defer/recover pair,
and returns only whether it panicked or not as a bool — the same panic/recover pattern
established in the Go Fundamentals course. The function inside defer calls recover(); if
there is a panic, recover stops it, returns its value, and sets p to true, if there is
no panic, recover returns nil and p, as the named return value, stays at its initial
false. Even though channel could hold a value (capacity 1, but empty here), every send
panics once it has been closed; this does not contradict the third section’s “closing does
not erase values” observation — closing only forbids new sends, not reading what already
exists. The second close panics in the same way, because close changes a channel’s state
irreversibly; there is no operation defined in the language for “closing again” a channel
that is already closed.
Together, these two panics show why the responsibility to close has to sit with a single side. If two separate goroutines try to close the same channel, the second one panics. If two separate goroutines are sending to the same channel and one of them closes it, the other’s next send panics. Without an agreement on whose job closing is, more than one goroutine cannot share the same channel — this problem itself is the subject of the Concurrency Patterns topic.
There is also a difference between these two panics. A panic from sending to a closed
channel is triggered at the same place in the source code, under the same conditions, on
every run — if there is a send after close, the panic is certain. A panic from closing a
second time is usually the result of a race, where two separate goroutines try to close the
same channel; which goroutine closes it first may not be deterministic, but that at least
one of them will panic is certain. This lesson sets up the second case, too, inside a single
goroutine, with two close calls in a row, deterministically — showing the same result
without running a real race.
A panic here is not a display of an error, it is a display of a contract violation.
Sending to a closed channel, or closing a channel a second time, is not a runtime error, it
is proof that the program’s own logic has tracked a channel’s state incorrectly. Even though
both of these can be caught with recover, the course’s rule still holds: instead of
printing the panic text, only true/false is written, because the panic text is a detail
tied to the version.
Summary
- A closed channel is always ready inside
select; what it carries back is the zero value andfalse, not data but news of termination. - Closing does not erase values that already exist: values waiting in the buffer can still
be received normally after closing, and only once the buffer is used up do the zero value
and
falsebegin. rangestops on its own once the channel closes and its buffer empties; there is no need to write abreakas well.- The responsibility to close belongs to the sender; a
rangewaiting on a channel that is never closed never stops, and this is a measurable goroutine leak. - Sending to a closed channel and closing a channel a second time both panic; both are violations of an irreversible state change.
- The authority to close sits in information, not in the channel’s type: only the sender can ever have any possibility of knowing when it will finish sending, the receiver never does.
Next Step
This topic’s five lessons measured communication between one goroutine and one other
goroutine: one channel, two sides, one sender, one receiver. Each lesson measured one
direction of this pairwise relationship — the goroutine itself, the channel’s two jobs, the
buffer’s deferral, select‘s selection, closing’s signal — and each one added a piece of
answer to the course’s opening question, how a goroutine will stop.
Closing is a termination signal, and the responsibility belongs to the sender — but if there is more than one sender, or more than one receiver, how does this responsibility get shared? If more than one goroutine tries to close the same channel at once, the second one panics; this lesson’s final measurement showed exactly that. The next topic looks at the patterns that use this signal among a large number of goroutines: worker pools, fan-out and fan-in, pipelines — and in each of them, how the responsibility to close is shared among how many sides.
To keep your progress and take notes, Log in
My notes
Log in to take notes.