Go’s select statement lets you wait on multiple channel operations simultaneously. Combining goroutines and channels with select is one of Go’s most powerful features for concurrent programming.
Basic Select
package main
import (
"fmt"
"time"
)
func main() {
// Create two channels
c1 := make(chan string)
c2 := make(chan string)
// Send to c1 after 1 second
go func() {
time.Sleep(1 * time.Second)
c1 <- "one"
}()
// Send to c2 after 2 seconds
go func() {
time.Sleep(2 * time.Second)
c2 <- "two"
}()
// Select waits for both values
for range 2 {
select {
case msg1 := <-c1:
fmt.Println("received", msg1)
case msg2 := <-c2:
fmt.Println("received", msg2)
}
}
}
select blocks until one of its cases can proceed, then executes that case. If multiple cases are ready, it chooses one at random.
How Select Works
- Evaluate all cases - Check which channels are ready
- Random selection - If multiple cases ready, pick one randomly
- Execute case - Run the selected case
- Block if none ready - Wait until at least one case can proceed
Select Cases
Receive Cases
select {
case msg := <-ch:
fmt.Println(msg)
case msg := <-anotherCh:
fmt.Println(msg)
}
Send Cases
select {
case ch <- value1:
fmt.Println("sent value1")
case ch <- value2:
fmt.Println("sent value2")
}
Mixed Send and Receive
select {
case msg := <-input:
process(msg)
case output <- result:
fmt.Println("sent result")
}
Default Case (Non-Blocking)
Add a default case to make select non-blocking:
select {
case msg := <-messages:
fmt.Println("received", msg)
default:
fmt.Println("no message received")
}
The default case executes immediately if no other case is ready, making the select non-blocking.
Common Patterns
1. Timeout Pattern
select {
case result := <-ch:
fmt.Println("got result:", result)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
2. Non-Blocking Receive
select {
case msg := <-messages:
fmt.Println("received", msg)
default:
fmt.Println("no message")
}
3. Non-Blocking Send
select {
case messages <- "hello":
fmt.Println("sent")
default:
fmt.Println("no receiver")
}
4. Shutdown Signal
for {
select {
case <-shutdown:
return
case work := <-jobs:
process(work)
}
}
5. First Response Wins
func first(servers []string) string {
results := make(chan string, len(servers))
for _, server := range servers {
go func(s string) {
results <- query(s)
}(server)
}
return <-results // Return first response
}
Random Selection
When multiple cases are ready, Go randomly selects one:
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "one"
ch2 <- "two"
select {
case msg := <-ch1:
fmt.Println(msg) // Might print "one"
case msg := <-ch2:
fmt.Println(msg) // Might print "two"
}
// Random which case executes
Do not rely on any particular ordering. Select is intentionally non-deterministic when multiple cases are ready.
Select in Loops
Processing Until Done
for {
select {
case msg := <-messages:
if msg == "quit" {
return
}
process(msg)
case <-time.After(1 * time.Second):
fmt.Println("waiting...")
}
}
Worker with Timeout
for {
select {
case job := <-jobs:
process(job)
case <-time.After(5 * time.Minute):
fmt.Println("idle timeout, exiting")
return
}
}
Practical Example: Multi-Source Aggregator
func aggregate(sources ...<-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
active := len(sources)
for active > 0 {
select {
case msg, ok := <-sources[0]:
if !ok {
sources = sources[1:]
active--
} else {
out <- msg
}
case msg, ok := <-sources[1]:
if !ok {
sources = sources[:1]
active--
} else {
out <- msg
}
}
}
}()
return out
}
Empty Select
A select with no cases blocks forever:
select {} // Blocks forever
Useful as a “stay alive” pattern for programs that should never exit, like servers with goroutines handling all work.
Fast Path with Default
// Try non-blocking first
select {
case results <- result:
// Sent immediately
default:
// Receiver not ready, handle accordingly
go func() {
results <- result // Send in background
}()
}
Avoid Busy Waiting
// BAD: Busy loop
for {
select {
case msg := <-ch:
process(msg)
default:
// Spins constantly!
}
}
// GOOD: Block when no work
for msg := range ch {
process(msg)
}
Select Statement Rules
- All channel operations are evaluated before selection
- Random selection when multiple cases ready
- Default executes immediately if no other case ready
- Blocks without default until a case can proceed
- nil channels are never ready and are effectively ignored
Nil Channel Trick
var ch1, ch2 chan string
ch1 = make(chan string) // Only ch1 is ready
select {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2: // nil channel - never ready
fmt.Println("from ch2:", msg)
}
Set a channel to nil to disable a case in a select without restructuring your code.
Common Pitfalls
Time.After in Loop
// BAD: Creates new timer every iteration
for {
select {
case msg := <-ch:
process(msg)
case <-time.After(1 * time.Second): // Leak!
fmt.Println("timeout")
}
}
// GOOD: Reuse timer
timer := time.NewTimer(1 * time.Second)
for {
select {
case msg := <-ch:
process(msg)
timer.Reset(1 * time.Second)
case <-timer.C:
fmt.Println("timeout")
}
}
Not Checking Channel Closed
// GOOD: Check if channel closed
select {
case msg, ok := <-ch:
if !ok {
return // Channel closed
}
process(msg)
}
Best Practices
- Use select for multiplexing - Multiple channels in one operation
- Add timeouts for robustness - Prevent indefinite blocking
- Use default sparingly - Only when non-blocking is required
- Check channel closure - Use two-value receive form
- Avoid time.After in loops - Creates timer leaks
- Use nil to disable cases - Clean way to handle dynamic channels