2.7 Nebenläufigkeit

Es wird behauptet, Go sei das C des 21. Jahrhunderts. Ich glaube, dafür gibt es zwei Gründe: erstens, Go ist eine simple Programmiersprache; zweitens: Nebenläufigkeit (Concurrency im Englischen) ist ein heißes Thema in der heutigen Welt und Go unterstützt die Eigenschaft als ein zentraler Aspekt der Sprache.

Goroutinen

Goroutinen und Nebenläufigkeit sind zwei wichtige Komponenten im Design von Go. Sie ähneln Threads, funktionieren aber auf eine andere Weise. Ein dutzend Goroutinen haben vielleicht nur fünf oder sechs zugrundeliegende Threads. Des Weiteren unterstützt Go vollständig das Teilen von Speicherressourcen zwischen den Goroutinen. Eine Goroutine nimmt gewöhnlicherweise etwa 4 bis 5 KB Speicher ein. Daher ist es nicht schwer, tausende von Goroutinen auf einem einzelnen Computer zu nutzen. Goroutinen sind weniger ressourcenhungrig, effizienter und geeigneter als Systemthreads.

Goroutinen laufen im Thread Manager während der Laufzeit von Go. Wir nutzen das Schlüsselwort go, um eine neue Goroutine zu erstellen, wobei es sich eigentlich um eine interne Funktion von Go handelt ( main() ist ebenfalls eine Goroutine ).

go Hallo(a, b, c)

Schauen wir uns ein Beispiel an.

package main

import (
    "fmt"
    "runtime"
)

func sag(s string) {
    for i := 0; i < 5; i++ {
        runtime.Gosched()
        fmt.Println(s)
    }
}

func main() {
    go sag("Welt") // Erzeugt eine neue Goroutine
    sag("Hallo")   // Aktuelle Goroutine
}

Ausgabe:

Hallo
Welt
Hallo
Welt
Hallo
Welt
Hallo
Welt
Hallo

Wie es scheint, ist es sehr einfach, Nebenläufigkeit in Go durch das Schlüsselwort go zu nutzen. Im oberen Beispiel teilen sich beide Goroutinen den selben Speicher. Aber es wäre besser, diesem Rat folge zu leisten: Nutze keine geteilten Daten zur Kommunikation, sondern kommuniziere die geteilten Daten.

runtime.Gosched() bedeutet, das die CPU andere Goroutinen ausführen und nach einiger Zeit an den Ausgangspunkt zurückkehren soll.

Das Steuerungsprogramm nutzt einen Thread, um alle Goroutinen auszuführen. Das bedeutet, dass einzig dort Nebenläufigkeit implementiert wird. Möchtest Du mehr Rechnenkerne im Prozessor nutzen, um die Vorteile paralleler Berechnungen einzubringen, musst Du runtime.GOMAXPROCS(n) aufrufen, um die Anzahl der Rechenkerne festzulegen. Gilt n<1, verändert sich nichts. Es könnte sein, dass diese Funktion in Zukunft entfernt wird. Für weitere Informationen zum verteilten Rechnen und Nebenläufigkeit findest Du in diesem Artikel.

Channels

Goroutinen werden im selben Adressraum des Arbeitsspeichers ausgeführt, sodass Du in den Goroutinen die genutzen Ressourcen synchronisieren musst, wenn diese geteilt werden sollen. Aber wie kommuniziere ich zwischen verschiedenen Goroutinen? Hierfür nutzt Go einen sehr guten Mechanismus mit dem Namen channel. channel ist wie eine bidirektionale Übertragungsleitung (Pipe) in Unix-Shells: nutze channel um Daten zu senden und zu empfangen. Der einzige Datentyp, der in Kombination mit diesen Datenkanälen genutzt werden kann, ist der Typ channel und das Schlüsselwort chan. Beachte, dass Du make brauchst, um einen neuen channel zu erstellen.

ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})

channel nutzt den Operator <-, um Daten zu senden und zu empfangen.

ch <- v    // Sende v an den Kanal ch.
v := <-ch  // Empfange Daten von ch und weise sie v zu

Schauen wir uns weitere Beispiele an.

package main

import "fmt"

func summe(a []int, c chan int) {
    gesamt := 0
    for _, v := range a {
    gesamt += v
    }
    c <- gesamt  // Sende gesamt an c
}

func main() {
    a := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go summe(a[:len(a)/2], c)
    go summe(a[len(a)/2:], c)
    x, y := <-c, <-c  // Empfange Daten von c

    fmt.Println(x, y, x + y)
}

Das Senden und Empfangen von Daten durch die Datenkanäle wird standardmäßig gestoppt, um die Goroutinen einfach synchron zu halten. Mit dem Blocken meine ich, dass eine Goroutine nicht weiter ausgeführt wird, sobald keine Daten mehr von einem channel empfangen werden (z.B. value := <-ch) und andere Goroutinen keine weiteren Daten über den entsprechenden Kanal senden. Anderseits stoppt die sendende Goroutine solange, bis alle Daten (z.B. ch<-5)über den Kanal empfangen wurden.

Gepufferte Channels

Eben habe ich die nicht-gepuffter Datenkanäle vorgestellt. Go unterstützt aber auch gepufferte Channel, die mehr als ein Element speichern können, z.B. ch := make(chan bool, 4). Hier wurde ein Channel mit der Kapazität von vier Booleans erstellt. Mit diesem Datenkanal sind wir in der Lage, vier Elemente zu senden, ohne das die Goroutine stoppt. Dies passiert aber bei dem Versuch, ein fünftes Element zu versenden, ohne das es von einer Goroutine empfangen wird.

ch := make(chan type, n)

n == 0 ! nicht-gepuffert(stoppt)
n > 0 ! gepuffert(nicht gestoppt, sobald n Elemente im Kanal sind)

Experimentiere mit dem folgenden Code auf Deinem Computer und verändere die Werte.

package main

import "fmt"

func main() {
    c := make(chan int, 2)  // Setze 2 auf 1 und Du erzeugst einen Laufzeitfehler. Aber 3 ist OK.
    c <- 1
    c <- 2
    fmt.Println(<-c)
    fmt.Println(<-c)
}

Range und Close

Wir können range in gepufferten Kanlen genauso nutzen, wie mit Slices und Maps.

package main

import (
    "fmt"
)

func fibonacci(n int, c chan int) {
    x, y := 1, 1
    for i := 0; i < n; i++ {
        c <- x
        x, y = y, x + y
    }
    close(c)
}

func main() {
    c := make(chan int, 10)
    go fibonacci(cap(c), c)
    for i := range c {
        fmt.Println(i)
    }
}

for i := range c wird nicht eher mit dem Lesen von Daten aus dem Channel aufhören, ehe dieser geschlossen ist. Wir nutzen das Schlüsselwort close, um den Datenkanal im oberen Beispiel zu schließen. Es ist unmöglich, Daten über einen geschlossenen Channel zu senden oder zu empfangen. Mit v, ok := <-ch kannst Du den Status eines Kanals überprüfen. Wird ok auf false gesetzt, bedeutet dies, dass sich keine weiteren Daten im Channel befinden und er geschlossen wurde.

Denke aber immer daran, die Datenkanäle auf seiten der Datenproduzenten zu schließen und nicht bei den Empfängern der Daten. Andernfalls kann es passieren, dass sich Dein Programm in den Panikmodus versetzt.

Ein weiterer Aspekt, den wir nicht unterschlagen sollten, ist, dass Du Channels nicht wie Dateien behandeln solltest. Du brauchst sie nicht andauernd schließen, sondern erst, wenn Du sicher bist, dass sie nicht mehr gebraucht werden oder Du das Abfragen der übertragenen Daten mit dem Schlüsselwort range beenden willst.

Select

In den vorherigen Beispielen haben wir bisher immer nur einen Datenkanal verwendet, aber wie können wir Gebrauch von mehreren Channels machen? Go erlaubt es, mit dem Schlüsselwort select viele Kanäle nach Daten zu belauschen.

select stoppt standardmäßig eine Goroutine und wird einzig ausgeführt, wenn einer der Channels Daten sendet oder empfängt. Sollten mehrere Kanäle zur gleichen Zeit aktiv sein, wird ein zufälliger ausgewählt.

package main

import "fmt"

func fibonacci(c, quit chan int) {
    x, y := 1, 1
    for {
        select {
        case c <- x:
            x, y = y, x + y
        case <-quit:
        fmt.Println("Fertig")
            return
        }
    }
}

func main() {
    c := make(chan int)
    quit := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println(<-c)
        }
        quit <- 0
    }()
    fibonacci(c, quit)
}

select hat ebenfalls einen Standardfall wie switch, mit dem Namen default. Wenn kein Datenkanal aktiv sein sollte, wird der Standardfall ausgeführt (es wird auf keinen Kanal mehr gewartet).

select {
case i := <-c:
    // Benutze i
default:
    // Dieser Code wird ausgeführt, sollte c gestoppt worden sein
}

Zeitüberschreitung

Manchmal kann es vorkommen, dass eine Goroutine gestoppt wird. Wie können wir verhindern, dass daraus resultierend das gesamte Programm aufhört zu arbeiten? Es ist ganz einfach. Es muss lediglich eine Zeitüberschreitung in select festgelegt werden.

func main() {
    c := make(chan int)
    o := make(chan bool)
    go func() {
        for {
            select {
                case v := <- c:
                    println(v)
                case <- time.After(5 * time.Second):
                    println("Zeitüberschreitung")
                    o <- true
                    break
            }
        }
    }()
    <- o
}

Runtime goroutine

Das Paket runtime beinhaltet ein paar Funktionen zum Umgang mit Goroutinen.

  • runtime.Goexit()

    Verlässt die aktuelle Goroutine, aber verzögerte Funktionen werden wie gewohnt ausgeführt.

  • runtime.Gosched()

    Lässt die CPU vorerst andere Goroutinen ausführen und kehrt nach einiger Zeit zum Ausgangspunkt zurück.

  • runtime.NumCPU() int

    Gibt die Anzahl der Rechenkerne zurück.

  • runtime.NumGoroutine() int

    Gibt die Anzahl der Goroutinen zurück.

  • runtime.GOMAXPROCS(n int) int

    Legt die Anzahl der Rechenkerne fest, die benutzt werden sollen.

results matching ""

    No results matching ""