Right, so you’ve met select and you’ve met for. Individually, they’re useful. Together, they form the bedrock of most concurrent Go programs you’ll ever write. This is the pattern you use when you need to manage multiple communication operations—waiting on several channels at once without knowing which will be ready first. It’s the event loop for the pragmatic gopher.

The basic idea is devilishly simple: you wrap a select statement inside an infinite for loop. This lets you continuously listen for events—messages coming in, signals to shut down, timers expiring—and handle them as they arrive, all within a single goroutine.

for {
    select {
    case msg := <-messagesCh:
        fmt.Printf("Received message: %s\n", msg)
    case sig := <-signalsCh:
        fmt.Printf("Received signal: %v\n", sig)
        return // Exit the loop and goroutine
    case <-time.After(30 * time.Second):
        fmt.Println("Nothing received for 30 seconds. Timing out.")
        return
    }
}

This is the workhorse. Your program just sits in this loop, reacting to things, until something tells it to stop. It’s elegant because it’s declarative: you state what you’re waiting for, and the runtime handles the messy business of actually waiting efficiently.

The Non-Blocking Default Case

Here’s the first thing everyone gets wrong. What happens if you run that loop and no channels have any activity? The select statement blocks forever, right? Well, yes, that’s the default behavior. But sometimes you need to do something while you’re waiting. Enter the default case.

for {
    select {
    case msg := <-messagesCh:
        processMessage(msg)
    default:
        // This runs immediately if no channel is ready
        fmt.Println("No messages. Doing some other work.")
        time.Sleep(100 * time.Millisecond) // Simulating work
    }
}

The default case makes the select non-blocking. If no channel in the other cases is ready to communicate, the default case fires immediately. This is how you create so-called “busy loops” or perform background tasks within the loop. Use it with extreme caution; a tight loop with a default case can absolutely hammer your CPU. That time.Sleep isn’t just for show—it’s a vital throttle.

The Everlasting Goroutine and How to Kill It

Notice that return in the first example? That’s crucial. An infinite for loop inside a goroutine will run, well, infinitely. If you fire this off as a goroutine and forget about it, you’ve got a leak. You must provide a clear path out. The canonical way to do this is with a dedicated channel, often named something like doneCh or quitCh.

func startEventLoop(messagesCh <-chan string, quitCh <-chan struct{}) {
    for {
        select {
        case msg := <-messagesCh:
            handle(msg)
        case <-quitCh:
            fmt.Println("Shutting down event loop.")
            return // Clean exit
        }
    }
}

// Elsewhere in your code, to stop the loop:
// close(quitCh) or quitCh <- struct{}{}

You signal on the quitCh, the select picks it up, and the goroutine exits cleanly. This is infinitely better than trying to use some external variable and a mutex—channels are the right abstraction for signaling between goroutines.

The Subtle Peril of the for-select Scope

Here’s a classic “gotcha” that will bite you eventually. Look at this code and tell me what’s wrong.

for {
    select {
    case msg := <-messagesCh:
        go func() {
            fmt.Printf("Handling message: %s\n", msg) // 😬
        }()
    }
}

See it? We’re launching a goroutine inside the case that uses the loop variable msg. By the time that new goroutine runs, the loop has likely already iterated, and the value of msg has changed. You’re printing the next message, or a race condition. It’s a nightmare. The fix is to pass the value as a parameter to the closure, creating a new lexical scope for each iteration.

for {
    select {
    case msg := <-messagesCh:
        go func(m string) { // 'm' is a copy for *this* goroutine
            fmt.Printf("Handling message: %s\n", m)
        }(msg) // Pass the current value of msg here
    }
}

This is such a common mistake that linters like staticcheck will actually catch it for you. Thank the gods for linters.

Why This Pattern is Everywhere

You see this for-select pattern in everything from network servers handling thousands of connections to worker pools processing jobs. It’s robust, it’s understandable, and it leverages Go’s primitives perfectly. It allows you to write concurrent code that is composable. You can have a goroutine that’s listening for work, a shutdown signal, and a health check timer, all in one clear block of code. It’s the opposite of the callback hell you find in other languages. You’re not nesting logic; you’re stating the conditions under which your goroutine should act. It’s a small shift in thinking, but it makes all the difference in writing concurrent code that doesn’t make you want to pull your hair out.