Golang: Channels

Wie in dem Beitrag über Goroutines zu sehen war, gestaltet sich die Verwendung dieser in Golang als durchaus einfach. Allerdings wurden für die dort gezeigten Anwendungsfälle auch recht simple Szenarien verwendet. Komplexer kann es werden, wenn zwischen verschiedenen Goroutinen eine Kommunikation notwendig ist. Um diese zu ermöglichen, bietet Golang Channels an. Channels sind Kommunikationskanäle, welche zum Versenden und Lesen von Daten zwischen Goroutinen verwendet werden können. Sie werden, ähnlich wie maps & slices, mit Hilfe der make-Funktion erstellt. Um Daten in einen Channel zu schreiben oder auszulesen wird der Channel-Operator <- verwendet, wobei der Pfeil in Richtung des „Daten-Flusses“ zeigt.

ch := make(chan int) // Channel erzeugen 
ch <- v // Daten in den Channel schreiben 
v <- ch // Daten aus den Channel lesen

Standardmäßig blockieren Channel Operationen den weiteren Ablauf der Goroutine. Wenn Daten in einen Channel geschrieben wurden, wird die Goroutine so lange blockiert, bis eine andere Goroutine die Daten aus dem Channel ausliest. Diese Eigenschaft kann zum Beispiel dafür verwendet werden, um Goroutinen miteinander zu synchronisieren.

Deadlock

Wird eine Channel Operation ausgeführt (zum Beispiel: v <- ch), wird die Ausführung dieser Goroutine blockiert, solange bis in dem Channel ein Wert zum Lesen vorhanden ist. Gleiches gilt für das Schreiben in einen Channel. Die Ausführung der Goroutine wird so lange blockiert, bis der Wert aus dem Channel ausgelesen wurde.

Existiert allerdings keine weitere Goroutine, die auf die Daten aus diesem Channel zugreift oder Daten in den Channel schreibt, tritt ein Deadlock auf. Dies lässt sich an einem einfachen Beispiel zeigen, bei welchem in der Main-Routine ein Wert in einen Channel geschrieben, aber nicht mehr ausgelesen wird:

package main

func main() {
    ch := make(chan int)
    ch <- 5
}

Ausgabe:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
    channels.go:5 +0x50
exit status 2

Beheben lässt sich ein Deadlock, indem sichergestellt wird, dass es weitere Goroutinen gibt, welche den Wert aus dem Channel lesen. Im folgenden Beispiel gibt es eine Goroutine (read), welche den Wert nach einer Wartezeit von 3 Sekunden ausliest. Während dieser Zeit ist die Ausführung der Main-Routine blockiert, wie der Ausgabe zu entnehmen ist.

package main

import (
    "fmt"
    "time"
)

func read(ch chan int) {
    time.Sleep(3 * time.Second)
    v := <- ch
    fmt.Printf("Reading %v\n", v)
}

func main() {
    start := time.Now()
    ch := make(chan int)
    go read(ch)
    v := 5
    fmt.Printf("Writing %v into channel\n", v)
    ch <- v
    diff := time.Since(start)
    fmt.Printf("Diff %s\n", diff)
}

Ausgabe:

Writing 5 into channel
Reading 5
Diff 3.002211204s

Unidirektionale Channel

Standardmäßig können Daten in Channel geschrieben und ausgelesen werden. Um die Fehleranfälligkeit der Programme zu minimieren, können Channel auch als Receive- bzw. Send-Only definiert werden. Diese Eigenschaft eines Channels wird dabei mit Hilfe des Channel-Operators angegeben. Als Beispiel dient die read-Funktion aus dem letzten Code-Beispiel. Da innerhalb der Funktion nicht in den Channel geschrieben wird, reicht es aus, wenn dieser nur lesend zur Verfügung steht: func read(ch <-chan int)‌‌. Wird dennoch in den Channel geschrieben, bricht das Programm mit der Fehlermeldung invalid operation: ch <- 5 (send to receive-only type <-chan int) ab.

Close & Range

Ein Channel kann auch geschlossen werden. Dies bewirkt, dass weder in den Channel geschrieben, noch aus ihm gelesen werden kann. Geschlossen wird ein Channel mit Hilfe der close-Funktion close(ch). Ob ein Channel geschlossen ist, kann ebenfalls überprüft werden: v, ok := <- ch. In diesem Fall soll aus dem Channel gelesen werden. In ok steht nun als Boolean true, wenn der Channel noch offen ist und false, wenn er geschlossen ist.

Mittels range besteht die Möglichkeit, so lange Werte aus einem Channel zu lesen, bis dieser geschlossen wurde:

package main

import (
    "fmt"
)

func write(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func main() {
    ch := make(chan int)
    go write(ch)
    for i := range ch  {
        fmt.Println(i)
    }

    fmt.Println("Done")
}

Ausgabe:

0
1
2
3
4
Done

Wird der Channel nicht mittels close(ch) geschlossen, tritt der bereits bekannte Deadlock auf, da in der Main-Routine immer noch versucht wird aus dem Channel zu lesen, obwohl es keine andere Routine mehr gibt, die in den Channel schreibt.

Select

Das Select-Statement ist ähnlich zum Switch-Case. Hierbei lässt sich auf mehrere Channel Operationen gleichzeitig warten. Auch hier wird die weitere Ausführung der aktuellen Goroutine blockiert, bis ein Case eintritt. Sind mehrere Cases gleichzeitig verfügbar, wird einer zufällig ausgewählt. Wie bei einem Switch-Case gibt es auch einen Default-Case. Der Default-Case wird immer dann ausgeführt, wenn kein anderer Case zutrifft. Dabei gilt zu beachten, dass der Default-Case nicht blockiert.

package main

import (
    "fmt"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    select {
        case <- ch1:
            fmt.Println("ch1 available")
        case <- ch2:
            fmt.Println("ch2 available")
        default:
            fmt.Println("No channel available")
    }
}

Ausgabe:

No channel available

In dem vorherigen Beispiel sind zwei Dinge zu beachten.

  1. Es wird immer der Default-Case ausgeführt, da keine Daten aus einem der beiden Channels zu lesen sind.
  2. Würde hier kein Default-Case existieren, tritt der weiter oben beschriebene Deadlock auf, da es keine weitere Goroutine gibt, welche in einen der beiden Channels schreibt.

Statt des Default-Cases lässt sich auch ein normaler Case mit einem Timeout verwenden. Hierbei wird der Case nach einer vorher definierten Wartezeit ausgeführt. Dies ist dann hilfreich, wenn auf mehrere Channel-Operationen gewartet wird (zum Beispiel auf Antworten von verschiedenen Backend-Services), bei denen nach einer gewissen Zeit abgebrochen werden soll.
In dem folgenden Code-Ausschnitt wird ein Beispiel gezeigt, bei welchem die fetchUrl-Funktion das Abfragen von URLs mittels time.Sleep simuliert. Statt eines Responses wird der Einfachheit halber die URL in den Channel geschrieben. In der Main-Funktion wird die fetchUrl-Funktion mit zwei unterschiedlichen URLs aufgerufen. Da das Erzeugen der beiden Goroutinen in der Main-Funktion den weiteren Ablauf nicht blockiert, wird danach im select-Statement darauf gewartet, dass eine Goroutine in den Channel schreibt. Da der Sleep-Aufruf in der fetchUrl-Funktion zufällig zwischen 100 und 150ms liegt, führt das mehrfache Ausführen des Programms dazu, dass unterschiedliche Cases in dem Select-Statement auftreten. Wenn beide Goroutinen länger als 120 Millisekunden benötigen tritt der letzte Case mit dem „Timeout“ ein.

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func fetchUrl(url string, ch chan<- string) {
    // Generate a random number between 100 and 150 ms
    min, max := 100, 150
    rand.Seed(time.Now().UnixNano())
    random := time.Duration(rand.Intn(max - min + 1) + min) * time.Millisecond
    time.Sleep(random)

    ch <- url
}

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    timeout := time.After(120 * time.Millisecond)

    go fetchUrl("service_1.com", ch1)
    go fetchUrl("service_2.com", ch2)

    select {
        case response := <-ch1:
            fmt.Printf("Response from %v\n", response)
        case response := <-ch2:
            fmt.Printf("Response from %v\n", response)
        case <-timeout:
            fmt.Println("Timeout")
    }
}

Ausgabe:

$ go run channels.go
Timeout

$ go run channels.go
Response from service_1.com

$ go run channels.go
Response from service_1.com

$ go run channels.go
Timeout

$ go run channels.go
Response from service_1.com

$ go run channels.go
Response from service_2.com

Schlusswort

Channels bieten eine einfache Möglichkeit zur Kommunikation zwischen mehreren Goroutinen. Dank ihrer Eigenschaften lässt sich concurrency in Golang somit auch in komplexen Szenarien übersichtlich abbilden. Dabei gilt vor allem zu beachten, dass keine Deadlocks erzeugt werden. Um Fehler im Code zu vermeiden, sollte ebenfalls nicht darauf verzichten werden, wo möglich, Channels als Receive- bzw. Send-Only zu definieren.

Kommentare anzeigen