Channel-Grundlagen in Golang

GolangBeginner
Jetzt üben

Einführung

Eines der attraktivsten Merkmale der Programmiersprache Go ist ihre native Unterstützung für nebenläufige Programmierung (Concurrent Programming). Sie bietet starke Unterstützung für Parallelität, Nebenläufigkeit und Netzwerkkommunikation, was eine effizientere Nutzung von Multi-Core-Prozessoren ermöglicht.

Im Gegensatz zu anderen Sprachen, die Nebenläufigkeit durch gemeinsam genutzten Speicher (Shared Memory) erreichen, realisiert Go die Nebenläufigkeit durch die Verwendung von Kanälen (Channels) für die Kommunikation zwischen verschiedenen Goroutinen.

Goroutinen sind die Einheiten der Nebenläufigkeit in Go und können als leichtgewichtige Threads betrachtet werden. Sie verbrauchen beim Wechsel zwischen ihnen weniger Ressourcen als herkömmliche Threads.

Kanäle und Goroutinen bilden zusammen die Nebenläufigkeits-Primitive (Concurrency Primitives) in Go. In diesem Abschnitt lernen wir die Kanäle, diese neue Datenstruktur, kennen.

Wissenspunkte:

  • Arten von Kanälen
  • Erstellen von Kanälen
  • Operationen auf Kanälen
  • Kanalblockierung (Channel Blocking)
  • Unidirektionale Kanäle

Überblick über Kanäle (Channels)

Kanäle sind eine spezielle Datenstruktur, die hauptsächlich für die Kommunikation zwischen Goroutinen verwendet wird.

Im Gegensatz zu anderen Nebenläufigkeitsmodellen, die Mutexes und atomare Funktionen (Atomic Functions) für den sicheren Zugriff auf Ressourcen verwenden, ermöglichen Kanäle die Synchronisation durch das Senden und Empfangen von Daten zwischen mehreren Goroutinen.

In Go ist ein Kanal wie ein Förderband oder eine Warteschlange (Queue), die der First-In-First-Out (FIFO)-Regel folgt.

Kanaltypen und Deklaration

Ein Kanal ist ein Referenztyp, und sein Deklarationsformat lautet wie folgt:

var channelName chan elementType

Jeder Kanal hat einen spezifischen Typ und kann nur Daten desselben Typs transportieren. Sehen wir uns einige Beispiele an:

Erstellen Sie eine Datei namens channel.go im Verzeichnis ~/project:

cd ~/project
touch channel.go

Zum Beispiel deklarieren wir einen Kanal vom Typ int:

var ch chan int

Der obige Code deklariert einen int-Kanal namens ch.

Darüber hinaus gibt es viele andere häufig verwendete Kanaltypen.

Schreiben Sie in channel.go den folgenden Code:

package main

import "fmt"

func main() {
    var ch1 chan int   // Deklariere einen Integer-Kanal
    var ch2 chan bool  // Deklariere einen Boolean-Kanal
    var ch3 chan []int // Deklariere einen Kanal, der Integer-Slices transportiert
    fmt.Println(ch1, ch2, ch3)
}

Der obige Code deklariert lediglich drei verschiedene Arten von Kanälen.

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe sieht wie folgt aus:

<nil> <nil> <nil>

Kanal-Initialisierung

Ähnlich wie bei Maps müssen Kanäle nach ihrer Deklaration initialisiert werden, bevor sie verwendet werden können.

Die Syntax zur Initialisierung eines Kanals lautet wie folgt:

make(chan elementType)

Schreiben Sie in channel.go den folgenden Code:

package main

import "fmt"

func main() {
    // Kanal, der Integer-Daten speichert
    ch1 := make(chan int)

    // Kanal, der Boolesche Daten speichert
    ch2 := make(chan bool)

    // Kanal, der []int-Daten speichert
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe wird wie folgt angezeigt:

0xc0000b4000 0xc0000b4040 0xc0000b4080

Im obigen Code werden drei verschiedene Arten von Kanälen definiert und initialisiert.

Durch die Untersuchung der Ausgabe können wir sehen, dass, im Gegensatz zu Maps, das direkte Drucken des Kanals selbst seinen Inhalt nicht preisgibt.

Das direkte Drucken eines Kanals liefert nur die Speicheradresse des Kanals selbst. Dies liegt daran, dass ein Kanal, ähnlich wie ein Zeiger (Pointer), nur eine Referenz auf eine Speicheradresse ist.

Wie können wir also auf die Werte in einem Kanal zugreifen und diese bearbeiten?

Kanaloperationen

Kanaloperationen konzentrieren sich auf das Senden und Empfangen von Daten, was mit dem Operator <- erfolgt. Um Kanäle in Aktion zu sehen, müssen wir sie mit Goroutinen verwenden, Go's leichtgewichtigen Threads für Nebenläufigkeit (Concurrency).

Ein Kanal verbindet eine sendende Goroutine und eine empfangende Goroutine. Wenn eine davon nicht bereit ist, blockiert die andere. Sehen wir uns ein vollständiges Beispiel an.

Schreiben Sie in channel.go den folgenden Code:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Erstelle einen unbuffered Kanal (ungepufferten Kanal)
    ch := make(chan int)

    // Starte eine neue Goroutine mit einer anonymen Funktion
    go func() {
        fmt.Println("Goroutine beginnt mit dem Senden...")
        // Sende den Wert 10 an den Kanal
        ch <- 10
        fmt.Println("Goroutine hat das Senden beendet.")
    }()

    fmt.Println("Main wartet auf Daten...")
    // Empfange den Wert vom Kanal
    value := <-ch
    fmt.Printf("Main empfangen: %d\n", value)

    // Gib der Goroutine Zeit, ihre abschließende Nachricht auszugeben
    time.Sleep(time.Second)
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe wird in etwa wie folgt aussehen (die Reihenfolge kann leicht variieren):

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

In diesem Beispiel:

  1. ch := make(chan int) erstellt einen unbuffered Kanal. Ungepufferte Kanäle erfordern, dass Sender und Empfänger gleichzeitig bereit sind, damit die Kommunikation stattfinden kann. Dies wird als Synchronisation bezeichnet.
  2. go func() { ... }() startet eine neue Goroutine. Das Schlüsselwort go führt die Funktion nebenläufig zur main-Funktion aus.
  3. Innerhalb der Goroutine sendet ch <- 10 den Wert 10 an den Kanal. Diese Operation blockiert, bis die main-Goroutine bereit ist, zu empfangen.
  4. In der main-Funktion empfängt value := <-ch den Wert. Diese Operation blockiert, bis die Goroutine einen Wert sendet.
  5. Sobald beide bereit sind, wird der Wert übertragen, und beide Goroutinen werden entblockt und setzen ihre Ausführung fort. Wir haben time.Sleep hinzugefügt, um sicherzustellen, dass das Programm nicht beendet wird, bevor die Goroutine ihre abschließende Nachricht ausgeben kann.

Schließen des Kanals

Wenn keine weiteren Werte mehr über einen Kanal gesendet werden, kann der Sender ihn mit der Funktion close() schließen. Eine gängige Methode, alle Werte aus einem Kanal zu lesen, bis er geschlossen wird, ist die Verwendung einer for range-Schleife.

Modifizieren wir channel.go:

package main

import "fmt"

func main() {
    // Ein gepufferter Kanal (buffered channel) zur Aufnahme mehrerer Werte
    ch := make(chan int, 3)

    // Starte eine Goroutine zum Senden von Daten
    go func() {
        fmt.Println("Goroutine sendet 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Schließe den Kanal nach dem Senden aller Werte
        close(ch)
        fmt.Println("Goroutine hat den Kanal geschlossen.")
    }()

    fmt.Println("Main empfängt Daten...")
    // Verwende eine for-range-Schleife, um Werte zu empfangen, bis der Kanal geschlossen ist
    for value := range ch {
        fmt.Printf("Main empfangen: %d\n", value)
    }
    fmt.Println("Main hat den Empfang beendet.")
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe sieht wie folgt aus (die Reihenfolge kann leicht variieren):

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

Dies demonstriert ein gängiges Muster: Die for range-Schleife kümmert sich automatisch darum, zu prüfen, ob der Kanal geschlossen ist, und beendet sich, wenn dies der Fall ist.

Wenn Sie mit geschlossenen Kanälen arbeiten, beachten Sie diese Schlüsselregeln:

  • Das Senden an einen geschlossenen Kanal führt zu einem Laufzeitfehler (einem Panic).
  • Das Empfangen von einem geschlossenen, leeren Kanal gibt sofort den Nullwert (zero value) des Kanaltyps zurück.
  • Das Schließen eines bereits geschlossenen Kanals führt zu einem Panic.
  • Im Allgemeinen sollte nur der Sender einen Kanal schließen, niemals der Empfänger.

Kanalblockierung

Wie wir im vorherigen Schritt gesehen haben, können Kanaloperationen blockieren. Dies ist ein Schlüsselmerkmal, das Goroutinen die Synchronisation ermöglicht. Betrachten wir dies genauer.

Ungepufferte Kanäle (Unbuffered Channels)

Kanäle, die mit make(chan T) erstellt werden, sind ungepuffert.

ch1 := make(chan int) // Ungepufferter Kanal

Ein ungepufferter Kanal hat keine Kapazität. Eine Sendeoperation auf einem ungepufferten Kanal blockiert die sendende Goroutine, bis eine andere Goroutine bereit ist zu empfangen. Umgekehrt blockiert eine Empfangsoperation die empfangende Goroutine, bis eine andere Goroutine bereit ist zu senden. Dies erzeugt einen starken Synchronisationspunkt, wie wir in unserem ersten Beispiel im vorherigen Schritt gesehen haben.

Gepufferte Kanäle (Buffered Channels)

Kanäle können auch gepuffert sein, was bedeutet, dass sie eine Kapazität haben, eine bestimmte Anzahl von Werten zu speichern, bevor sie blockieren.

ch2 := make(chan int, 5) // Gepufferter Kanal mit einer Kapazität von 5

Bei einem gepufferten Kanal gilt:

  • Eine Sendeoperation blockiert nur, wenn der Puffer voll ist.
  • Eine Empfangsoperation blockiert nur, wenn der Puffer leer ist.

Wenn der Puffer nicht voll ist, kann ein Sender einen Wert senden, ohne dass ein Empfänger sofort bereit sein muss. Der Wert wird in der Warteschlange (Queue) des Kanals gespeichert.

Deadlock-Beispiel

Was passiert, wenn wir die Kapazität eines Kanals überschreiten? Das Programm führt zu einem Deadlock. Ein Deadlock tritt auf, wenn alle Goroutinen in einem Programm blockiert sind und auf etwas warten, das niemals eintreten wird.

Erstellen Sie eine Datei namens channel1.go:

cd ~/project
touch channel1.go

Schreiben Sie den folgenden Code, der versucht, zwei Werte an einen Kanal mit einer Kapazität von eins zu senden, alles innerhalb der Haupt-Goroutine:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // This succeeds
    fmt.Println("Sent 10 to channel")
    ch <- 20 // This blocks forever because the channel is full
    fmt.Println("succeed")
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel1.go

Die Programmausgabe sieht wie folgt aus:

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

Das Programm stürzt mit einem Deadlock-Fehler ab (panics). Die main-Goroutine sendet 10, wodurch der Puffer gefüllt wird. Anschließend versucht sie, 20 zu senden, blockiert jedoch, da der Puffer voll ist. Da keine andere Goroutine jemals Werte vom Kanal empfangen wird, bleibt die main-Goroutine für immer blockiert, was dazu führt, dass die Go-Laufzeitumgebung einen Deadlock erkennt.

Um dies zu beheben, muss die Operation mit einer anderen Goroutine koordiniert werden, die den Wert empfangen und Platz im Puffer schaffen kann.

Unidirektionale Kanäle

Standardmäßig ist ein Kanal bidirektional und kann sowohl zum Lesen als auch zum Schreiben verwendet werden. Manchmal müssen wir die Verwendung eines Kanals einschränken, z. B. möchten wir sicherstellen, dass ein Kanal innerhalb einer Funktion nur beschrieben oder nur gelesen werden kann. Wie können wir das erreichen?

Wir können einen Kanal mit eingeschränkter Verwendung explizit deklarieren, bevor wir ihn initialisieren.

Nur-Schreib-Kanal (Write-only Channel)

Ein Nur-Schreib-Kanal bedeutet, dass innerhalb einer bestimmten Funktion nur in den Kanal geschrieben werden kann und nicht daraus gelesen werden kann. Deklarieren und verwenden wir einen Nur-Schreib-Kanal vom Typ int:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Daten in den Kanal geschrieben")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Daten empfangen:", <-ch)
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe sieht wie folgt aus:

Data written to channel
Data received: 10

In diesem Beispiel schränkt die Funktion writeOnly den Kanal auf das reine Schreiben ein.

Nur-Lese-Kanal (Read-only Channel)

Ein Nur-Lese-Kanal bedeutet, dass innerhalb einer bestimmten Funktion nur aus dem Kanal gelesen werden kann und nicht hineingeschrieben werden kann. Deklarieren und verwenden wir einen Nur-Lese-Kanal vom Typ int:

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Daten vom Kanal empfangen:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe sieht wie folgt aus:

Data received from channel: 20

In diesem Beispiel schränkt die Funktion readOnly den Kanal auf das reine Lesen ein.

Kombination von Nur-Schreib- und Nur-Lese-Kanälen

Sie können Nur-Schreib- und Nur-Lese-Kanäle kombinieren, um zu demonstrieren, wie Daten durch eingeschränkte Kanäle fließen:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

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

Führen Sie das Programm mit dem folgenden Befehl aus:

go run channel.go

Die Programmausgabe sieht wie folgt aus:

Data written to channel
Data received from channel: 30

Dieses Beispiel veranschaulicht, wie ein Nur-Schreib-Kanal Daten an einen Nur-Lese-Kanal übergeben kann.

Zusammenfassung

In diesem Lab haben wir die Grundlagen von Kanälen (Channels) gelernt, darunter:

  • Arten von Kanälen und wie man sie deklariert
  • Methoden zur Initialisierung von Kanälen
  • Operationen auf Kanälen
  • Das Konzept der Kanalblockierung (Channel Blocking)
  • Deklaration von unidirektionalen Kanälen