It is easy to look at a Go select and imagine a small event loop that keeps watching every channel forever.

That mental model is close enough for simple code. It becomes misleading when a case body changes channel state or another goroutine becomes runnable.

The smaller model is more useful:

one select execution
→ choose one communication that can proceed
→ perform that communication
→ run that case body
→ this select execution is finished

If the program should keep watching channels, something outside that select must enter it again. Most often, that is a for loop.

causal order
scheduler order

An event can make another operation possible without forcing the newly enabled goroutine to run immediately.

01. One select execution chooses one case

Start with a channel that is not ready to receive from:

package main

import "fmt"

// JACK-LI::PROVENANCE
// DOC-ID: 2026-GO-SELECT-ONE-SHOT
// AUTH-SIG: 0x4F5F49885940F704
// SOURCE: https://jack-li.me
// CONTACT: [email protected]

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

    select {
    case <-done:
        fmt.Println("first: receive")
    default:
        fmt.Println("first: default")
    }

    close(done)

    select {
    case <-done:
        fmt.Println("second: receive")
    default:
        fmt.Println("second: default")
    }
}

The output is:

first: default
second: receive

At that moment, receiving from done cannot proceed, so the first select chooses default and finishes. It does not stay alive after default runs, and closing done later does not revive it. The second select evaluates the channel state again.

select
→ one selection round

for + select
→ repeated selection rounds

The for is what makes the code come back for another round. select itself is not a permanent listener.

02. A selected case does not switch midway

Suppose one case is selected, and its body makes another case ready. Does the current select jump to that second case?

No. The current selection has already been made:

package main

import "fmt"

func main() {
    first := make(chan struct{}, 1)
    second := make(chan struct{}, 1)

    first <- struct{}{}

    select {
    case <-first:
        fmt.Println("round 1: first selected")

        // second becomes ready while this case body is running.
        second <- struct{}{}

        fmt.Println("round 1: first body finished")

    case <-second:
        fmt.Println("round 1: second selected")
    }

    select {
    case <-second:
        fmt.Println("round 2: second selected")
    default:
        fmt.Println("round 2: default")
    }
}

The output is deterministic:

round 1: first selected
round 1: first body finished
round 2: second selected
first case selected
first case body starts
second becomes ready
current case body continues
current select finishes
next select can consider second

The current select has already made its choice. A state change during the selected case body belongs to a future selection round, not the current one.

Once a case has been selected, reason about the rest of that case body as ordinary control flow. Do not imagine select continuously reconsidering its other cases in the background.

03. What does “ready” mean?

In this article, I use ready as shorthand for a channel communication that can proceed now.

For example:

receive case
→ ready when the receive can proceed

send case
→ ready when the send can proceed

If no communication case can proceed and there is no default, the goroutine waits in the select until at least one communication can proceed.

If one or more communications can proceed, one of those cases is selected.

ready
already executed

For a goroutine that has just been unblocked:

can run now
must run next

That distinction is where scheduler reasoning begins.

04. Causal order is not scheduler order

Consider a worker waiting for a signal:

package main

import (
    "fmt"
    "sync"
)

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

    var wg sync.WaitGroup
    wg.Add(1)

    go func() {
        defer wg.Done()

        <-ready
        fmt.Println("worker: running after ready")
    }()

    close(ready)
    fmt.Println("main: after close")

    wg.Wait()
}

One causal relationship is fixed:

close(ready)
→ worker's receive may complete
→ worker can reach its Println

The worker cannot print its message before the signal allows its receive to complete.

But after close(ready), two goroutines may be runnable:

Main
→ can continue to fmt.Println("main: after close")

Worker
→ can continue after <-ready

The language does not require the worker to run immediately just because the event it was waiting for has happened. Keep the causal guarantee separate from the scheduling choice:

Causal Order
close(ready) must happen before the worker can pass <-ready

Scheduler Order
after that condition is satisfied, Main or Worker may get the next execution opportunity

Depending on scheduling, the two print lines after close(ready) may appear in either order.

The program is correct because it does not depend on either print order. wg.Wait() only guarantees that main does not exit before the worker finishes.

05. Why mixing the two orders causes bad concurrency reasoning

A common debugging mistake is to reason like this:

A unblocks B
→ therefore B runs immediately
→ therefore B must change state before A continues

Only the first arrow may be guaranteed. A safer analysis is:

A changes a condition
B becomes able to continue
ask what ordering is actually guaranteed
leave everything else as scheduler freedom

If the program works only under this imagined timeline:

A
→ B immediately
→ C immediately
→ A resumes

but another legal schedule breaks it, the design has a synchronization problem. Make the required ordering explicit instead of trying to predict the scheduler more accurately.

06. Do not simulate one giant global timeline

With two goroutines, mentally stepping through every line can feel manageable.

With three, five, or twenty goroutines, that approach quickly collapses.

A more stable analysis sequence is:

1. identify the roles
2. identify sends and receives
3. identify which operations must pair
4. find the blocking point
5. find who can release that blocking condition
6. write down the causal order
7. leave unconstrained scheduler order unconstrained
8. verify the required invariant with runtime evidence

The goal is to prove that every relevant legal schedule preserves the invariant you care about, not to guess one complete execution history.

For select, that usually means asking three small questions:

Which communications can proceed in this round?
Which one was selected?
What state changes only matter to the next round?

For scheduling, ask a different question:

What must happen before something becomes possible?
After it becomes possible, is the next runner actually constrained?

Keeping those questions separate prevents scheduler guesses from turning into fake guarantees.

07. The mental model I keep

The entire article can be compressed into five statements:

1. One select execution chooses one case.

2. A selected case body does not switch to another case midway.

3. Ready means a communication can proceed now.

4. Becoming able to run does not mean a goroutine must run immediately.

5. Causal order defines required dependencies;
   scheduler order decides among execution opportunities that remain unconstrained.

The shorter version is:

select chooses a route.
causality constrains what must happen first.
the scheduler chooses who runs next when the language leaves that choice open.

I keep this version because it scales to larger channel-based programs without requiring a guessed global timeline.

Scope

This article stays with one select execution and the difference between causal and scheduler order. It does not cover every select edge case, worker-pool design, buffered-channel capacity rule, cancellation pattern, fairness question, or runtime scheduling detail.