Skip to content
academia.sh

Lesson 04 / 14

select Statement

The select statement is the language's answer to waiting on more than one channel at the same time: when only one branch is ready, that branch is chosen deterministically; when none are ready and there is a default branch, that branch is chosen. Source order carries no priority. Bounding measurement: when two branches are ready at the same time, selection is random, and this case is not run, only modeled — randomness is the language's own decision.

Published

In this lesson

  • Shows that select deterministically chooses the one branch that is ready, and that the branches' source order carries no priority
  • Proves, by running it, that the default branch is chosen when no branch is ready, and how select is used as a non-blocking control tool
  • Shows that selection is random when two branches are ready at the same time, and that this case is modeled, not run
Contents

The previous lesson measured where a channel’s capacity starts blocking, and used select with default only to poll a single channel. But a goroutine often waits for news from more than one source at the same time: one of several result channels might be ready, a cancellation signal might arrive, a timeout might run out. The select statement exists for exactly this — the one structure that waits on more than one channel at the same time and handles whichever becomes ready first.

This need shows up everywhere that waiting on a single channel is not enough. When a goroutine is only waiting on one channel, writing <-channel is enough; but a goroutine that wants to handle whichever of two separate sources arrives first cannot write this with a single receive expression. select fills exactly this gap.

A select block contains more than one case, each starting with a channel operation. The statement picks one of the ready operations and runs that branch; if none is ready and there is a default branch, that runs immediately, otherwise select waits until at least one branch is ready. This lesson measures how this choice gets made, where it is deterministic, and where it is random.

Every branch of select can be a send or a receive; within the same select, some branches can be sends and others receives, what matters is only which one can complete at that moment without blocking. This is the first difference that sets select apart from a switch: a switch compares a value against its branches in sequence, while select tries every branch’s own channel operation at the same time and decides based on which one is ready. Writing a case that is not a channel operation does not compile; select’s branches always start with a send or a receive.

The language sets no upper limit on how many branches a select can have; a select block can wait on two channels or on ten. This lesson only uses two-channel examples, because the rules being measured — deterministic selection, order independence, randomness — do not depend on the number of branches; two branches are enough to show these rules and are the easiest case to read. As the number of branches grows, the rules being measured do not change, only the number of branches that can be ready at the same time grows; the third section’s rule about randomness also stays valid in exactly the same way when more than two branches are ready.

Selection Is Deterministic When Only One Branch Is Ready

If only one of the branches inside a select is ready, select runs that branch without considering any other option. If none are ready and there is a default branch, select falls to it without waiting at all — this is exactly the behavior used to poll capacity in the previous lesson.

// singleanddefault.go — selection is deterministic when only one branch is ready, default is chosen when none are
package main

import "fmt"

func choose(a, b chan string) string {
	select {
	case v := <-a:
		return "a: " + v
	case v := <-b:
		return "b: " + v
	default:
		return "default: neither is ready"
	}
}

func main() {
	a := make(chan string, 1)
	b := make(chan string, 1)

	fmt.Println("when neither is ready :", choose(a, b))

	a <- "first value"
	fmt.Println("when only a is ready  :", choose(a, b))

	b <- "second value"
	fmt.Println("when only b is ready  :", choose(a, b))
}
when neither is ready : default: neither is ready
when only a is ready  : a: first value
when only b is ready  : b: second value

GC14. — All three calls call the same choose function, with the same source code; the only thing that changes is the fullness of the channels. In the first call, both are empty, and default is chosen. Once a value is placed on a, the second call chooses the a branch and, in doing so, consumes the value on a — a is now empty again. In the third call, with only b full, the b branch is chosen. In no call were more than one branch ready at the same time; this is why all three results are strictly repeatable.

It matters that this measurement can be done inside a single goroutine, requiring no concurrency at all: values are placed on the a and b channels directly from the main goroutine, no goroutine is started. This shows that select itself is not a construct specific to concurrency — select is only a language feature that chooses among more than one channel operation; whether or not there is a goroutine on the other side of the channels does not change select’s own behavior. In later lessons select will always be used between goroutines, but this lesson shows its core behavior in its plainest form, inside a single goroutine.

This distinction looks small but matters: select’s deterministic behavior comes not from timing, only from which channels are full at that moment. Had the same code filled the channels in a different order, or run on a different machine, the result would still be the same — because what decides it is not timing, but the fullness state itself.

Source Order Is Not a Priority

select’s branches carry no top-to-bottom priority order — this is a fundamental difference from an ordinary if/else if chain or a switch. Whichever branch is ready gets chosen, no matter what order it was written in; source order exists only for readability, it does not affect runtime behavior.

// orderdoesnotmatter.go — the source order of select branches does not decide which branch is chosen
package main

import "fmt"

func choose(a, b chan string) string {
	select {
	case v := <-b:
		return "b: " + v
	case v := <-a:
		return "a: " + v
	}
}

func main() {
	a := make(chan string, 1)
	b := make(chan string, 1)
	a <- "only a filled"

	fmt.Println(choose(a, b))
}
a: only a filled

GC15. — In this function, the b branch is written first in the source, the a branch second. When run with only a full, the result is still a: only a filled — showing that the order in the source does not change the outcome. Every case in a select is weighed equally; whichever branch is ready is the one that runs. This raises the next section’s question: what would happen if two branches were ready at the same time, given that order carries no priority?

This behavior comes from how select evaluates its branches. The order written in the source is only the order the compiler reads the cases in; at runtime, select evaluates all of the branches at once, at the moment it runs, finds the ones that are ready, and then chooses among those. In an ordinary if a != nil { ... } else if b != nil { ... } chain, the first branch whose condition holds would win — there, source order really is a priority. select does not follow that model; this difference is one of the points most often confused when learning select, because its syntax looks a lot like a switch, but its evaluation rule is different.

Bounding Measurement: When Two Branches Are Ready at Once, Selection Is Random

Every measurement so far has set up situations where at most one branch is ready at any given moment. What rule does the language apply when two branches are ready at the same time? Go’s own specification gives a clear answer to this question: when more than one branch is ready at the same time, select chooses uniformly at random among them. This is a deliberate exception to the determinism rule every measurement in this course has rested on up to this lesson — and this is why this lesson does not run this case, only models it.

// modeledonly.go — when two branches are ready at once selection is random, this lesson does not run this, only shows it
package main

func chooseWhenBothReady(a, b chan string) string {
	select {
	case v := <-a:
		return "a: " + v
	case v := <-b:
		return "b: " + v
	}
}

GC16. — This function shows exactly the situation select faces when called with both a and b full: the compiler makes no fixed preference between the two branches here, the runtime chooses randomly again on every call. chooseWhenBothReady is never called anywhere in this lesson — had it been called, it could have produced different results between two runs, and this would deliberately break the “same output on every run” condition this course’s verification looks for. The function compiles only because it exists; the source itself shows where the randomness shows up, not a run of it.

This randomness is not a shortcoming, it is a deliberate design decision. If select always chose the first branch in the source between two ready branches, programmers would, over time, start using this order like a priority rule, and this could lead to one of the channels being systematically starved — if the same two branches stayed ready one after another, the second branch might never run at all. Random selection removes this risk at the language’s own level: no branch can systematically get ahead just because it was written first in the source.

This unmeasurability is the actual reason behind the previous section’s finding that “source order is not a priority.” Had order carried a priority, which branch gets chosen when two are ready at the same time would still be deterministic — the one written first would win. Because order carries no weight at all, there is no real rule left to decide between two branches that are both ready at once; the language fills this gap with randomness, a different path from the “defined” class seen in the Go Fundamentals course: there, a fixed value was placed in for something left unwritten, here, a value drawn again every time is placed in for something left unwritten. This lesson does not carry this distinction forward as an axis, it only notes it once.

Because this lesson never calls chooseWhenBothReady, the only way to show the difference between two runs is to read the source. This is the one place, unlike every other lesson in this course up through this one, where the claim “the same source can give a different result on different runs” is proven without running it: Go’s own specification guarantees that the randomness exists, this lesson only shows, on the source itself, where that guarantee takes effect.

select Is a Non-Blocking Control Tool

The default branch turns select into more than a polling tool, into a control tool that keeps the flow moving without ever stopping it. This can be used to drain every ready value in a channel until none are left — the moment the channel empties, the loop stops itself without waiting.

This use separates two different jobs select does. In the first section’s use, select was making a choice among more than one channel — deciding which source was ready. Here, select is used on a single channel, to ask whether that channel is ready; the presence of the default branch is the language’s way of saying “do not wait if it is not ready.” Both uses share the same syntax, but their purposes differ: one decides among more than one source, the other reads a single source’s instantaneous state.

// nonblockingcontrol.go — select is used to keep the flow moving without waiting on a channel that is not ready
package main

import "fmt"

func tryToReceive(channel chan int) (int, bool) {
	select {
	case v := <-channel:
		return v, true
	default:
		return 0, false
	}
}

func main() {
	results := make(chan int, 3)
	results <- 10
	results <- 20

	total, count := 0, 0
	for {
		v, ok := tryToReceive(results)
		if !ok {
			break
		}
		total += v
		count++
	}
	fmt.Println("values received without blocking from the channel:", count, "total:", total)

	_, ok := tryToReceive(results)
	fmt.Println("attempt once the channel is empty                :", ok)
}
values received without blocking from the channel: 2 total: 30
attempt once the channel is empty                : false

GC17. — Only two values were placed on the results channel; the loop drains these two with tryToReceive, and because ok returns false on the third attempt, the loop stops on its own — no goroutine was waited for, no order was left to the scheduler. This setup lets a channel be asked “what do you have for me right now”; if the channel itself is empty, the asking side can move on without waiting. This is the general form of the technique used to poll buffer capacity in the previous lesson: there, whether a single channel was full was being asked, here the same technique is used to drain a channel.

The loop’s stopping condition also stands out: the for loop does not stop based on a fixed number, it stops based on the second value tryToReceive returns. How many times it will loop cannot be read from the source code — no matter how many values were placed on the results channel, the same loop runs and stops at exactly that many steps. This differs from walking a slice with range: how many times range will loop can be read in advance from the slice’s length, while how many times this loop will run is only learned by running it. This difference becomes even clearer when compared against the range over channel behavior the closing lesson measures: range counts a channel’s closing as its stopping condition, while the loop here counts the channel’s current fullness as its stopping condition — both produce a finite loop, but based on a different signal.

This way of using it has a cost: tryToReceive does not wait for a goroutine to produce, it only takes whatever is ready at that moment. If nothing has been written to the channel yet, tryToReceive always returns false — even if the producing goroutine is about to send a value a microsecond later. A non-blocking control tool loses waiting in return; which one is needed, when select should be allowed to wait and when it should move on immediately, is a design decision.

This danger becomes even clearer with the wrong use of putting a default branch inside a loop: if the goal is to wait for a value to arrive, and the loop retries immediately every time it falls to default, the program waits without ever blocking, by keeping the processor continuously busy — this is select‘s counterpart of the busy waiting pattern set up in the context of locks and critical sections in the Operating System Concepts course. This lesson does not set up that pattern: tryToReceive is called once, not waited on in a loop. When waiting is actually needed, the right tool is a select with no default — then select itself blocks until a branch is ready and does not keep the processor busy; this would be what the first section’s choose function looks like written without a default branch.

Summary

  • select deterministically chooses the one branch that is ready, among more than one channel operation, only when exactly one branch is ready.
  • The branches’ order in the source carries no priority; whichever branch is ready gets chosen no matter what order it was written in.
  • When no branch is ready and there is a default branch, it runs immediately; select turns this way into a polling and control tool that never waits.
  • When two branches are ready at the same time, selection is uniformly random; this is a deliberate exception to the determinism rule this course measures, and it is therefore not run, only modeled.
  • select+default can be used to drain a channel of whatever values it holds, with no goroutine waited for, until none are left.
  • A select with no default blocks when no branch is ready and does not keep the processor busy; this is the right tool when waiting is actually needed, not a loop with default.

Next Step

This lesson never touched on how a closed channel behaves inside select. A receive from a closed channel makes a select branch ready right away, exactly like a receive from a full channel — but the answer that comes back is not the measured value itself, it is the news that the channel will now stay empty. The next lesson measures this last behavior: closing a channel is a termination signal, and it measures how that signal shows itself in both range and a direct receive.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close