Channels sind ein zentrales Konzept in Go für Nebenläufigkeit. Sie ermöglichen den Datenaustausch zwischen Goroutines und deren Synchronisierung ohne explizite Locks. Dieser Guide behandelt die Verwendung von Channels, häufige Herausforderungen und praktische Patterns für nebenläufige Systeme.

Einführung

Channels in Go ermöglichen es, Daten zwischen Goroutinen sicher und elegant auszutauschen. Das zentrale Motto von Go in Bezug auf Concurrency lautet wie folgt.

Don’t communicate by sharing memory; share memory by communicating. Das bedeutet: Anstatt dass mehrere Goroutinen auf denselben Speicher zugreifen und ihn mit Locks schützen (wie in vielen anderen Sprachen), kommunizieren Goroutinen in Go über Channels. Der Channel selbst kümmert sich um die Synchronisation.

Grundlagen der Concurrency

Goroutinen - die Basis

Bevor wir Channels verstehen können, sollten wir Goroutinen verstehen. Es gibt bereits in einem anderen Artikel “Goroutines” eine ausführliche Beschreibung, was Goroutinen sind. Hier nochmals kurz wiederholend zusammengefasst.

Eine Goroutine ist eine leichtgewichtige Thread-Abstraktion in Go.

Go Beispiel - Goroutine
package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {

    // Normale synchrone Ausführung
    sayHello("World")

    // Startet eine Goroutine
    go sayHello("Concurrency")

    // Hauptprogramm wartet kurz,
    // damit Goroutine Zeit hat
    time.Sleep(1 * time.Second)

    fmt.Println("Programm Ende")

}
Output
Hello, World!
Hello, Concurrency!
Programm Ende

Was genau passiert hier?

  • sayHello("World") wird synchron ausgeführt (normal)
  • go sayHello("Concurrency") startet eine Goroutine
  • Das Hauptprogramm wartet mit time.Sleep(), damit Goroutine Zeit hat zu laufen

Ohne Sleep würde das Programm sofort beenden, bevor die Goroutine läuft. Allerdings ist time.Sleep() keine gute Lösung für Synchronisation.

Das Problem ohne Channels

Go Beispiel - ohne Channels
package main

import (
    "fmt"
    "time"
)

func main() {
    counter := 0

    // Starte 5 Goroutinen, die den Counter erhöhen
    for i := 0; i < 5; i++ {
        go func() {
            for j := 0; j < 1000; j++ {
                counter++ // <=== Race Condition
            } 
        }()
    }

    time.Sleep(1 * time.Second)
    fmt.Printf("Counter: %d\n", counter)

}
Output
Counter: 3326

Probleme:

  • Race Condition: Mehrere Goroutinen greifen gleichzeitig auf counter zu
  • Keine Garantie: Wir wissen nicht, wann alle Goroutinen fertig sind
  • Unsicher: Das Programm ist nicht deterministisch

Channels lösen beide Probleme.

Was sind Channels?

Ein Channel ist eine typisierte Pipe, durch die Werte gesendet und empfangen werden können. Channels verbinden Goroutinen und ermöglichen sichere Kommunikation.

Anatomie eines Channels

Go Anatomie
// Channel Deklaration
var ch chan int

// Channels mit make erstellen
ch = make(chan int)

// Oder in einer Zeile
ch := make(chan int)

Wichtig

Channels müssen mit make erstellt werden. Ein deklarierter aber nicht initialisierter Channel hat den Wert nil.

Drei grundlegende Channel-Operationen

Hierbei handelt es sich um die Operationen: “Channel erstellen”, “Daten senden”, “Daten empfangen”.

Go Beispiel - Operationen
package main

import "fmt"

func main() {

    // 1. Channel erstellen
    ch := make(chan string)

    // 2. Daten senden
    go func() {
        ch <- "Hallo aus Goroutine"
    }()

    // 3. Daten empfangen
    msg := <-ch

    fmt.Println(msg)

}
Output
Hallo aus Goroutine

Die Operatoren

  • ch <- value: Sende value in den Channel ch
  • value := <- ch: Empfange einen Wert aus Channel ch
  • <-ch: Empfange und verwerfe diesen Wert

Häufige Stolperfallen

Senden auf einem nil-Channel blockiert für immer.

Ein deklarierter aber nie mit make initialisierter Channel ist nil. Jede Sende- und Empfangs-Operation darauf blockiert ewig — die Goroutine ist faktisch tot. Das gilt auch für Receives. Genutzt werden kann das Verhalten allerdings gezielt in select, um einzelne Cases dynamisch zu deaktivieren.

Senden auf einem geschlossenen Channel panischt.

ch <- x nach close(ch) ist kein Fehler, sondern eine Runtime-Panic, die das Programm beendet, wenn niemand sie behandelt. Deshalb gilt die Faustregel: nur der Sender schließt — und nur, wenn er sicher ist, dass keine weiteren Sender mehr senden.

Receive auf einem geschlossenen Channel liefert sofort den Zero-Value.

v := <-ch blockiert nicht, sondern gibt den Zero-Value des Element-Typs zurück. Um “echten Wert” und “Channel ist zu” zu unterscheiden, gibt es die comma-ok-Form: v, ok := <-ch. Ist ok == false, ist der Channel geschlossen und leer.

Mehrere Sender plus close ist ein Race.

Sobald mehr als eine Goroutine sendet, darf keine davon einfach close aufrufen — sonst landet eine Sende-Operation potentiell auf einem schon geschlossenen Channel und panischt. In solchen Topologien koordiniert ein separater Schließer (z. B. via sync.WaitGroup oder ein dediziertes Done-Signal), oder man schließt den Channel gar nicht und beendet den Receiver anders.

Unbuffered und buffered haben verschiedenes Synchronisations-Verhalten.

Ein unbuffered Channel ist ein Rendezvous: Sende- und Empfangs-Operation finden gleichzeitig statt, beide Seiten warten aufeinander. Ein buffered Channel mit Kapazität entkoppelt Sender und Empfänger, solange der Puffer nicht voll bzw. nicht leer ist. Wer “Senden hat empfangen” garantieren will, braucht entweder unbuffered oder ein zweites Acknowledge.

select mit default ist ein non-blocking Poll.

Ohne default blockiert select, bis genau ein Case bereit ist (bei mehreren bereiten Cases wird zufällig gewählt). Mit default läuft select sofort weiter, falls kein Case bereit ist — praktisch für “versuche zu senden/empfangen, aber blockiere nicht”. In der Endlosschleife wird daraus aber schnell Busy-Waiting; lieber ein zweiter Channel mit Timer.

range über einen Channel terminiert erst bei close.

for v := range ch liest, bis der Channel geschlossen und leer ist. Wird er nie geschlossen, blockiert die Schleife für immer und der range-Loop ist eine versteckte Goroutine-Leak-Quelle.

Channel-Direction in Funktionssignaturen ist Dokumentation und Schutz.

chan T ist bidirektional, chan<- T nur Senden, <-chan T nur Empfangen. Eine bidirektionale Variable kann implizit auf eine richtungs-eingeschränkte zugewiesen werden — andersherum nicht. So lässt sich am Funktionsrand garantieren, dass ein Producer nicht versehentlich aus seinem Output liest und ein Consumer nicht in den Input schreibt. close auf <-chan T ist übrigens ein Compile-Fehler.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Goroutines & Channels

Zur Übersicht