Skip to content
academia.sh

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 and false, 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 false begin.
  • range stops on its own once the channel closes and its buffer empties; there is no need to write a break as well.
  • The responsibility to close belongs to the sender; a range waiting 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close