JavaRush /Courses /Go SELF /select: channel multiplexing and the default branch

select: channel multiplexing and the default branch

Go SELF
Level 67, Lesson 0
Available

1. Why you need select: wait for one of several

If you have written concurrent code before, you might have thought something like: “I need to wait for a value from a channel—okay, I write <-ch.” And that is indeed a working approach… as long as you have one channel and one scenario. But a real program usually lives in an “or” world: either a new task arrives, or we need to stop, or a “stop now” signal arrives, or there is space in a channel to send, or some component has already closed the input.

select is a Go language construct that lets you say: “I am ready to do one of several actions with channels: read from here, send there, and if nothing is ready—either I will wait, or I will do something else.” It is like a person at a reception desk: they cannot answer the phone and greet a visitor at the same time, but they can choose what to do depending on who actually shows up right now.

From an architecture perspective, select is the foundation of an event loop: one control flow makes decisions based on which events are already available. That is exactly the mental model we will build.

2. select syntax and the “ready case”

select looks similar to switch, but its meaning is different: switch chooses a branch by the value of an expression, while select chooses a branch by the readiness of a channel operation. Inside select, each case is either a receive (<-ch) or a send (ch <- v). If the operation cannot be performed right now, that case is considered “not ready.”

Minimal example: we wait to see who sends a message first—the a channel or the b channel.

package main

func main() {
	select {
	case <-make(chan int):
		// we will almost never get here (nobody writes to the channel)
	case <-make(chan int):
		// and here either
	}
}

This example is intentionally “useless”: neither channel is serviced, so select will simply block forever. But it shows the main rule: if no case is ready, select blocks (if there is no default).

To understand “readiness,” it helps to keep a simple table in mind.

Operation Unbuffered channel (make(chan T)) Buffered channel (make(chan T, n))
Receive: v := <-ch ready if someone is already sending or the channel is closed ready if the buffer contains at least 1 element or the channel is closed
Send: ch <- v ready if someone is already waiting to receive ready if there is free space in the buffer

And here is an important detail: a closed channel makes a receive “ready” (it does not block), but it gives you ok == false. That is, “ready” does not always mean “we received useful data.”

Why case order does not give priority

It is very tempting (especially after if/else) to think: “Well, I wrote the most important case at the top, so it will have priority.” But… no. And that is not a bug—it is an important part of the design.

The rule is: if exactly one case is ready, that one runs. If several are ready, Go picks one of them non-deterministically (in practice it looks like pseudorandomness). This is done so there is no hidden “priority” based on line order in code; otherwise concurrent programs would turn into a minefield of non-obvious starvation bugs.

Let’s show this with a small and honest example: two buffered channels already contain values, so both case branches are ready.

package main

import "fmt"

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

	a <- "A"
	b <- "B"

	select {
	case v := <-a:
		fmt.Println(v) // sometimes A
	case v := <-b:
		fmt.Println(v) // sometimes B
	}
}

If you run this several times, you will see different output. And that is normal. The moral is simple: you cannot build program correctness on the assumption that select “checks the top case first”.

If you need priority, you make it explicit (for example, with separate logic, separate channels, or a protocol), not with “magic line order.”

3. default: a non-blocking attempt

default in select is not a “just in case” branch like in switch. In select, default means: “If right now we cannot execute any case, do not wait; execute this instead.”

So default turns select from “wait for an event” into “try to do something, if you get lucky.”

Non-blocking receive from a channel:

package main

import "fmt"

func main() {
	ch := make(chan int)

	select {
	case v := <-ch:
		fmt.Println("got:", v)
	default:
		fmt.Println("nothing yet") // nothing yet
	}
}

Non-blocking send is especially popular in “first result wins” situations: we want to send a result, but if someone already managed to send earlier—we do not want to hang. This pattern appears directly in classic Go concurrency examples: the send is done via select { case ch <- v: default: }, so losing goroutines do not get stuck forever.

package main

func trySend(ch chan<- int, v int) bool {
	select {
	case ch <- v:
		return true
	default:
		return false
	}
}

Here it is important to say out loud the philosophy: default is permission to lose an operation (or defer it), because we consciously decided “not to wait.” If losing is unacceptable, default is not your tool.

A small historical fact: in very early versions of Go there were separate “boolean” forms of non-blocking channel operations, but they were removed because select solves the problem universally and makes the language simpler.

4. Mini app: the TaskBox event loop using select

Now we will assemble a small part of our learning app. Suppose throughout the course we are building a console program TaskBox: it accepts commands, adds tasks, and sometimes writes “service messages” to a log channel. Today our goal is not “to build a perfect task tracker,” but to show how select becomes the heart of the program: one loop listens for events and reacts.

Let’s draw a diagram so it feels like “who talks to whom”:

flowchart LR
    Input[goroutine: reads commands] -->|Task| addCh
    Loop[event loop: select] -->|log msg| logCh
    Logger[goroutine: prints the log] --> Out[(stdout)]
    addCh --> Loop
    logCh --> Logger

Types and channels: what will be an “event”

We will represent a task as a struct with a title. The addCh channel will deliver new tasks into the event loop. The logCh channel will accept log messages (and we will deliberately make logging non-blocking so we do not hang on a slow logger).

package main

type Task struct {
	Title string
}

Channels in main:

package main

func main() {
	addCh := make(chan Task)      // new tasks
	logCh := make(chan string, 3) // small buffer for logs
	_, _ = addCh, logCh
}

Note: logCh is buffered. It is a small “safety cushion” so brief log spikes do not block the system.

Event loop: one for + one select

Now the heart: a loop that lives “while the program runs” and chooses what to do when an event arrives.

package main

import "fmt"

func eventLoop(addCh <-chan Task, logCh chan<- string) {
	tasks := make([]Task, 0)

	for {
		select {
		case t := <-addCh:
			tasks = append(tasks, t)
			fmt.Println("added:", t.Title) // added: milk
			tryLog(logCh, "task added: "+t.Title)
		}
	}
}

There is an important idea about data ownership here: tasks lives inside the event loop. Only the event loop mutates it. That means we do not need a Mutex or complicated guarantees—we simply “serialize” changes through a single stream of event processing.

Non-blocking logging: write if you can

Logging is an excellent candidate for default. Often logging is “nice to have,” but if it suddenly starts slowing down business logic—you will learn new words (usually not printable).

Let’s implement tryLog:

package main

func tryLog(logCh chan<- string, msg string) {
	select {
	case logCh <- msg:
		// wrote to the log
	default:
		// buffer is full — skip
	}
}

This pattern is very similar to the classic Go concurrency trick “do not block on send if the result is no longer needed.”

Yes, we “lose” the log line. But we lose it by contract: better to lose the line “task added” than to freeze the entire program.

Logger: reads logCh and prints

Let’s make a separate logger goroutine. It will read the channel and print while the channel is not closed. (We will polish closing and shutdown protocols in detail in the lecture about goroutine leaks; today we are just showing the basic mechanics.)

package main

import "fmt"

func logger(logCh <-chan string) {
	for msg := range logCh {
		fmt.Println("LOG:", msg) // LOG: task added: milk
	}
}

5. Empty channel vs. closed channel: learn to tell the difference

When you do a non-blocking receive via default, your brain quickly starts thinking: “If I did not read—then there is nothing there.” And that is only half true. There might be “nothing yet,” or there might be “nothing ever again,” because the channel was closed.

Therefore any code that reads from a channel and needs to terminate correctly should, at the right moment, use the form v, ok := <-ch.

Let’s show a mini snippet: the event loop reads addCh and can exit when addCh is closed.

package main

import "fmt"

func eventLoop(addCh <-chan Task) {
	for {
		select {
		case t, ok := <-addCh:
			if !ok {
				fmt.Println("addCh closed") // addCh closed
				return
			}
			fmt.Println("added:", t.Title)
		}
	}
}

Here it is important to feel: “readiness of a case” and “usefulness of the data” are different things. A case t := <-addCh can run even on a closed channel; t will just be the zero value, and ok will tell the truth.

6. default in a loop: the busy loop risk

default feels like a lifesaver: “Oh, I am not blocking; I can do something else.” And that is true. But there is a trap almost every beginner falls into: they write for { select { default: } }—and accidentally create a small perpetual motion machine that turns the CPU into a heater.

A busy loop looks like this: the loop spins millions of times per second because default executes instantly, and there is nothing in the loop body that actually waits for an event.

package main

func main() {
	for {
		select {
		default:
			// no blocking cases -> the loop "burns" CPU
		}
	}
}

Why is this bad? Because you are not “checking sometimes,” you are checking constantly. Even if the Go runtime can preempt goroutines (and overall has improved over the years), your code will still burn CPU for no benefit, and on a real server that turns into “why is our hardware bill like we are building a spaceship?” By the way, in historical Go releases the scheduler was improved specifically because “busy goroutines” can interfere with others.

How to think correctly: if you need to wait for an event, your select must contain at least one blocking case (for example, receiving from a channel). You add default not instead of waiting, but to make a “quick attempt” when waiting is not required. If it feels like default is needed “so the program does not hang,” the issue is usually in the channel protocol design, not in the absence of default.

7. Common mistakes when working with select

Mistake #1: building logic around case order.
Branch order in select visually looks like “higher means more important,” and your hand naturally reaches to make the top case “priority.” But if several branches are ready, the choice is non-deterministic, and the program will start behaving differently depending on the scheduler, load, and the phase of the moon. The correct approach is to treat select as a fair lottery and design the protocol so that any choice remains correct.

Mistake #2: using default as “wait a little.”
default does not wait even a millisecond. It means “do not wait at all.” That is why code like “if there is no data, I will go into default and do something useful” in a loop often turns into a busy loop. If you truly want to wait for data—default is not needed. If you want to sometimes do side work—you need to structure the loop so it has real event waiting, while default is a rare branch, not the main route.

Mistake #3: non-blocking send silently drops data, and nobody noticed.
select { case ch <- v: default: } is a powerful tool, but it essentially says: “if sending did not work—oh well.” This is acceptable for logs, metrics, “first result wins,” or similar scenarios where loss is allowed by contract. But if you send real data this way (for example, tasks to process), you will get mysterious “missing items” and suspect everything except your own default.

Mistake #4: confusing “channel is empty” with “channel is closed.”
When receiving in select without checking ok, you can accidentally treat the zero value as normal data, especially if the channel type is int or string. As a result, closing the channel turns into a stream of empty values, and your logic goes off the road. The cure is discipline: when it is important to terminate correctly, read t, ok := <-ch and respect ok == false.

Mistake #5: using select as a “crutch against hangs” instead of agreeing on a protocol.
If you are afraid that receive or send might hang, that often means participants do not have a clear protocol: who closes the channel, who must read results, what termination means. select is not magic; it does not remove the need for agreements. It just makes choosing between events convenient. Real reliability appears when you can answer in advance: “which event guarantees exit from each goroutine.”

1
Task
Go SELF, level 67, lesson 0
Locked
Two payments
Two payments
1
Task
Go SELF, level 67, lesson 0
Locked
Quick sensor
Quick sensor
1
Task
Go SELF, level 67, lesson 0
Locked
Sum Until Stop
Sum Until Stop
1
Task
Go SELF, level 67, lesson 0
Locked
Logs without blocking
Logs without blocking
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION