iX 7/2018
S. 132
Praxis
Programmiersprachen
Aufmacherbild

Go-Tutorial, Teil 3: Nebenläufigkeit

Get-together

Für nebenläufige Funktionen nutzt Go leichtgewichtige Goroutinen. Sie erweitern im Beispielprogramm den „GitHub Progress Monitor“ und automatisieren Event-Analysen für verschiedene Repositories.

Eine Besonderheit von Google Go haben die beiden ersten Tutorialteile bisher nicht behandelt: die Handhabung nebenläufiger Funktionen. Viele Sprachen nutzen dafür Threads. Sie lassen sich gleichzeitig ausführen, die Kommunikation zwischen ihnen erfolgt in der Regel über Shared Memory, für die Synchronisation kommt ein Mutex-Verfahren zum Einsatz. Jedoch haben Kontextwechsel zwischen Threads einen gewissen Overhead, und Fehler bei der Nutzung der Mutexe können schnell zu Deadlocks führen. Verzichtet man auf sie, muss man hingegen mit inkonsistenten Daten rechnen.

Als Lösung für dieses Dilemma setzt Go Goroutinen ein, leichtgewichtige Funktionen, die ein Multiplexer auf einer Menge von Threads verteilt. Besteht die Gefahr, dass eine Goroutine blockiert, etwa bei der Ausführung eines Systemaufrufs, würden die dem gleichen Thread zugeordneten Goroutinen ebenfalls blockiert. In diesem Fall verschiebt die Laufzeitumgebung die Goroutine auf einen freien Thread und verhindert so die Blockade. Der Entwickler muss hierfür keine Vorsorge treffen. Mit ihrem geringen Overhead gegenüber Threads ist der gleichzeitige Betrieb Tausender Goroutinen auch auf kleinen Systemen möglich und skaliert zudem nahtlos mit der Anzahl der Kerne.

Channels machen den Unterschied

Listing 1: Start von Goroutinen

// Start a simple function.
go CopyFile("foo.txt", "bar.txt")

// Start a method.
go myAnalyzer.Process(input, output)

// Start a local function.
go func() {
    in := readFile("in.txt")
    out := process(in)
    writeFile("out.txt", out)
}()

Der Start einer solchen Goroutine erfolgt über das Schlüsselwort go (Listing 1). Hiermit lässt sich ein Funktions- oder Methodenaufruf in den Hintergrund schicken. Eine Referenz wird nicht zurückgegeben. Damit kann eine Goroutine nicht einfach von außen über ein Schlüsselwort gestoppt werden. Ebenso wird ein return einer Goroutine verworfen. In beiden Fällen kommen Channels für Signale sowie für die Ein- und Ausgabe von Daten zum Einsatz.

So weit ist dies nicht ungewöhnlich. Und für die entsprechenden Anwendungsfälle enthält Gos Standardbibliothek in den Packages sync und sync/atomic Helfer für eine traditionelle Synchronisation. Doch das eigentlich Besondere der Sprache liegt hier in den Channels – generischen Datentypen wie Arrays, Slices und Maps. Sie werden bei der Deklaration spezifiziert und mit make() angelegt. Ein optionaler Parameter steuert, ob ein Channel einen Puffer erhalten soll. Ohne Puffer blockiert der Schreiber, bis seine Daten aus dem Channel ausgelesen sind. Mit Puffern wird die Ausführung unmittelbar nach dem Schreiben fortgesetzt, solange im Puffer noch Platz ist.

Einen Channel für Strings erzeugt die Zeile

myChan := make(chan string) 

Die Anweisung

myChan <- "foo"

schreibt den String "foo" in den Channel. Der Operator für das Empfangen ist ebenfalls ein Pfeil, jedoch sind die einzelnen Elemente der Anweisung anders angeordnet. Mit

data := <-myChan 

lassen sich Daten aus dem Channel lesen und der Variablen data zuordnen.

Listing 2: Ergebnisrückgabe

// Start finding answer in background.
answerChan := make(chan Answer)

go findAnswerToAllQuestions(answerChan)

// Do something else.
...

// Read answer.
answer := <-answerChan

Ein sehr einfacher Anwendungsfall für den Einsatz von Channels ist die oben angesprochene Rückgabe von Ergebnissen. Ein erzeugter Ergebnis-Channel wird der Goroutine mit übergeben und später wieder ausgelesen (Listing 2).

Listing 3: Timeout

select {
case answer := <-answerChan:
    // Process answer.
    ...
case <-time.After(5*time.Second):
    // Handle timeout.
    ...
}

Doch was muss man tun, wenn das Warten auf die Antwort mit einem Timeout versehen werden soll? Einen direkten Parameter für den Empfangsoperator gibt es nicht, jedoch kann Go über das select-Statement auf mehreren Channels zeitgleich lauschen. Zusammen mit der Funktion time.After(Duration) <-chan Time aus dem Package time lässt sich so leicht ein Timeout realisieren (Listing 3).

Channels versenden auch Channels

Listing 4: Aneinanderfügen von Strings

package appender

type Appender struct {
    content     string
    
    AppendChan  chan string
    ContentChan chan chan string
    ResetChan   chan string
}

func New(content string) *Appender {
    a := &Appender{
        content:     content,
        AppendChan:  make(chan string),
        ContentChan: make(chan chan string),
        ResetChan:   make(chan string),
    }
    go a.backend()
    return a
}

Diese Form, aus mehreren Channels parallel Daten zu empfangen, kommt zudem bei einer anderen typischen Spielart von Goroutinen zum Einsatz. Sie bildet das Backend für die oben angedeutete Form der Objekte, die über Channels ihre Nachrichten empfangen und wie gewünscht agieren. Ein kleiner Typ für das Aneinanderfügen von Strings soll dies demonstrieren. Er enthält drei offene Channels für den anzufügenden Inhalt, für die Abfrage des erzeugten Strings und für das Zurücksetzen auf einen neuen Wert (Listing 4).

Listing 5: Channel-Abfrage

func (a *Appender) backend() {
    for {
        select {
        case content := <-a.AppendChan:
            a.content = append ⤦
 (a.content, content)
        case contentChan := <- ⤦
 a.ContentChan:
            contentChan <- a.content
        case content := <-a.ResetChan:
            a.content = content
        }
    }
}

Listing 6: Zusammenspiel der Goroutinen

// Goroutine A.
a := appender.New("foo")

// Goroutine B.
a.AppendChan <- "bar"
a.AppendChan <- "baz"

// Goroutine C.
a.AppendChan <- "yadda"

// Goroutine A.
resultChan := make(chan string)
a.ContentChan <- resultChan
result := <-resultChan
a.ResetChan <- ""

Auffällig ist hier der ContentChan. Er zeigt, dass sich auch Channels über Channels versenden lassen, hier ein chan string. So erhält der Appender einen Weg, auf dem er seinen Inhalt an den Aufrufer zurücksenden kann. Die Goroutine ist hier in der Methode backend() implementiert (Listing 5). Sie betreibt eine Endlosschleife, in der sie in einem select die Channels abfragt und entsprechend handelt. Nun ist die Nutzung eines Appender durch unterschiedliche Goroutinen über dessen Referenz sehr einfach (Listing 6).

Doch Obacht: Welches Ergebnis gibt die Goroutine nun in result zurück? Die Eleganz der sequenziellen Ausführung der Kommandos innerhalb der Instanz des Appender darf nicht über potenzielle Race Conditions hinwegtäuschen. "foobarbazyadda" ist ebenso möglich wie "fooyaddabarbaz" oder "foobaryaddabaz". Beim Design von Goroutinen muss man daher beachten, dass Operationen geschlossen durchzuführen und zusammenhängend zu ändernde Daten auch zusammenhängend zu übertragen sind. Ein SetA(), SetB() und GetAB() lädt zu inkonsistenten Zuständen ein. Glücklicherweise bringt das Go Toolset einen Race Detector mit.