Channels in Go: Bequem parallel

Seite 4: Effektivität und Stolperfallen

Inhaltsverzeichnis

Die bisherigen Beispielprogramme haben nur Booleans und Strings über Channels gesendet. Das ist selbst bei langen Zeichenketten kein Problem, denn Go verwaltet sie intern als Struktur, die Länge und einen Pointer auf unveränderliche Daten enthält. Dabei übermittelt der Code nur Pointer und Länge. Der Empfänger kann den String wegen des Datentyps nicht verändern.

Anders verhält es sich, wenn Programme mit beliebig großen Ganzzahlen aus dem Paket math/big rechnen. Ein Beispiel: Bei einer Anwendung stellt man per Profiling (Paket runtime/pprof) fest, dass die Division den Löwenanteil der Rechenzeit benötigte. Auch hier bietet sich die Parallelisierung an, die Go-Routinen erhalten eine Struktur mit Zähler und Nenner. Sie senden die Ergebnisse jeweils an eine weitere Go-Routine, die die Quotienten aufsummieren. Zwar übergibt auch dieses Programm die Daten wieder nur als Pointer, aber die Empfänger ändern diesmal die übermittelten Werte. Deshalb muss das Programm die Werte vor dem Rechnen auf lokale Variable kopieren. Diese Umsetzung ist effektiv: Es legt die lokalen Variablen nur einmal an und überscheibt sie immer wieder, da es die Variablen in den Go-Routinen außerhalb einer Schleife definiert. Go erzwingt im Unterschied zu Rust aber ein derartiges lokales Umkopieren nicht. Andererseits lassen sich Go-Programme meist übersichtlich und einfach aufbauen, wodurch Denkfehler schneller sichtbar werden.

Weil Go-Routinen sehr schlank sind, ist es auch kein Problem, selbst einige tausend parallel laufen zu lassen. Allerdings gilt es dabei zu bedenken, dass der Start vieler Routinen – und mehrfaches Beenden – Zeit und Speicher verbraucht. Ebenso kostet das Senden über Channels mehr Zeit als ein simpler Funktionsaufruf. Es ist also durchaus sinnvoll, größere Datenmengen an einer Stelle zu halten und die Freigabe für konkurrierende Routinen per Channel oder per Mutex zu regeln. Wie der folgende Code-Ausschnitt zeigt, lauert eine weitere Falle häufig in der richtigen Reihenfolge von Anweisungen:

    close(fnchan)
    wg.Wait()
    close(prtout)
    <-done

In diesem Beispiel schließt das Programm zunächst den Kanal fnchan, der range-Schleifen in mehreren Go-Routinen beendet. Danach wartet der Code per WaitGroup, bis alle Go-Routinen beendet sind. Erst dann haben die Schleifen wirklich ihre Ergebnisse per Channel prtout an einen Empfänger geschickt, der nun per close(prtout) beendet wird. Davor schickt dieser Empfänger aber über den Bool-Channel done eine Meldung an main() zurück: "Ich bin fertig, das Ergebnis kann verwendet werden." Der nächste Abschnitt zeigt, was bei Fehlern in der Reihenfolge passiert.

Das Entwicklungstempo in einer Sprache hängt wesentlich davon ab, wie schnell das Entwicklerteam Fehler findet. Der Go-Compiler besticht durch klare, fast immer einzeilige Meldungen, wodurch man erstaunlich schnell zu einem kompilierbaren Programm kommt. Wer sich je mit seitenlangen, unverständlichen Template-Fehlern in C++ herumschlagen musste, weiß das zu schätzen. Doch an dieser Stelle geht es um die berüchtigten Race Conditions, bei denen der Compiler und in der Regel selbst ein Debugger nicht helfen können: Solche Fehler hängen von der zufälligen Abarbeitungsreihenfolge ab. Dafür bietet das Runtime-System von Go mit der Deadlock-Erkennung zur Laufzeit eine nützliche Besonderheit. Sie schlägt an, wenn Channels keinen aktiven Sender oder Empfänger mehr haben. Wenn man im letzten Beispiel zwei Anweisungen tauscht (etwa ein close() und das Wait()), endet das Programm mit einem präzisen Stacktrace, wie ihn das nächste Listing zeigt. Anhand von Dateinamen und Zeilennummern kann man in der Regel schon herausfinden, wo der Denkfehler steckt:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /datadisk/wobst/tmp/tmp/picscale.go:337 +0x6a6

goroutine 6 [chan receive]:
main.main.func3(0x0?)
        /datadisk/wobst/tmp/tmp/picscale.go:277 +0x89
created by main.main
        /datadisk/wobst/tmp/tmp/picscale.go:276 +0x233
exit status 2

Die präzise Quellangabe des Fehlers durch die Deadlock-Erkennung bei Go.

Grundsätzlich ist eine Deadlock-Erkennung nicht trivial. So weiß Go zum Beispiel, dass das vergebliche Warten auf ein Netzwerkinterface nicht dazu gehört. In der Praxis kommen Programmierer und Programmiererinnen überraschend schnell zum Ziel – ganz im Unterschied zu Sprachen, bei denen das Programm einfach hängenbleibt. Für Race Conditions im engeren Sinne (genauer: Data Races) steht hingegen die Compile- und Laufzeitoption -race zur Verfügung:

  go run -race program.go
 	 oder
  go build -race _paketname_

Sie verlangsamt die Ausführung erheblich bis zu 20-fach, entdeckt aber nicht synchronisierte Schreib-/Lesezugriffe auf Speicherbereiche. Solche Fehler werden durch saubere Verwendung von Channels zwar unwahrscheinlicher, aber wenn sie auftreten, ist häufig guter Rat teuer. Allerdings kann -race nur anschlagen, wenn ein solcher Fehler tatsächlich auftritt, was vom Timing abhängt. Doch dann steht wenigstens eine Diagnose zur Verfügung.