Skip to main content

select Statement — Waiting on Multiple Channels

The select statement lets you wait on multiple channel operations simultaneously. It looks similar to switch, but each case is a channel operation.

select Basic Syntax

package main

import (
"fmt"
"time"
)

func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
time.Sleep(1 * time.Second)
ch1 <- "message from ch1"
}()

go func() {
time.Sleep(2 * time.Second)
ch2 <- "message from ch2"
}()

// Handle whichever channel is ready first
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
}
}
// Output:
// ch1: message from ch1 (after 1 second)
// ch2: message from ch2 (1 more second later)

select behavior rules:

  1. Multiple cases ready simultaneously— one is chosen at random
  2. No cases ready— blocks and waits
  3. default case present— executes immediately without blocking

Timeout Pattern

Use time.After to set a time limit on network requests or external operations.

package main

import (
"fmt"
"time"
)

func slowOperation() <-chan string {
ch := make(chan string)
go func() {
time.Sleep(3 * time.Second) // Simulate slow work
ch <- "done"
}()
return ch
}

func main() {
result := slowOperation()

select {
case res := <-result:
fmt.Println("Result:", res)
case <-time.After(2 * time.Second): // 2-second timeout
fmt.Println("Timeout! Operation took too long.")
}
}

time.After(d) returns a channel that sends a value after the specified duration. Very convenient for implementing timeouts.

default — Non-blocking Receive/Send

Adding a default case makes select return immediately without blocking.

package main

import "fmt"

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

// Non-blocking send
select {
case ch <- 42:
fmt.Println("Send succeeded")
default:
fmt.Println("Channel full, send failed")
}

// Non-blocking receive
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("Channel is empty")
}

// Second receive attempt (already empty)
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("Channel is empty")
}
}
// Output:
// Send succeeded
// Received: 42
// Channel is empty

done Channel Pattern — Goroutine Termination Signal

Send a termination signal to goroutines via a done channel. One of the most commonly used patterns in Go.

package main

import (
"fmt"
"time"
)

func worker(done <-chan struct{}) {
for {
select {
case <-done:
fmt.Println("Worker exiting")
return
default:
// Perform actual work
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}

func main() {
done := make(chan struct{})

go worker(done)

time.Sleep(2 * time.Second)
close(done) // Send termination signal (close, not send)

time.Sleep(100 * time.Millisecond)
fmt.Println("Main exiting")
}

Using close(done) sends a signal to all goroutines waiting on that channel simultaneously.

Priority Processing Pattern

Use this when you need to separate high-priority tasks from regular ones.

package main

import (
"fmt"
)

func prioritySelect(high, low <-chan string) {
for {
// Check high-priority channel first
select {
case msg := <-high:
fmt.Println("[HIGH]", msg)
continue
default:
}

// If no high-priority, wait on both
select {
case msg := <-high:
fmt.Println("[HIGH]", msg)
case msg := <-low:
fmt.Println("[LOW]", msg)
}
}
}

func main() {
high := make(chan string, 5)
low := make(chan string, 5)

high <- "Urgent alert 1"
high <- "Urgent alert 2"
low <- "Regular task 1"
low <- "Regular task 2"
high <- "Urgent alert 3"

go prioritySelect(high, low)

var input string
fmt.Scanln(&input)
}

Real-World Example — Retry Logic with Timeout

package main

import (
"errors"
"fmt"
"math/rand"
"time"
)

// Simulate an occasionally failing external service
func callExternalService() <-chan string {
ch := make(chan string, 1)
go func() {
delay := time.Duration(rand.Intn(3)+1) * time.Second
time.Sleep(delay)
if rand.Intn(2) == 0 {
ch <- "success response"
}
// On failure, send nothing
}()
return ch
}

func callWithRetry(maxRetries int, timeout time.Duration) (string, error) {
for i := 0; i < maxRetries; i++ {
fmt.Printf("Attempt %d/%d...\n", i+1, maxRetries)
result := callExternalService()

select {
case res := <-result:
return res, nil
case <-time.After(timeout):
fmt.Println("Timeout, retrying")
continue
}
}
return "", errors.New("max retries exceeded")
}

func main() {
rand.Seed(time.Now().UnixNano())

result, err := callWithRetry(3, 1500*time.Millisecond)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}

Using nil Channels in select

Operations on a nil channel block forever. This can be used to dynamically disable a case in select.

package main

import "fmt"

func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)

go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // Set to nil when closed → disables this case
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
}
}()

return out
}

func main() {
ch1 := make(chan int, 3)
ch2 := make(chan int, 3)

ch1 <- 1
ch1 <- 3
ch1 <- 5
close(ch1)

ch2 <- 2
ch2 <- 4
ch2 <- 6
close(ch2)

for v := range merge(ch1, ch2) {
fmt.Printf("%d ", v)
}
fmt.Println()
}

Key Takeaways

  • select— handles whichever channel operation is ready (random selection on tie)
  • time.After— convenient timeout implementation
  • default— non-blocking channel operation
  • close(done)— send simultaneous termination signal to multiple goroutines
  • nil channel— permanently blocks that case → use for dynamic deactivation