Ein einzelner Channel-Receive (v := <-ch) blockiert, bis genau dieser Channel etwas zu sagen hat. Was aber, wenn eine Goroutine auf mehrere Channels gleichzeitig warten soll — auf eingehende Arbeit und auf ein Cancellation-Signal und auf einen Timeout? Genau dafür existiert select: das Channel-Multiplexing-Statement von Go. Es evaluiert alle seine Cases, wartet darauf, dass mindestens einer kommunizieren kann, und führt — bei mehreren gleichzeitig bereiten Cases — eine zufällige Auswahl aus. Dieser Artikel arbeitet die Spec-Semantik gründlich durch, zeigt das default-Verhalten, die kanonischen Timeout- und Cancellation-Muster sowie zwei größere Praxis-Beispiele für Worker und Fan-In-Merge.

Wofür select da ist — Channel-Multiplexing

Goroutinen kommunizieren über Channels, und in jeder nicht-trivialen Concurrency-Architektur hat eine Goroutine mit mehr als einem Channel zu tun. Ein HTTP-Handler wartet auf Datenbank-Antwort und auf Request-Cancellation. Ein Worker wartet auf neue Jobs und auf ein Shutdown-Signal. Ein Aggregator liest aus mehreren Producern gleichzeitig. Ein einfaches <-ch würde die Goroutine an einen einzigen Channel binden — alles andere wäre blockiert, bis dieser Channel feuert.

select löst dieses Problem auf der Sprach-Ebene. Es ist syntaktisch ein Geschwister des switch-Statements, semantisch aber etwas ganz Eigenes: kein Wert-Vergleich, sondern eine Wartemenge von Channel-Operationen, aus der die Laufzeit diejenige auswählt, die als Erste kommunizieren kann.

Go motivation.go
package main

import (
    "fmt"
    "time"
)

func main() {
    jobs := make(chan string)
    quit := make(chan struct{})

    go func() {
        time.Sleep(50 * time.Millisecond)
        jobs <- "Job-1"
        time.Sleep(50 * time.Millisecond)
        close(quit)
    }()

    for {
        select {
        case j := <-jobs:
            fmt.Println("verarbeite", j)
        case <-quit:
            fmt.Println("Shutdown")
            return
        }
    }
}
Output
verarbeite Job-1
Shutdown

Die Schleife horcht in jedem Durchgang gleichzeitig auf jobs und quit. Welcher Channel zuerst kommuniziert, bestimmt den Pfad. Ohne select müsstest du entweder pollen (CPU-Verschwendung) oder eine zweite Goroutine spawnen, die nur auf quit wartet — beide Wege sind schlechter als das eingebaute Multiplexing.

Spec-Syntax — SelectStmt, CommClause, Cases

Die Go-Spec definiert select mit einer kleinen, präzisen Grammatik. Wer die Cases aus der Spec einmal gelesen hat, versteht für immer, warum bestimmte Schreibweisen erlaubt sind und andere nicht:

SelectStmt = "select" "{" { CommClause } "}" .
CommClause = CommCase ":" StatementList .
CommCase   = "case" ( SendStmt | RecvStmt ) | "default" .
RecvStmt   = [ ExpressionList "=" | IdentifierList ":=" ] RecvExpr .
RecvExpr   = "<-" Expression .

Ein select-Block enthält eine Liste von CommClauses. Jede CommClause besteht aus genau einem CommCase (Send, Receive oder default) und einer dazugehörigen StatementList, die ausgeführt wird, wenn dieser Case gewählt wurde. Das case-Schlüsselwort darf — anders als beim switch — nur eine einzelne Kommunikations-Operation als Bedingung tragen, keinen Ausdruck und keinen Wert-Vergleich.

Go syntax-uebersicht.go
select {
case <-recvCh:            // Recv-Case, Wert verworfen
    // ...
case v := <-recvCh2:      // Recv-Case mit Bindung
    _ = v
case v, ok := <-recvCh3:  // Recv-Case mit comma-ok
    _, _ = v, ok
case sendCh <- 42:        // Send-Case
    // ...
default:                  // höchstens einmal pro select erlaubt
    // ...
}

Bemerkenswert: der Send selbst (sendCh <- 42) ist Teil des Case-Headers, nicht ein Statement im Block. Die Statement-Liste unter dem Case läuft erst, nachdem die Kommunikation stattgefunden hat. Das ist anders als bei einem normalen if-Block — und der häufigste Anfänger-Stolperer, dazu später mehr.

Wie der Multiplexer wählt — die Random-Choice-Regel

Die Auswertungs-Reihenfolge eines select ist in der Spec klar geregelt. Sie passiert in drei Phasen:

  1. Evaluation aller Channel-Ausdrücke und Sendewerte — in Quelltext-Reihenfolge, jedes genau einmal. Wenn dein Case case ch[i] <- f(): lautet, werden ch[i] und f() vor der Wartephase berechnet.
  2. Auswahl unter den bereiten Cases. Die Laufzeit prüft, welche der evaluierten Operationen gerade ohne Blockieren ausgeführt werden können. Sind mehrere bereit, wird einer pseudo-zufällig gewählt. Sind keine bereit und kein default vorhanden, blockiert das select, bis ein Case bereit wird.
  3. Ausführung des Case-Bodys — erst die Kommunikation (Send oder Receive), dann die Statement-Liste unter dem Case.

Die Spec formuliert die Auswahl-Regel knapp: „If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection." Diese Zufälligkeit ist kein Implementierungsdetail, sondern Sprach-Garantie. Sie verhindert, dass dein Code sich auf eine Reihenfolge verlässt, die der Compiler oder Scheduler nicht garantieren.

Go random-choice.go
package main

import "fmt"

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

    // Beide Channels sind bereits sendebereit:
    a <- 1
    b <- 2

    counts := map[string]int{"a": 0, "b": 0}
    for i := 0; i < 10000; i++ {
        // Nach jeder Runde Channel neu befüllen, damit beide bereit bleiben.
        select {
        case <-a:
            counts["a"]++
            a <- 1
        case <-b:
            counts["b"]++
            b <- 2
        }
    }
    fmt.Printf("a=%d b=%d (ca. 50/50 erwartet)\n", counts["a"], counts["b"])
}
Output
a=4983 b=5017 (ca. 50/50 erwartet)

Konsequenz für die Praxis: verlasse dich niemals auf eine bestimmte Reihenfolge der Case-Auswahl. Wenn du Priorität brauchst (erst quit, dann jobs), baust du das explizit über ein verschachteltes select mit default — siehe Abschnitt 06.

Recv-Case mit comma-ok — aktiver Send vs. geschlossen

Der Recv-Case unterstützt dieselbe comma-ok-Syntax wie ein normaler Channel-Receive: case v, ok := <-ch: liefert in ok die Information, ob der Wert von einem echten Send stammt (ok == true) oder ob der Channel geschlossen ist (ok == false, v == zero(T)). Diese Unterscheidung ist im select-Kontext lebenswichtig, weil ein geschlossener Channel im Recv-Case dauerhaft bereit ist — er liefert für immer Zero-Werte. Wer das nicht prüft, baut sich eine Busy-Loop.

Go comma-ok.go
package main

import (
    "fmt"
    "time"
)

func main() {
    data := make(chan int)
    tick := time.NewTicker(20 * time.Millisecond)
    defer tick.Stop()

    go func() {
        data <- 10
        data <- 20
        close(data)
    }()

    for {
        select {
        case v, ok := <-data:
            if !ok {
                fmt.Println("data-Channel geschlossen — Recv-Case deaktivieren")
                data = nil // nil-Trick: deaktiviert diesen Case dauerhaft
                continue
            }
            fmt.Println("Wert:", v)
        case t := <-tick.C:
            fmt.Println("Tick", t.Format("15:04:05.000"))
            if data == nil {
                return
            }
        }
    }
}
Output
Wert: 10
Wert: 20
data-Channel geschlossen — Recv-Case deaktivieren
Tick 12:00:00.020

Zwei Ideen kombiniert: comma-ok erkennt den geschlossenen Channel, und das Setzen data = nil deaktiviert den Case (siehe Abschnitt 11). Ohne diese Behandlung würde select in jedem Durchlauf sofort den geschlossenen data-Case wählen und nie zum tick-Case kommen — eine klassische, schwer zu debuggende Endlosschleife.

Send-Case — die Sende-Operation ist der Case

Anders als bei einem if oder switch ist die Sende-Operation ch <- v selbst der Case-Header, nicht ein Statement im Body. Das select wartet darauf, dass der Send durchgeht — also bis ein Empfänger bereit ist (bei ungepufferten Channels) oder Platz im Puffer ist (bei gepufferten Channels). Erst dann läuft die Statement-Liste unter dem Case.

Go send-case.go
package main

import (
    "fmt"
    "time"
)

func main() {
    out := make(chan int)   // ungepuffert
    done := make(chan struct{})

    // Consumer startet erst nach 50 ms — Send muss warten.
    go func() {
        time.Sleep(50 * time.Millisecond)
        fmt.Println("empfangen:", <-out)
        close(done)
    }()

    select {
    case out <- 42:
        fmt.Println("Send durchgegangen")
    case <-time.After(20 * time.Millisecond):
        fmt.Println("Send-Timeout — niemand hört zu")
        return
    }

    <-done
}
Output
Send-Timeout — niemand hört zu

Im Beispiel oben gewinnt der Timeout, weil der Consumer 50 ms wartet, der time.After-Channel aber schon nach 20 ms feuert. Die Statement-Liste fmt.Println("Send durchgegangen") wird nicht ausgeführt, weil der Send-Case nicht gewählt wurde. Damit kannst du Sender so bauen, dass sie nicht unbegrenzt auf einen verschwundenen Empfänger warten — ein üblicher Trick in Pipeline-Stages, die per Cancellation abbrechen können müssen.

default — non-blocking select

Mit einem default-Case wird select niemals blockieren. Sind beim Eintritt in das Statement keine Communication-Cases bereit, läuft sofort der default-Branch. Das ist die Mechanik für „versuche zu senden/empfangen, gib sonst auf" — ein Polling-Check ohne Wartezeit.

Go non-blocking-send.go
package main

import "fmt"

// trySend versucht, v auf ch zu senden, ohne zu blockieren.
// Liefert true bei Erfolg, false wenn der Puffer voll ist.
func trySend(ch chan<- int, v int) bool {
    select {
    case ch <- v:
        return true
    default:
        return false
    }
}

func main() {
    buf := make(chan int, 2) // Kapazität 2

    fmt.Println(trySend(buf, 1)) // true
    fmt.Println(trySend(buf, 2)) // true
    fmt.Println(trySend(buf, 3)) // false — Puffer voll
}
Output
true
true
false

Der Recv-Spiegel sieht analog aus: tryReceive prüft, ob ein Wert sofort verfügbar ist. Beide Patterns sind in Metrik- und Telemetrie-Code üblich, wo das Verwerfen eines Werts besser ist als das Blockieren des Hot-Paths.

default lässt sich außerdem zum Prioritäts-Select zweckentfremden. Standard-select würde bei zwei bereiten Cases zufällig wählen; wer einen Channel bevorzugen will, prüft ihn in einer ersten Stufe mit default:

Go prio-select.go
// priorisiert quit über jobs: quit wird zuerst geprüft, jobs nur,
// wenn quit nichts hat.
select {
case <-quit:
    return
default:
}

select {
case <-quit:
    return
case j := <-jobs:
    handle(j)
}

Achtung: Diese „Priorität" ist best effort. Zwischen den beiden select-Statements kann ein Job durchschlüpfen, bevor das zweite select quit erneut prüft. Für strikte Priorität sind andere Architekturen besser (eigener Watchdog, Context).

for { select { ... } } — der Klassiker für Worker-Loops

Die mit Abstand häufigste Form von select ist die Verschachtelung mit einer Endlos-Schleife. Eine Goroutine soll wiederholt auf Ereignisse warten — neue Jobs, Timer-Ticks, Cancellation — und auf jedes mit einer Aktion reagieren. Das Muster sieht so aus:

Go for-select.go
package main

import (
    "fmt"
    "time"
)

func worker(jobs <-chan int, quit <-chan struct{}) {
    for {
        select {
        case j, ok := <-jobs:
            if !ok {
                fmt.Println("jobs-Channel geschlossen — Worker beendet")
                return
            }
            fmt.Println("verarbeite Job", j)
        case <-quit:
            fmt.Println("Quit-Signal — Worker beendet")
            return
        }
    }
}

func main() {
    jobs := make(chan int)
    quit := make(chan struct{})

    go worker(jobs, quit)

    jobs <- 1
    jobs <- 2
    time.Sleep(10 * time.Millisecond)
    close(quit)
    time.Sleep(10 * time.Millisecond)
}
Output
verarbeite Job 1
verarbeite Job 2
Quit-Signal — Worker beendet

Das Pattern ist so verbreitet, dass viele Go-Programmierer es als eine einzige Idee denken: „eine Goroutine, die auf einer Event-Loop sitzt". Die for-Schleife garantiert Wiederholung, das select multiplext zwischen den Eingaben, jeder Branch kennt seine eigene Abbruch-Strategie (return aus der Funktion, break aus der Schleife per Label, continue für den nächsten Durchlauf).

break allein hilft hier übrigens nicht — es bricht das select ab, nicht die umschließende for. Wer wirklich aus der Schleife raus will, nimmt return, ein Label oder eine Flag-Variable.

Timeout-Pattern — case <-time.After(d):

time.After(d) liefert einen <-chan time.Time, der nach Ablauf von d genau einen Wert sendet. In einen select eingebaut, wird daraus ein Timeout-Branch: gewinnt der Timer das Rennen gegen die anderen Cases, ist die Operation abgelaufen.

Go timeout-after.go
package main

import (
    "fmt"
    "time"
)

func fetch(slow bool) <-chan string {
    out := make(chan string, 1)
    go func() {
        if slow {
            time.Sleep(200 * time.Millisecond)
        } else {
            time.Sleep(20 * time.Millisecond)
        }
        out <- "Antwort"
    }()
    return out
}

func main() {
    for _, slow := range []bool{false, true} {
        select {
        case r := <-fetch(slow):
            fmt.Println("OK:", r)
        case <-time.After(100 * time.Millisecond):
            fmt.Println("Timeout nach 100 ms")
        }
    }
}
Output
OK: Antwort
Timeout nach 100 ms

time.After ist bequem, hat aber eine Tücke: jeder Aufruf erzeugt einen neuen Timer, der bis zum Ablauf vom Garbage Collector nicht eingesammelt wird — der zugehörige Goroutine-Eintrag im Runtime-Timer-Heap bleibt bestehen. In einer Hot-Loop, die time.After zigtausendmal pro Sekunde aufruft, kann das messbar Speicher kosten. In solchen Fällen verwendest du stattdessen einen vorbereiteten time.Timer mit Reset oder direkt einen context.WithTimeout — letzteres ist die idiomatische Antwort, sobald die Funktion einen Context hat.

Cancellation-Pattern — case <-ctx.Done():

Das context-Paket der Stdlib standardisiert Cancellation und Deadlines. Jeder Context hat eine Methode Done() <-chan struct{}, die einen Channel liefert, der geschlossen wird, sobald der Context abläuft oder explizit gecanceled wird. Im select ist das der kanonische Cancellation-Case:

Go ctx-done.go
package main

import (
    "context"
    "fmt"
    "time"
)

func longJob(ctx context.Context) (string, error) {
    done := make(chan string, 1)
    go func() {
        time.Sleep(200 * time.Millisecond)
        done <- "fertig"
    }()

    select {
    case r := <-done:
        return r, nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled oder DeadlineExceeded
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    r, err := longJob(ctx)
    if err != nil {
        fmt.Println("Abbruch:", err)
        return
    }
    fmt.Println("Resultat:", r)
}
Output
Abbruch: context.deadlineExceeded

Der Unterschied zu time.After ist konzeptionell wichtig: time.After ist ein lokaler Timer, der nichts über die Aufruf-Kette weiß. ctx.Done() ist Teil eines Cancellation-Baums — wer den Parent-Context canceled, canceled implizit alle Children. In einem HTTP-Handler, der wiederum eine Datenbank-Query, einen Cache-Lookup und einen externen API-Call macht, propagiert genau ein Context-Cancellation alle drei Operationen. Das ist mit time.After nicht erreichbar.

Faustregel: Sobald die Funktion einen context.Context als Parameter hat, ist ctx.Done() der Cancellation-Case der Wahl — nicht time.After, nicht ein selbstgebauter quit-Channel.

Der Empty-Select-Trick — select {}

Ein select ganz ohne Cases ist syntaktisch erlaubt und blockiert für immer — keine Communication kann je bereit werden, weil keine deklariert ist, und ohne default gibt es nichts zu tun außer warten. Das ist der idiomatische „parke diese Goroutine"-Trick.

Go empty-select.go
package main

import (
    "net/http"
)

func main() {
    // Worker-Goroutinen starten, Server starten ...
    go startWorkers()
    go startHTTPServer()

    // main soll nicht enden, sonst beendet die Runtime das Programm.
    // Equivalent zu: for { time.Sleep(time.Hour) } — nur eleganter.
    select {}
}

func startWorkers()    { /* ... */ }
func startHTTPServer() { _ = http.ListenAndServe(":8080", nil) }

Wenn main returnt, beendet die Go-Runtime das gesamte Programm — auch alle laufenden Goroutinen. Ein select {} am Ende parkt die main-Goroutine bewusst auf unbestimmte Zeit und lässt die anderen weiterleben. Modernere Programme nutzen statt dieses Tricks meistens ein Signal-Handling-Setup mit signal.NotifyContext, das bei SIGTERM sauber herunterfährt — aber für kleine Tools ist select {} knapp und korrekt.

go vet warnt bei einem select {}, das erreichbar ist und keine Begründung trägt, nicht. Wer mehr Klarheit will, kommentiert die Zeile mit der Intention („blockiert main, damit Background-Goroutinen weiterlaufen").

nil-Channel als deaktivierter Case

Ein Channel mit dem Wert nil blockiert alle Operationen für immer — sowohl Send als auch Receive. In einem normalen Programm ist das ein Deadlock-Generator. In einem select ist es ein Feature: ein nil-Channel-Case ist niemals bereit und wird damit faktisch deaktiviert. Das gibt dir ein elegantes Mittel, einzelne Cases dynamisch ein- und auszuschalten.

Go nil-channel-disable.go
package main

import (
    "fmt"
    "time"
)

// Ein Producer, der nach `closeAfter` Ticks fertig ist.
// Sobald der Input-Channel geschlossen ist, wird er auf nil gesetzt
// und der Recv-Case effektiv deaktiviert — der Worker dreht dann
// nur noch auf dem Tick.
func run() {
    in := make(chan int)
    tick := time.NewTicker(10 * time.Millisecond)
    defer tick.Stop()

    go func() {
        for i := 1; i <= 3; i++ {
            in <- i
        }
        close(in)
    }()

    for n := 0; n < 5; n++ {
        select {
        case v, ok := <-in:
            if !ok {
                fmt.Println("in geschlossen — Case deaktivieren")
                in = nil // ab jetzt nie wieder bereit
                continue
            }
            fmt.Println("Wert:", v)
        case <-tick.C:
            fmt.Println("Tick")
        }
    }
}

func main() { run() }
Output
Wert: 1
Wert: 2
Wert: 3
in geschlossen — Case deaktivieren
Tick

Ohne den in = nil-Trick würde der Recv-Case auf dem geschlossenen Channel in jedem Durchlauf gewinnen (geschlossener Channel ist immer recv-bereit). Indem du den Channel auf nil setzt, sagst du dem select: „diesen Eingang gibt es nicht mehr" — die Schleife schaut nur noch auf die übrigen.

Das Pattern lässt sich auch umgekehrt einsetzen: ein Send-Case mit nil-Channel, der erst aktiviert wird, wenn ein Wert zum Senden bereit ist. Ein gängiger Trick in State-Machine-artigen Goroutinen, die zwischen Empfangs- und Sende-Modus wechseln.

Praxis 1 — Worker mit Cancellation und Heartbeat

Hier ein realistischer Worker, wie er in produktiven Go-Services hundertfach steht. Er liest Jobs aus einem Channel, schreibt Resultate in einen Output-Channel, sendet alle 50 ms einen Heartbeat zur Überwachung, und reagiert sauber auf Context-Cancellation:

Go praxis-worker.go
package main

import (
    "context"
    "fmt"
    "time"
)

type Job struct{ ID int }
type Result struct {
    JobID int
    Value int
}

func worker(
    ctx context.Context,
    jobs <-chan Job,
    results chan<- Result,
    heartbeat chan<- time.Time,
) error {
    tick := time.NewTicker(50 * time.Millisecond)
    defer tick.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()

        case j, ok := <-jobs:
            if !ok {
                return nil // Producer fertig
            }
            // Resultat schreiben — auch hier auf Cancellation hören,
            // damit ein blockierter Send keinen Goroutine-Leak macht.
            res := Result{JobID: j.ID, Value: j.ID * j.ID}
            select {
            case results <- res:
            case <-ctx.Done():
                return ctx.Err()
            }

        case t := <-tick.C:
            // Heartbeat nicht-blockierend senden — wenn niemand
            // zuhört, verwerfen statt den Worker zu blockieren.
            select {
            case heartbeat <- t:
            default:
            }
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
    defer cancel()

    jobs := make(chan Job)
    results := make(chan Result, 4)
    heartbeat := make(chan time.Time, 1)

    go func() {
        for i := 1; i <= 3; i++ {
            jobs <- Job{ID: i}
            time.Sleep(20 * time.Millisecond)
        }
        close(jobs)
    }()

    done := make(chan error, 1)
    go func() { done <- worker(ctx, jobs, results, heartbeat) }()

    beats := 0
    for {
        select {
        case r := <-results:
            fmt.Printf("Result: job=%d value=%d\n", r.JobID, r.Value)
        case <-heartbeat:
            beats++
        case err := <-done:
            fmt.Println("worker beendet:", err, "heartbeats:", beats > 0)
            return
        }
    }
}
Output
Result: job=1 value=1
Result: job=2 value=4
Result: job=3 value=9
worker beendet: <nil> heartbeats: true

Drei Lektionen aus dem Beispiel. Erstens: jeder potenziell blockierende Send wird selbst in ein select mit ctx.Done() gewickelt. Sonst hängt der Worker auf einem results <- res, wenn niemand mehr liest — und der Goroutine-Leak ist da. Zweitens: der Heartbeat ist non-blocking (default-Branch), weil ein verpasster Tick kein Drama ist, aber ein blockierter Worker schon. Drittens: die main-Loop muxt aus drei verschiedenen Quellen (Resultate, Heartbeats, Worker-Ende) — exakt der Anwendungsfall, für den select erfunden wurde.

Praxis 2 — Fan-In-Merge zweier Input-Channels

Ein zweites kanonisches Muster: Fan-In, also das Zusammenführen mehrerer Producer in einen einzigen Output. Die pipelines-Beispiele aus dem Go-Blog nutzen dafür eine sync.WaitGroup; für genau zwei oder drei Inputs ist eine handgeschriebene select-Variante übersichtlicher und kommt ohne WaitGroup aus:

Go praxis-merge.go
package main

import (
    "fmt"
    "sort"
)

// merge2 führt zwei Input-Channels in einen Output-Channel zusammen.
// Schließt out, sobald beide Inputs geschlossen sind.
func merge2(a, b <-chan int) <-chan int {
    out := make(chan int)

    go func() {
        defer close(out)
        for a != nil || b != nil {
            select {
            case v, ok := <-a:
                if !ok {
                    a = nil // Input erschöpft → Case deaktivieren
                    continue
                }
                out <- v
            case v, ok := <-b:
                if !ok {
                    b = nil
                    continue
                }
                out <- v
            }
        }
    }()

    return out
}

func producer(values ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, v := range values {
            out <- v
        }
    }()
    return out
}

func main() {
    a := producer(1, 3, 5, 7)
    b := producer(2, 4, 6)

    merged := merge2(a, b)

    var got []int
    for v := range merged {
        got = append(got, v)
    }
    sort.Ints(got) // Reihenfolge ist nicht-deterministisch (Random Choice)
    fmt.Println(got)
}
Output
[1 2 3 4 5 6 7]

Der Kern ist die Schleifenbedingung for a != nil || b != nil zusammen mit dem nil-Trick aus Abschnitt 11. Sobald ein Input-Channel geschlossen ist, wird seine Variable auf nil gesetzt — der zugehörige Case ist deaktiviert, das select arbeitet nur noch auf dem verbleibenden Channel weiter. Wenn auch dieser geschlossen ist, bricht die for-Schleife ab, defer close(out) schließt den Output, und der range-Reader im Aufrufer terminiert sauber.

Bemerkenswert: die Reihenfolge der gemergten Werte ist nicht festgelegt. Wenn beide Inputs gleichzeitig bereit sind, wählt select zufällig. Wer geordneten Merge braucht (etwa zwei sortierte Streams), muss eine andere Datenstruktur benutzen — ein klassischer Merge-Algorithmus mit Lookahead, kein nacktes select.

Erkenntnisse

AspektZusammenfassung
ZweckAuf mehrere Channel-Operationen gleichzeitig warten — der Multiplexer für Goroutinen
CasesSend, Receive (optional mit comma-ok), default (höchstens einmal)
AuswahlBei mehreren bereiten Cases pseudo-zufällig — keine Reihenfolge-Garantie
defaultMacht das select non-blocking; läuft, wenn kein anderer Case bereit ist
Timeoutcase <-time.After(d): für lokale Timeouts, case <-ctx.Done(): für propagierende Cancellation
Emptyselect {} blockiert für immer — der „parke die Goroutine"-Trick
nil-ChannelDeaktiviert seinen Case dauerhaft — Mittel für dynamisches Enable/Disable
Loop-Formfor { select { ... } } ist der Standard für Worker und Event-Loops

Besonderheiten

select ist nicht switch — Cases sind Channel-Operationen, keine Ausdrücke.

Wer select zum ersten Mal sieht, liest es als switch-Variante und schreibt versehentlich case x == 5: oder case time.Now():. Beides ist Compile-Fehler. Ein case in einem select muss eine SendStmt (ch <- v) oder eine RecvStmt (<-ch oder v := <-ch) sein — nichts anderes.

Bereite Cases werden pseudo-zufällig gewählt — niemals auf Reihenfolge verlassen.

Die Spec garantiert uniform pseudo-random selection, und die Runtime hält sich daran. Wer „erst quit, dann jobs" haben will, baut sich Priorität über ein vorgeschaltetes select mit default — und akzeptiert, dass auch das nur best-effort ist.

Channel-Ausdrücke werden bei Eintritt evaluiert — nicht erneut pro Wartephase.

case ch[i] <- f(): berechnet ch[i] und f() einmal am Anfang des select. Wenn f() Seiteneffekte hat, passieren sie vor dem Warten, nicht beim Case-Treffen. Eine häufige Verwechslung, gerade bei Side-Effekt-haltigen Sendewerten.

default macht select non-blocking — Türöffner für try-send/try-recv.

Ohne default blockiert select, bis ein Case bereit ist. Mit default läuft die Statement-Liste des default-Branchs sofort, wenn kein anderer Case bereit ist. Das ist die idiomatische Mechanik für „versuche zu senden, gib sonst auf" — siehe Metrik-Drops und nicht-blockierende Heartbeats.

Geschlossener Channel ist im Recv-Case dauerhaft bereit — comma-ok ist Pflicht.

Wer auf einem geschlossenen Channel im select empfängt, bekommt für immer Zero-Werte. Ohne comma-ok (v, ok := <-ch) bemerkt die Schleife das nicht, und das select dreht in einer Busy-Loop. Beim Erkennen des ok == false typischerweise den Channel auf nil setzen, um den Case zu deaktivieren.

nil-Channel deaktiviert seinen Case — niemals deadlockt das select, solange andere Cases existieren.

Ein nil-Channel blockiert in einer normalen Operation für immer; in einem select-Case ist er einfach „nicht bereit". Damit kannst du Cases dynamisch ein- und ausschalten — etwa Recv-Cases nach Channel-Close, oder Send-Cases solange kein Wert vorliegt. Das ist das fortgeschrittene select-Idiom.

time.After(d) erzeugt einen neuen Timer pro Aufruf — in Hot-Loops vermeiden.

Jeder time.After-Aufruf legt einen Timer in den Runtime-Timer-Heap, der bis zum Ablauf nicht freigegeben wird. Im for { select { ... case <-time.After(d) ... } } summieren sich diese Timer. Lösung: einen time.Timer mit Reset wiederverwenden oder direkt context.WithTimeout nutzen.

break im select-Case bricht nur das select ab, nicht die umschließende for-Schleife.

Wer in einem for { select { ... } } per break aussteigen will, bricht damit nur den select ab — die for-Schleife läuft weiter. Für „raus aus allem" nimmt man return, ein gelabeltes break Loop: oder eine Flag-Variable, die das for in der nächsten Iteration auswertet.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Goroutines & Channels

Zur Übersicht