Suche
Die Golumne

Go Concurrency – nebeneinander und doch miteinander

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

In der Welt der Programmiersprachen ist Googles Spross Go ein noch recht junger Vertreter. Doch in den acht Jahren seit ihrer Vorstellung erreichte die Sprache immer mehr Entwickler. War ihr Einsatz anfangs noch auf die Cloud und Systemprogramme fokussiert, so findet man Go nun zunehmend auch in anderen Anwendungsbereichen wieder. In den bekannten Statistiken von Tiobe, GitHub, Stack Overflow, RedMonk, IEEE oder dem PYPL-Index rückt Go kontinuierlich Platz um Platz vorwärts. Ist Google damit ein guter, wenn nicht gar „der“ C-Nachfolger gelungen? Eine kurze Vorstellung der Sprache bildet den Auftakt zu unserer neuen Kolumne.

Durch die Ähnlichkeit zu C fallen so einige Besonderheiten von Go nicht unmittelbar ins Auge. Eine imperative Grundstruktur, eine einfache Form der Objektorientierung und Closures sind an sich schon ganz gut. Doch eine der größten Stärken der Sprache liegt in ihrer Fähigkeit zur Nebenläufigkeit.

Was sich hinter Nebenläufigkeit verbirgt, ist vielen oft nicht so richtig bewusst. Prozesse, die nebeneinander arbeiten, sind in Betriebssystemen allerdings seit Langem bekannt. Und auf feinerer Ebene innerhalb der Prozesse auch in Form von Threads. Für so einen armen kleinen Prozessor ist der Wechsel zwischen beidem, sowohl zwischen Prozessen als auch zwischen Threads, etwas sehr Teures und auch auf Mehrkernsystemen nicht gerade das Einfachste. Also führt Go als eine der nebenläufigen Sprachen etwas noch Leichtgewichtigeres ein: Goroutines. Sie werden durch den Scheduler auf einen Threadpool verteilt und wurden für einen schnellen Wechsel zwischen ihnen entworfen. So können sehr leicht tausende dieser kleinen Routinen innerhalb eines Prozesses betrieben werden.

In ihrer Nutzung gibt es unterschiedliche Formen: Natürlich können Funktionen so als Goroutines gestartet werden, dass sie eine Aufgabe, ein Teilprogramm, abarbeiten und sich dann wieder beenden. Spannender wird es jedoch, wenn sie im Hintergrund aktiv sind und eingehende Daten individuell verarbeiten können. Sprachen wie Erlang/OTP folgen dabei dem Aktorenmodell. Hier werden Nachrichten an die Prozesse verschickt; diese interpretieren die so erhaltenen Daten und führen entsprechende Funktionen aus. Go jedoch folgt der von Tony Hoare entwickelten Idee der Communicating Sequential Processes, kurz CSP. Hierbei kommen neben den Goroutines noch Channels zum Einsatz. Dies sind Datentypen für den Versand von Daten zwischen verschiedenen Goroutines. Innerhalb der jeweiligen Goroutine selbst werden diese Channels dann beispielsweise durch ein in einer Endlosschleife betriebenes select-Statement abgefragt. Der Start der Goroutine erfolgt mit dem sprechenden Kommando go. Listing 1 zeigt beispielhaft eine Goroutine zur Begrüßung ihr zugesandter Personen.

func greeter(nameC chan string, stopC chan bool) {
  count := 1
  last := "none"
  for {
    select {
    case name := <-nameC:
      fmt.Printf("Hello, %s! ", name)
      fmt.Printf("You're number %d. ", count)
      fmt.Printf("Last one has been %s.\n", last)
      count++
    case <-stopC:
      return
    }
  }
}

Sie wird mit den zwei Channels zur Kommunikation gestartet:

nameC := make(chan string)
stopC := make(chan bool)

go greeter(nameC, stopC)

Im Hintergrund arbeitend wartet sie auf Namen und begrüßt sie individuell. Gleichzeitig wird ein Zähler hochgezählt. Erst eine Nachricht über den Stoppchannel beendet die Goroutine:

nameC <- "World"
nameC <- "Chip"
nameC <- "Chap"

stopC <- true

Doch warum ist dies nun besonders? Ein einfacher Funktionsaufruf täte es doch auch. Beim Einsatz in einem Programm mit mehreren Threads bestünde dann jedoch die wunderbare Gefahr der Race Conditions. Die in diesem Fall außerhalb der Funktion angesiedelten Variablen für den Zähler und den letzten Namen könnten unkorrekt verändert werden. So entstehen inkonsistente Zustände.

Dieser bekannten Problematik werden in herkömmlichen Systemen Mutexe beziehungsweise Semaphoren entgegengesetzt. Sie erlauben einem Thread, den Zugriff durch andere Threads zu blockieren, ihre Änderungen durchzuführen und den Zugriff dann wieder freizugeben. Go verfügt natürlich auch über diese Möglichkeit der Synchronisation. Hier bleibt zwar der Vorteil der leichtgewichtigen Goroutines, doch wie in Systemen mit Threads blockiert auch in diesem Fall der Aufrufer vollständig. Sofern auf berechnete Ergebnisse gewartet werden soll, ist dies einwandfrei. Doch die Variante mit Channels erlaubt zusätzlich den Einsatz von Puffern. nameC := make(chan string, 16) erzeugt den Channel mit einer Puffergröße von 16 Strings, ein Sender blockiert in diesem Fall nicht. Erst wenn der Leser zu langsam ist und so auch der Puffer gefüllt, wird schließlich auch hier der Sender angehalten.

Warum das Ganze?

Der Neuling in Go denkt sich schnell, dass die Goroutines für eine Parallelverarbeitung praktisch sind, und natürlich ist dies durchaus möglich. Manche Aufgaben lassen sich wunderbar in Teiloperationen zerlegen und deren Ergebnisse letztendlich wieder sammeln. Oder man lässt einen Webserver quasi parallel die eingehenden Requests verarbeiten. Doch wie sagte schon Rob Pike: „Concurrency is not parallelism“. Die Nebenläufigkeit entspricht mit ihrem Nachrichtenversand vielmehr der ursprünglichen Idee der Objektorientierung. Instanzen kapseln Informationen und verarbeiten Nachrichten, so das Modell. Nicht umsonst spricht Smalltalk hier von Messages. Und eine Anwendung setzt sich aus vielen derartig arbeitenden Instanzen unterschiedlichster Natur zusammen, teils miteinander arbeitend, teils nebeneinander. Ein wacher Blick durch die Umwelt zeigt eine Vielzahl derartiger Systeme überall um uns herum.

Gopher ante portas: Go hält Einzug auf JAXenter!

Wenige (relativ) neue Programmiersprachen erfreuen sich derzeit einer so großen Belibtheit wie Go. Googles Alternative für „C“ ist immer häufiger Zentrum interessanter Anwendungen und mittlerweile im Mainstream angekommen. Grund genug, sich ein wenig eingehender mit der Sprache zu befassen. Folgende Artikel, Kolumnen und Infografiken sind Teil unserer aktuellen Go-Aktion auf JAXenter:

Happy Gophing!

Doch warum dann nicht einfach gleich objektorientierte Sprachen nutzen wie bisher? Der Ausflug in die Threads hat es gezeigt: Instanzen und Threads arbeiten orthogonal, und der Entwickler muss sich manuell um die Zuordnung der Rechenzeit zu den Funktionen kümmern. Ebenso obliegt ihm der Schutz der Ressourcen durch Mutexe, was leicht zu Fehlern führen kann. Ein Verzicht auf das Nebeneinander ist in Zeiten von Mehrkernsystemen ebenfalls keine adäquate Lösung. Hier sind wir alle doch über die gewonnenen Fähigkeiten der CPUs froh und möchten sie auch gerne nutzen.

Der Einsatz einer leichtgewichtigen Nebenläufigkeit stellt sich in dem Fall anders dar. Das Konstrukt aus nachrichtenverarbeitender Goroutine und ihr Daten sendendem Nutzer wirkt auf den ersten Blick weniger elegant als Sprachen wie Smalltalk. Doch dafür ist es flexibler und mächtiger. Ein entsprechendes Design bildet dies zudem eleganter ab, wobei der Nutzer des Typs mit den Channels nicht in Berührung kommt. Sie werden oft als private Felder eines structs versteckt.

Als Beispiel dient ein einfacher Taschenrechner mit den vier Grundrechenarten. Er braucht zuerst einmal einen Typ für die Nachrichten, also eine kleine private Struktur mit dem Operator, den Argumenten und einem Kanal für das Ergebnis:

type operation struct {
operator string
value    float64
resultC  chan float64
}

Der Taschenrechner selbst ist ebenfalls eine Struktur. Spannend wird sie dann durch die entsprechenden Methoden (Listing 2).

type Calculator struct {
  register.  float64
  operationC chan *operation
  stopC chan struct{}
}

func NewCalculator() *Calculator {
  c := &Calculator{
    operationC: make(chan *operation),
    stopC: make(chan struct{}),
  }
  go c.loop()
  return c
}

Der Quit-Channel ist für Daten des Typs struct{}, da dieser Typ noch kleiner ist als ein Boolean. Er wird daher gerne für eine einfache Signalisierung verwendet. In der Constructor-Funktion wird nun zuerst der Typ Calculator initialisiert und dann die Goroutine als private Methode loop() gestartet (Listing 3). Nun kann der Rechner an den Nutzer zurückgegeben werden. Die Methode loop() entspricht dann dem bekannten Muster.

func (c *Calculator) loop() {
  for {
    select {
    case o := <-c.operationC:
      switch o.operation {
      case "add":
        o.register += o.value
        o.resultC <- o.register
      case "subtract":
        o.register -= o.value
        o.resultC <- o.register
      case "multiply":
        o.register *= o.value
        o.resultC <- o.register
      case "divide":
        o.register /= o.value
        o.resultC <- o.register
      }
    case <-c.stopC:
      return
    }
  }
}

Anschließend muss nur mehr die Kommunikation mit der Schleife durch entsprechende Funktionen abgebildet werden. Dadurch wird die Nutzung der Channels endgültig vor dem Nutzer versteckt (Listing 4).

func (c *Calculator) Add(value float64) float64 {
  o := &operation{
      operation: "add",
      value: value,
      resultC: make(chan float64),
  }
  o.operationC <- o
  return <-o.resultC
}

Dies auch noch für die anderen Operationen und für die Beendigung des Calculators eingestellt – und schwupp ist der nebenläufige Taschenrechner fertig. Er arbeitet elastisch mit seinen vielen nebenläufigen Kollegen von einem Kern nahtlos bis zu großen Mehrkernsystemen. Die Nutzung ist vollkommen transparent.

c := NewCalculator()
fmt.Printf("Add 1.0 = %f\n", c.Add(1.0))
fmt.Printf("Multiply 2.5 = %f\n", c.Multiply(2.5))
c.Stop()

Aber schwupp implementiert? Naja, eine kurze und elegante Implementierung war dies nicht gerade. Die Nebenläufigkeit sorgt zwar für ein leistungsfähiges System, doch sie bringt Overhead mit sich. Zudem fehlt im Beispiel die Fehlerkontrolle, zum Beispiel bei einem Teilen durch 0. Die Integration weiterer Operatoren oder von Fähigkeiten wie dem Zurücksetzen des Registers sind ebenfalls recht aufwendig. Trotz hilfreicher Bibliotheken muss dieser Overhead genau abgewogen werden, denn nicht für jede Aufgabe lohnt sich die Nebenläufigkeit, und der Bedarf wird durch den Kontext des jeweiligen Typs bestimmt.

Vereinfachung

Wenn sie eingesetzt werden soll, dann hängt der Grad der Komplexität der Lösung auch von den konkreten Anforderungen ab. Ein Beispiel wie das obige macht Sinn, wenn Daten unterschiedlichster Typen über entsprechende Channels verarbeitet werden sollen. Doch die sequenzielle Ausführung von Methoden wie beim Aktorenmodell funktioniert in Go ebenfalls. Den Start macht ein Funktionstyp, der den Rahmen der späteren Aufgaben bietet:

type Message func() bool

Weiter geht es mit einem Actor, der die ihm zugesandten Nachrichten eine nach der anderen ausführt (Listing 5).

type Actor struct {
  messageC chan Message
}

func NewActor() * Actor {
  a := &Actor{
    messageC: new(chan Message),
  }
  go a.loop()
  return a
}

func (a *Actor) Do(msg Message) {
  a.messageC <- msg
}

func (a *Actor) loop() {
  defer close(a.messageC)
  for msg := range a.messageC {
    if !msg() {
      return
    }
  }
}

Dieser Hilfstyp kann im Rahmen eigener nebenläufiger Typen eingesetzt werden. Hierfür wird der Actor Bestandteil der Version 2 unseres Taschenrechners (Listing 6).

type Calculator struct {
  register float64
  actor *Actor
}

func NewCalculator() *Calculator {
  return &Calculator{
    actor: NewActor(),
  }
}

Der Constructor macht die Vereinfachung schon mal deutlich. Auch die Realisierung des ersten Operators stellt sich noch einmal leichter dar (Listing 7).

func (c *Calculator) Add(value float64) float64 {
  var result float64
  c.actor.Do(func() bool {
    c.register += value
    result = c.register
    return true
  })
  return result
}

Wir schicken hier also einfach eine Funktion zur Ausführung in der Goroutine auf die Reise. Da immer nur eine ausgeführt wird, kann sie auf alle Felder der Struktur zugreifen. Gleichzeitig hat sie Zugriff auf Variablen des sie umgebenden Kontexts – ein klassisches Closure. In unserem Beispiel setze ich so das Ergebnis, das am Ende zurückgegeben wird (Listing 8). Dies erleichtert auch die Fehlerbehandlung, die im ersten Beispiel noch fehlte.

func (c *Calculator) Divide(value float64) (float64, error) {
  var result float64
  var err error
  c.actor.Do(func() bool {
    if value == 0.0 {
      err = errors.New("divide by zero")
      return true
    }
    c.register += value
    result = c.register
    return true
  })
  return result, err
}

Weitere Operatoren lassen sich in diesem Modell einfach und komfortabel als Methoden hinzufügen. Dabei sind auch komplexere Beispiele denkbar, mit Operationen außerhalb der eigentlichen Goroutine oder mehreren unabhängigen Messages zur atomaren Ausführung innerhalb einer Methode. Fragt sich an dieser Stelle, warum die Message einen Boolean zurückgibt.
Irgendwie soll unsere Goroutine auch beendet werden können. Dies kann auf diesem Weg ebenfalls einfach erreicht werden:

func (c *Calculator) Stop() {
c.actor.Do(func() bool { return false })
}

Alternativ kann der Actor natürlich auch mit mehreren Channels arbeiten und eine eigenständige Stop()-Methode erhalten. Eine weitere Variante könnte zusätzlich auch eine Methode für das asynchrone Ausführen von Messages anbieten oder bei synchronen Messages mit Time-outs arbeiten. Hier treffen die Konzepte der Nebenläufigkeit auf die Schule der Synchronisation – so macht Go Spaß. Die oben bereits referenzierte Bibliothek enthält auch dafür eine komplette Implementierung.

Zusammenspiel

Wie bereits erwähnt, besteht keine Notwendigkeit für eine Nebenläufigkeit bei jedem gekapselten Typ. Wesentlich ist der Scope. Erst bei einem Zugriff durch mehrere Goroutines gleichzeitig muss für eine Serialisierung gesorgt werden, und selbst dann ist ab und zu der Einsatz von Mutexen vollkommen hinreichend. Doch wie schon anfangs angedeutet, kann man mit nebenläufigen Konstrukten wunderbare Architekturen erstellen.

So lassen sich durch verschiedene Goroutines Daten nebeneinander aus unterschiedlichen Quellen empfangen. Mapper können diese aufbereiten, Filter ungewünschte Informationen entfernen und Weichen diese Daten je nach Inhalt unterschiedlichen Prozessoren zuordnen. Aggregatoren führen sie wieder zusammen, während Kombinatoren nach einer definierten Zusammenstellung suchen. Sender versenden die Daten an verschiedene Ziele. Hier ist noch eine Vielzahl weiterer vernetzter Komponenten denkbar. Mit gepufferten Channels kann jede von ihnen isoliert mit den anstehenden Daten arbeiten und muss bei einer Weiterleitung an eine oder mehrere Komponenten nicht auf deren Verarbeitung warten.

DevOpsCon Whitepaper 2018

Free: BRAND NEW DevOps Whitepaper 2018

Learn about Containers,Continuous Delivery, DevOps Culture, Cloud Platforms & Security with articles by experts like Michiel Rook, Christoph Engelbert, Scott Sanders and many more.

Doch auch einfachere Komponenten sind denkbar. Caches arbeiten im Hintergrund und nutzen Tickersignale für ihr Aufräumen, und Monitore empfangen Heartbeats oder Störungssignale durch andere Goroutines, um so bei Bedarf für ein robustes System zu sorgen. Factories führen ihre erzeugenden Operationen in einer Goroutine aus und liefern über einen Channel bei Bedarf ihre Erzeugnisse. Eine Crontab erlaubt das regelmäßige Ausführen von Funktionen, und auch das kontinuierliche Einlesen und Auswerten von Logdateien wird so problemlos möglich. Neben diesen mal mehr und mal minder komplexen Typen bleibt natürlich immer noch die einfache Ausführung einer komplexeren Aufgabe im Hintergrund, ohne dass dies die auftraggebende Goroutine blockiert. Entsprechende Channels oder nebenläufig designte Typen im Kontext der jeweiligen Funktionen erlauben dann, das Ergebnis an die Anwendung zurückzugeben.

Schnell wird deutlich, wie sehr sich eine Anwendung als ein Zusammenspiel vieler individueller Komponenten entwerfen lässt. Der Gedankengang der Kapselung, wie wir ihn von der Objektorientierung kennen, lässt sich so noch flexibler realisieren. Manches Mal muss initial Arbeit in einen entsprechenden Typ gesteckt werden, doch oft helfen existierende Packages, die Komplexität zu verstecken. Die Belohnung ist ein elastisch wachsendes System mit großartigem Antwortzeitverhalten. Eine Vielzahl von Lösungen im Web oder in den Clouds zeigt es.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: