16.1 select: Waiting on Multiple Channel Operations
Right, so you’ve got goroutines firing off left and right, channels shuttling data all over the place. It’s beautiful chaos. But what happens when you need to listen to more than one channel at a time? You can’t just sit on a single <-ch receive operation; that’s like trying to listen to two conversations by putting your ear to one person’s mouth. You need a better tool. You need select.
Think of select as your channel multiplexer. It lets a single goroutine wait on multiple channel operations simultaneously, proceeding with the first one that becomes ready. If none are ready, it blocks until one is. If multiple are ready at the same instant, it picks one at random. This last part is crucial and trips everyone up, so we’ll come back to it.
The syntax looks suspiciously like a switch statement that got a job at the post office, handling communications.
select {
case msg := <-messagesCh:
fmt.Printf("Received message: %s\n", msg)
case sig := <-signalsCh:
fmt.Printf("Received signal: %v\n", sig)
}
The Random Selection Fairness
I told you I’d come back to this. When multiple case statements have channels with data ready to be received (or are ready to be sent to), the select statement does not prioritize the first one you wrote. It chooses one pseudo-randomly. The Go runtime does this to prevent starvation. If it always chose the first ready case, you could theoretically have a heavily loaded channel at the top of your select block preventing any other case from ever being chosen. This randomness is a feature, not a bug, but it means you cannot rely on any implicit ordering.
The Default Case: For When You’re Impatient
What if you don’t want to block at all? What if you just want to check if a channel operation is ready and, if not, immediately get on with your life? That’s what the default case is for.
func tryReceive(ch <-chan string) (string, bool) {
select {
case msg := <-ch:
return msg, true
default: // This runs immediately if no other case is ready
return "", false
}
}
This is the fundamental building block for non-blocking channel operations. It’s how you build things like “receive if you can, but don’t wait.” Use it wisely, because polling a channel in a tight loop with default is a fantastic way to waste CPU cycles. If you need to do that, you should probably be using a select without a default and let it block efficiently instead.
Handling Timeouts Gracefully (Without a Library)
Before context was everywhere, this is how we did timeouts. And honestly, for simple cases, it’s still perfectly elegant. The time.After function returns a channel that receives a value after the specified duration. Wrapping a channel operation in a timeout is a classic select pattern.
select {
case result := <-databaseCh:
fmt.Println("Query result:", result)
case <-time.After(2 * time.Second):
fmt.Println("Query timed out. Moving on.")
}
A critical gotcha here: the channel returned by time.After isn’t closed when the timeout fires; it’s sent a single time.Time value. More importantly, the timer isn’t garbage collected until the channel does fire. So if you’re doing this in a long-running loop, you’re creating a new timer object every iteration. For those scenarios, use time.NewTimer and properly Stop it to avoid resource leaks.
The Empty Select Statement
Here’s a wonderfully absurd one for you. What does this do?
select {}
It blocks forever. It’s a select statement with zero cases. Since there’s nothing to wait on, it waits forever. It’s the concurrency equivalent of staring into the void. You’ll see it in some example code, but in production, you’d more likely use something like <-make(chan struct{}) for the same effect, as it’s slightly more explicit about its intent.
Send and Receive Together
It’s not just about receiving. You can wait on send operations too. This is incredibly useful for preventing a goroutine from blocking indefinitely trying to send to a channel that no one is listening to.
select {
case messagesCh <- "hello":
fmt.Println("Message sent successfully.")
case <-time.After(1 * time.Second):
fmt.Println("Failed to send message; no one was listening.")
}
The Only Practical Use for a nil Channel
Here’s a pro-tip: you can use nil channels in your select statements. A receive or send on a nil channel blocks forever. This sounds useless until you realize it’s a perfect way to disable a case in a select loop dynamically.
var dataCh <-chan Data // Initially nil
var timerCh <-chan time.Time
for {
select {
case data := <-dataCh: // disabled if dataCh is nil
handleData(data)
case <-timerCh: // disabled if timerCh is nil
doTimeout()
}
}
By setting dataCh to nil when you want to stop receiving, and timerCh to an actual channel when you need a timeout, you can dynamically reconfigure the behavior of your select loop on the fly. It’s a pattern that feels a bit clever, but it’s undeniably effective.