Bereits 2012 veröffentlicht, fristete Go lange Zeit ein Nischendasein. Dann mauserte es sich durch Vorzeigeprojekte wie Docker schnell zur Quasi-Standardsprache für systemnahe Programmierung.
Im Jahr 2012 brachten die Unix- und C-Urgesteine Robert Griesemer, Rob Pike und Ken Thompson unter der Ägide von Google die systemnahe Programmiersprache Go heraus. Sie exisiterte lange Zeit nur in der Nische, mauserte sich aber schließlich zum Quasi-Standard für systemnahe Programmierung. Heute reiben sich Beobachter der Unix-Szene die Augen, wie viele Tools mittlerweile aus Source-Code in Go entstanden sind.
Eine Programmiersprache mit einem alltäglichen Wort wie Go zu benennen, erscheint aus Sicht eines Suchmaschinenbetreibers als nachgerade idiotische Idee, weil Such-Engines solche Füllwörter eigentlich aus Abfragen entfernen. Deshalb empfiehlt es sich, auf der Suche nach Go-Programmiertipps nach Golang zu suchen, was sich auch in der Community als Bezeichnung für die Sprache durchgesetzt hat.
Schnell installiert
Wer Go ausprobieren möchte, greift am einfachsten auf ein Paket seiner Lieblings-Distro zu, unter Ubuntu etwa per »sudo apt install golang«. Nach der Installation steht mit »go build« ein superschneller Go-Compiler bereit, mit »gofmt« ein Formatierer, mit »go doc« ein Renderer für Manual-Seiten und vieles andere mehr.
Go bietet ein ausgereiftes Entwicklungs-Framework, eine riesige Standardbibliothek zum Erledigen typischer Programmieraufgaben, Unterstützung für automatische Tests sowie eine rege Community, die neue Libraries auf Github ablegt, von wo man sie einfach per Compiler-Aufruf in eigene Applikationen einbindet.
Im folgenden kurzen Abriss zählen wir lediglich die wichtigsten Pluspunkte auf; viele davon kommen in der Tradition von Endlosdiskussionen vom Schlage “Emacs oder Vi” immer wieder hoch. Möchten Sie tiefer einsteigen, empfehlen wir Ihnen das interaktive Tutorial auf https://tour.golang.org oder das ausgezeichnete Originalbuch von einem der Go-Macher [1].
Gofumpt und basta!
Die Internet-Gemeinschaft hat über die Jahre viel Energie damit verschwendet, endlos über die korrekte Zahl der Einrückungen oder Leerzeichen zwischen Schlüsselwörtern zu diskutieren oder sogar über den “richtigen” Editor.
Dem strikten Beispiel von Python teilweise folgend, legt die Go-Community die Formatierung des Codes fest. Der Compiler motzt zwar nicht, falls jemand nun Tabs statt vier oder acht Leerzeichen zum Einrücken verwendet, aber die Community besteht prinzipiell darauf, jeglichen Code erst durch den Prettyfier Gofmt zu jagen, bevor er irgendwo online in einem Repository wie Github erscheint. Der Formatierer legt rigoros Tabs zur Einrückung fest, macht keine Leerzeichen zwischen runden Klammern und Text, setzt aber um Punktuation wie »+« oder »=« Leerzeichen zur leichteren Lesbarkeit. Diskussion gibt es keine, es wird einfach so gemacht.
Ähnliches gilt für den Camel Case bei Variablen und Funktionen: »geoSearch()« ist in, »geo_search()« out. Zudem spielt es eine Rolle, ob man eine Variable in einer Struktur oder einem Paket groß oder klein schreibt: Erstere exportiert Go aus dem aktuellen Context, Letztere bleibt privat.
Ergebnis oder Fehler?
Ein heißes Endlosthema bei Programmiersprachen ist das Für und Wider von Exceptions (Java, Python) gegenüber zurückgereichten Error-Codes. Go sieht sich in der Tradition der Klassiker wie C (bei der Autorenliste eigentlich klar) und wertet bei jedem Funktionsaufruf den Fehlerwert aus – allerdings mit einem Twist: Da Funktionen in Go mehrere Werte zurückgeben können, kommt neben einem Ergebnis meist ein Fehlercode zurück wie zum Beispiel bei der Funktion »ReadFile()« aus der Go-Standardbibliothek »os« (Listing 1).
Listing 1
readfile.go
package main
import (
"log"
"os"
)
func main() {
data, err := os.ReadFile("/tmp/dat")
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(data)
}
Der erste Rückgabewert »data« enthält nach einem erfolgreichen Lesevorgang die Daten der angegebenen Datei in einem Array vom Typ »[]byte«. Geht allerdings etwas schief, kommt als zweiter Wert in der Variablen »err« ein Fehler zurück. Der Code des Hauptprogramms prüft dieses Ergebnis vorschriftsgemäß mit »err != nil«, was im Fall eines Fehlers für die If-Bedingung einen wahren Wert liefert, denn im Erfolgsfall ist »err« auf »nil« gesetzt.
Im Fehlerfall sollte man übrigens den anderen Datenrückgabewert (»data«) nicht verwenden. Er ist aber meist ohnehin auf den in Go üblichen Null-Value gesetzt, den Variablen annehmen, nachdem sie zwar deklariert, aber noch nicht initialisiert sind. Im Beispiel eines Arrays vom Typ »[]byte« ist dies ein leeres Array.
Batteries included
Als wirklich praktisch stellen sich die All-inclusive-Binaries heraus, die der Go-Compiler produziert. Wer zum Beispiel ein Go-Programm auf einem Shared Server des Billighosters laufen lassen will, kompiliert es einfach in Ruhe auf dem heimischen Rechner, gern auch in einem Docker-Container oder einem Mac, und kopiert eine einzige Datei hoch, die dort ohne Murren läuft. Kein Hinterherhecheln nach irgendwelchen Abhängigkeiten, keine Probleme mit Shared Libraries oder zusätzlich benötigten Modulen, und selbstverständlich braucht man keine Root-Rechte zur Installation.
Das mag als Lösung eines relativ trivialen Problems erscheinen. Wer aber schon einmal versucht hat, ein selbst geschriebenes Python-Skript bei einem Kunden zu installieren, der wahlweise nicht die richtige Python-Version, nicht alle erforderlichen Pakete oder vielleicht sogar keine Verbindung ins Internet hatte, um diese fehlende Infrastruktur nachzuziehen, der wird ein einziges lauffertiges Binary als Heilsbringer begrüßen.
Wer seine Software öffentlich vertreibt und den Anwendern das Kompilieren aus dem Go-Source-Code ersparen möchte, der kann auch ein Binary zum Download auf einer Webseite anbieten. Wohlgemerkt: Nur ein Binary für alle Linux-Varianten – und dann vielleicht noch eines für den Mac. Und eventuell sogar ein drittes für den Raspberry Pi auf ARM-Basis. Dabei muss der Build-Rechner nicht einmal die Architektur der Zielplattform fahren. Wer auf dem Mac ein Linux-Binary erzeugen möchte, der macht das mit: »GOOS=linux GOARCH=386 go build …«, denn Go beherrscht das Cross-Kompilieren perfekt. Sogar Windows-Binaries kann der Tausendsassa.
Zwar belegen Go-Binaries naturgemäß mehr Plattenplatz als dynamisch gelinkte Programme, aber gegenüber einer 16-TByte-Festplatte nimmt sich ein 2 MByte großes “Hello-World”-Binary in Go ziemlich unbedeutend aus – insbesondere im Vergleich mit der Hölle der Abhängigkeiten, in die jeder Installateur sonst unweigerlich hinabsteigen muss.
Nachladen von Github
Eine Sprache lebt nicht nur vom Kern allein. Wichtig ist auch, dass möglichst viele freiwillige Mitarbeiter laufend neue Erweiterungen schreiben und sie der Community frei zur Verfügung stellen. Go kann tatsächlich Fremdbibliotheken auf Repo-Servern wie Github direkt aus dem Code referenzieren, und der Compiler saugt diese, wie auch abhängige Pakete, direkt vom Ursprungs-Server. Listing 2 nutzt zum Beispiel die Library »progressbar« auf Github, die einen Fortschrittsbalken ins Terminalfenster malt. In seiner »import«-Sektion referenziert das Programm die Github-Adresse des Projekts und weist ihr das (optionale) Kürzel »pb« zu.
Listing 2
pb.go
01 package main
02
03 import (
04 pb "github.com/schollz/progressbar/v3"
05 "time"
06 )
07
08 func main() {
09 bar := pb.Default(100)
10 for i := 0; i < 100; i++ {
11 bar.Add(1)
12 time.Sleep(400 * time.Millisecond)
13 }
14 }
Bei einem spontanen »go build« von Listing 2 würde der Go-Compiler allerdings über die fehlende Library maulen, doch ein vorangegangenes »go get« mit dem Github-Pfad holt den Progressbar-Code herein. Statt »go get« aufzurufen, definieren heute aber viele Entwickler ein Go-Modul mit »go mod init Name«, das sich Abhängigkeiten in einer neu angelegten Datei »go.mod« merkt. Ein anschließendes »go build« erledigt in einem Rutsch das Einholen neuen Codes und das Verlinken mit dem bestehenden.
Abbildung 1 zeigt, wie der Code von Listing 2 sich nach dem Erzeugen eines neuen Go-Moduls mit dem Aufruf »go build« butterweich übersetzen lässt, denn der Compiler zieht die Version v3 der »progressbar«-Library beim Kompilieren von Github herein. So kann auch Hans Dampf einfach neue Go-Bibliotheken auf Github abstellen, um sie mit der Welt zu teilen, und die Welt greift einfach zur Compile-Zeit darauf zu.
Aufmerksamen Lesern fällt auf, dass sich das Dependency-Hell-Problem mit diesem Ansatz schlicht vom Installationszeitpunkt auf den der Kompilierung vertagt. Verlässt sich Go-Code auf Open-Source-Projekte auf Github, läuft ein einmal kompiliertes Binary zwar immer weiter und lässt sich auch anstandslos neu installieren. Der Build-Prozess für neue Versionen aber könnte ins Schleudern kommen, falls der Library-Autor sein Github-Projekt einstampft oder nicht rückwärtskompatible Änderungen macht: Das zöge dem darauf vertrauenden Projekt des Anwenders den Teppich unter den Füßen weg.
JSON von Haus aus
Moderne Systemkomponenten kommunizieren übers Netzwerk häufig mittels Daten im JSON-Format. Go springt auf diesen Wagen auf und verpackt seine Datenstrukturen ohne viel Brimborium in JSON und wickelt sie auf der Empfängerseite auch wieder klaglos aus. Naturgemäß reiben sich JSONs Pi mal Daumen typisierte Daten mit Gos strengem Typmodell, doch Go lässt hier zumindest teilweise Milde walten.
Listing 3 definiert zunächst eine Datenstruktur »keyVal« mit den vier Komponenten »A« bis »D«, die jeweils einen String als Wert führen. Damit der Go-Code auf die Felder der Struktur auch außerhalb des aktuellen »package«-Spielraums zugreifen darf, beginnen ihre Namen mit einem Großbuchstaben. JSON schreibt Schlüssel in den JSON-Objekten, in die Go die Strukturfelder ummodelt, aber traditionell klein.
Listing 3
json.go
package main
import (
"encoding/json"
"fmt"
)
type keyVal struct {
A string `json:a`
B string `json:b`
C string `json:c`
}
func main() {
jsonStr := []byte(`{"a": "x", "b": "y", "d": "z"}`)
data := keyVal{}
err := json.Unmarshal(jsonStr, &data)
if err != nil {
panic(err)
}
fmt.Printf("data=%+v\n", data)
}
Die Zuordnung des Namens des Strukturfelds zum Namen in JSON bestimmt der in Backticks stehende Text hinter der Felddefinition: Der Eintrag »A string `json:a`« legt fest, dass es sich beim Feld »A« der Go-Struktur des Typs »KeyVal« um einen String handelt, der in JSON in einem Objekt-Label mit dem Namen »a« daherkommt.
Auf Änderungen in den JSON-Daten reagiert der Go-Empfänger recht flexibel. Kommt im JSON-Salat ein Wert an, den die empfangenden Go-Struktur nicht definiert, ignoriert ihn die Lesefunktion »json.Unmarshal()« einfach. Führt umgekehrt die Struktur einen Eintrag, der im eintrudelnden JSON nicht existiert, lässt Go das Strukturfeld uninitialisiert.
Flexibel bei Änderungen
Während die ankommende JSON-Map in Listing 3 Werte für die Schlüssel »a«, »b« und »d« definiert, enthält die empfangende Struktur »keyVal« Felder unter den Namen »A«, »B« und »C«. Wie die Ausgabe »data={A:x B:y C:}« des aus Listing 3 kompilierten Binaries zeigt, geht trotzdem alles gut über die Bühne.
Den überzähligen JSON-Eintrag »d« hat der Empfänger stillschweigend ignoriert. Den in JSON fehlenden Schlüssel »c« ließ er in der Go-Struktur uninitialisiert, das Feld verblieb auf seinem Null-Value. Auf diese Weise kommen Go-Programme mit im Lauf der Entwicklung zusätzlich geschickten JSON-Feldern zurande, ohne abzustürzen oder mit einem Fehler abzubrechen.
Damit sie aber neue Felder auch tatsächlich in einem neuen Strukturfeld besetzen, muss man die Struktur erweitern und das Programm neu übersetzen. Es gibt übrigens auch die Möglichkeit zu tricksen und ein JSON-Objekt direkt in eine Go-Map zu überführen und somit das Go-Programm dynamisch an sich verändernde JSON-Strukturen anzupassen. Doch darüber rümpfen alte Go-Haudegen die Nase, denn es öffnet die Tür für ignorierte Typfehler.
Go kennt bei klaren Typverletzungen keine Milde, etwa, wenn ein Struktureintrag vom Typ »string« in JSON als Integer daherkommt. In diesem Fall gibt »json.Unmarshal()« einen Fehler zurück, den das Programm abfangen muss und daraufhin hoffentlich mit einer Meldung Alarm schlägt.
Runen, Zeichen und Bytes
Programmcode interpretiert Go als UTF-8-kodiert, also in der platzsparenden Standardkodierung des Unicode-Zeichensatzes. Ein String in Go enthält eine Reihe von Unicode-Codepoints, die im Go-Slang Runes heißen. Wer mittels des »range«-Operators über einen String iteriert, bekommt Runen zurück, die sowohl ASCII-Zeichen als auch Umlaute gleichwertig repräsentieren.
Wer lieber auf rohen Bytes herumorgelt, verwendet statt Strings besser Byte-Arrays vom Typ »[]byte«. Das geht nicht nur schneller, sondern hat auch den Nebeneffekt, dass der Code das Array nicht nur lesen, sondern auch modifizieren kann. Strings sind in Go fix.
Wird jetzt zum Beispiel ein Zeichen aus einem String geholt, um damit in einer Hash-Tabelle den String unter dem Schlüssel des Anfangsbuchstabens abzuspeichern, ist wie in Listing 4 genau auf die Datentypen zu achten, sonst motzt der Compiler.
Listing 4
hash.go
package main
import (
"fmt"
)
func main() {
hash := map[rune]string{}
str := "abc"
for _, ch := range str {
hash[ch] = str
}
key := 'a'
fmt.Printf("%c: %s\n", key, hash[key])
}
Die Hash-Tabelle (Map) in Zeile 8 weist Einträgen vom Typ »string« Schlüssel des Typs »rune« zu. Die beiden geschweiften Klammern am Ende der Deklaration geben die Initialisierungsdaten der Tabelle an, die in diesem Fall mit einem leeren Datenbereich einfach leer bleibt.
Die For-Schleife ab Zeile 11 iteriert dann über die Runen des Strings »”abc”«. Sie legt für jede einen Map-Eintrag unter dem jeweiligen Buchstaben an, der auf den Gesamt-String verweist. Zeile 16 greift dann in die Tabelle und holt den Eintrag unter dem Kürzel »a« wieder hervor: »a: abc«.
Noch ein Wort zur For-Schleife ab Zeile 11: Der »range«-Operator iteriert über alle Einträge der ihm übergebenen Datenstruktur, indem er zu jedem Eintrag zwei Werte zurückgibt: den bei »0« beginnenden laufenden Index sowie den Wert des Eintrags. Listing 4 interessiert sich allerdings nur für die einzelnen Zeichen im String und braucht den Indexzähler nicht. Daher weist es ihn der Variablen »_« (Unterstrich) zu. Das hat den Effekt, dass Go den Wert ungesehen verwirft. Es vermeidet zudem die Fehlermeldung, die andernfalls hochkäme, falls der Index einer Variable »i« zugewiesen würde, die sonst nirgendwo verwendet wird.
Speicher (fast) automatisch
Maps wachsen automatisch mit ihren Anforderungen und nutzen dynamisch wachsenden Speicher. Go verwaltet Speicher scheinbar automatisch. Die Initialisierung einer Hash-Tabelle im vorigen Beispiel sorgt dafür, dass eine darauf folgende Anweisung wie »data[“a”]=”abc”« ohne explizite Speicherallokation funktioniert. Sowohl für die Schlüssel in der Hash-Tabelle als auch für die ihnen zugewiesenen Werte sorgt Go intern für adäquate Speicherreservierung.
Generiert eine Funktion eine Hash-Tabelle und gibt sie zurück, bleibt sie auch im Hauptprogramm gültig. Referenziert später irgendwann keiner mehr die Tabelle, räumt der Garbage-Collector [2] sie zu gegebener Zeit zusammen und gibt den allokierten Speicher frei, ohne dass der Programmierer sich darum einen Kopf machen müsste.
Komplizierter wird es, wenn eine zwei- oder mehrdimensionale Datenstruktur vorliegt. Dann muss der Go-Programmierer auf jeder Ebene getrennt initialisieren. Skriptsprachen wie Python oder Ruby deklarieren einfach ein zweidimensionales Array oder eine Hashmap, und dann sorgt die Laufzeitumgebung dafür, dass ein neuer Eintrag »data[i][j]« auf automatisch allokierten Speicher zugreift und nicht etwa im Nirwana landet. Aber wehe dem, der das in Go probiert: Ein Skript wie das aus Listing 5 kompiliert zwar anstandslos, löst aber den Laufzeitfehler »panic: assignment to entry in nil map« aus.
Listing 5
dimfail.go
package main
func main() {
twodim := map[string]map[string]string{}
twodim["foo"]["bar"] = "baz" // panic!!
}
Listing 6 macht es dagegen richtig: Bevor das Programm auf die zweite Ebene der Hash-Tabelle zugreift, weist Zeile 5 dem Eintrag der ersten Ebene eine frisch allokierte Unter-Hashmap zu. Ab diesem Zeitpunkt dürfen Einträge über zwei Ebenen zugreifen. Allerdings gilt es, auf der ersten Ebene für jeden neuen Eintrag erst den Unter-Hash anzulegen, bevor der Zugriff auf die zweite Ebene erfolgt.
Listing 6
dimok.go
package main
func main() {
twodim := map[string]map[string]string{}
twodim["foo"] = map[string]string{}
twodim["foo"]["bar"] = "baz" // ok!
}
Prozesse, Threads und Goroutinen
Traditionell implementieren Unix-Systeme Nebenläufigkeit mit Prozessen, deren Ressourcenverbrauch aufgrund der Speicherduplizierung jedoch enorm ist. Sprachen wie Java oder C++ erlauben das Hantieren mit Threads, die sich den Speicher teilen und deshalb leichtfüßiger daherkommen. Allerdings geht auch bei einigen Hunderttausend parallel laufender Threads der Prozessor in die Knie.
Go setzt auf dem Thread-Modell noch einmal eine Abstraktionsschicht auf, schickt pro Thread viele sogenannter Goroutinen ins Rennen, disponiert sie mittels eines eigenen Schedulers und erlaubt so, tatsächlich Millionen davon gleichzeitig laufen zu lassen. Mit der Syntax »go func() {…}« feuert der Go-Programmierer neue Goroutinen ab, die der Prozessor scheinbar gleichzeitig zusammen mit dem restlichen Programmfluss ausführt.
Naturgemäß wirft das Probleme bei der Synchronisation auf: Wie wartet eine Goroutine auf die andere, wie tauschen sie Daten aus, und wie kann ein Hauptprogramm alle bislang gestarteten Goroutinen wieder einberufen und herunterfahren?
Channels: Synchrones Sprachrohr
Verschiedene nebenläufige Programmteile in Go schicken sich oft Nachrichten über sogenannte Channels. Deren Funktion geht über das einer luftpostartigen Unix-Pipe hinaus, denn Sender und Empfänger synchronisieren sich damit nebenher miteinander auf elegante Weise, ohne dass umständlich zu handhabende Softwarestrukturen wie Semaphoren gebraucht würden.
Listing 7
block.go
package main
func main() {
ch := make(chan bool)
ch <- true // blocking
<-ch
}
Liest ein Go-Programm aus einem Channel, in den niemand schreibt, blockiert die lesende Goroutine den Programmfluss, bis im Channel etwas ankommt. Versucht umgekehrt eine Goroutine aus einem Channel zu lesen, in den niemand schreibt, blockiert sie ebenso, bis eine Nachricht vorliegt.
Wer demnach versucht, in einem Go-Programm erst aus einem Channel zu lesen und dann etwas hineinzuschreiben, oder umgekehrt erst zu schreiben und dann zu lesen, schreibt das langweiligste Go-Programm der Welt: Es blockiert permanent (Listing 7). Läuft sonst auch nichts, stellt die Go-Runtime einen Deadlock fest und bricht das Programm mit einem Fehler ab:
fatal error: all goroutines are asleep - deadlock!
Lese- und Schreibanweisungen an einen Channel müssen also immer in verschiedenen nebenläufigen Goroutinen stehen. Listing 8 erzeugt zum Beispiel zwei Channels »ping« und »ok«, die Nachrichten vom Typ »bool« aufnehmen (»true« oder »false«). Dann setzt das Hauptprogramm (das als Goroutine unterwegs ist) eine weitere Goroutine in Gang. Die versucht, parallel aus dem Channel »ping« zu lesen, und blockiert damit.
Listing 8
chan.go
package main
import (
"fmt"
)
func main() {
ping := make(chan bool)
ok := make(chan bool)
go func() {
select {
case <-ping:
fmt.Printf("Ok!\n")
ok <- true
}
}()
fmt.Printf("Ping!\n")
ping <- true
<-ok
}
Mittlerweile fährt das Hauptprogramm fort und schreibt einen booleschen Wert in den Channel »ping«. Sobald die parallele Goroutine am anderen Ende lauscht, geht der Schreibvorgang durch, und das Hauptprogramm hängt nun an der lesenden Anweisung des »ok«-Channels am Ende von Listing 8.
Die parallele Go-Routine, die mit der Variablen »ok« ebenfalls Zugriff auf den Channel hat, schreibt nun einen booleschen Wert in »ok« hinein, worauf die letzte Zeile des Hauptprogramms die Blockade aufgibt und das Programm endet – ein perfekter Handschlag, mit dem sich zwei Goroutinen, die des Hauptprogramms und die zusätzlich gestartete, miteinander verabreden, also synchronisieren.
Die Ausgabe des aus Listing 8 kompilierten Binaries lautet »”Ping!”« und »”Ok!”«, und zwar genau in dieser Reihenfolge und niemals außer der Reihe, denn das gezeigte Channel-Arrangement schließt gefürchtete Race Conditions kategorisch aus.
Nicht mehr als zwei
Normalerweise puffern Channels erhaltene Eingaben nicht, wie das Beispiel in Listing 7 gezeigt hat, das einfach nur den Programmfluss blockierte. Wer im Normalfall will, dass ein Leser lesen kann, ohne zu blockieren, muss dafür sorgen, dass ein Schreiber parallel in den Channel hineinschreibt. Andererseits können gepufferte Channels Daten vorhalten, also kann ein Schreiber hineinschreiben, ohne zu blockieren, auch wenn gerade niemand liest. Dockt irgendwann ein Leser an, holt der die Daten aus dem Puffer.
Gepufferte Channels bieten ein Werkzeug, um die maximale Anzahl gleichzeitig laufender Goroutinen zu limitieren. Das vermeidet bei rechenintensiven Arbeiten, die CPU mit einer einzigen Applikation zu überlasten. Listing 9 feuert in einer For-Schleife zehn Goroutinen ab, doch ein gepufferter Channel lässt immer nur zwei gleichzeitig laufen. Wie funktioniert das?
Listing 9
limit.go
package main
import (
"fmt"
"time"
)
func main() {
limit := make(chan bool, 2)
for i := 0; i < 10; i++ {
go func(i int) {
limit <- true
work(i)
<-limit
}(i)
}
time.Sleep(10 * time.Second)
}
func work(id int) {
fmt.Printf("%d start\n", id)
time.Sleep(time.Second)
fmt.Printf("%d end\n", id)
}
Gepufferte Channels erzeugen einen Flaschenhals für vorbeidonnernde Goroutinen nach folgendem Verfahren: Die Größe des Channel-Puffers (angegeben als zweites optionales Argument in der »make«-Anweisung) legt die maximale Anzahl parallel laufender Goroutinen in einem Programmabschnitt fest. Jede Goroutine, die Einlass begehrt, versucht zunächst, in den Channel zu schreiben. Ist noch ein Pufferplatz frei, dann sind noch nicht zu viele Goroutinen unterwegs, und der Channel lässt sie schreiben und weiterlaufen, ohne zu blockieren.
Ist der Puffer hingegen schon voll, darf keine weitere Goroutine mehr in den geschützten Bereich eindringen, und der Channel blockiert alle Versuche ankommender Gäste, in den Puffer zu schreiben. Am Ende des geschützten Bereichs lesen herausströmende Goroutinen dann ein Datenstück aus dem Channel, machen damit einen Pufferplatz frei, und am Anfang des geschützten Bereichs lässt der Channel eine der hereindrängenden Goroutinen hinein. Auf diese Weise limitiert ein gepufferter Channel die Maximalzahl parallel laufender Goroutinen in einem geschützten Bereich.
Abbildung 2 zeigt, dass zunächst die Goroutinen mit den Indexwerten »i=0« und »i=3« eindringen (da ist Zufall im Spiel), dann »3« den Bereich verlässt, worauf »9« vorn eindringt, dann verabschiedet sich »0« und »4« dringt ein und so weiter.
Aufpassen heißt es übrigens bei For-Schleifen wie der in Zeile 11 von Listing 9, die Goroutinen abfeuern und dabei mit einem Schleifenzähler wie »i« arbeiten. Die Variable »i« ändert sich bei jedem Schleifendurchgang. Da alle Goroutinen sich diese Variable teilen, würden alle denselben Wert (den des letzten Schleifendurchgangs) anzeigen, wenn sie die Variable einfach übernähmen. Damit jede Goroutine ihre eigene Kopie des aktuellen Stands von »i« erhält und anzeigen kann, übergibt Zeile 12 dem Aufruf »go func()« die Variable »i« als Parameter. So klappt alles wie gewünscht.
Kontextuell gesehen
Wer viele Goroutinen erzeugt, muss sie auch wie einen Sack Flöhe hüten und ihren Lebenszyklus exakt bestimmen. Anderenfalls entsteht Wildwuchs, und nicht freigegebene Ressourcen legen irgendwann das Hauptprogramm lahm.
In Googles Rechenzentren kam dieses Problem bei den Webservern auf, die üblicherweise, um Benutzeranfragen zu beantworten, mittels Goroutinen Daten bei verschiedenen Backend-Diensten einholen. Tritt dabei eine Verzögerung auf und der Webserver verliert die Geduld, muss er allen bislang parallel begonnenen Goroutinen möglichst in einem Rutsch mitteilen, dass ihre Dienste nicht mehr gebraucht werden und sie ihre Tätigkeit bitte sofort einstellen. Der Webserver möchte dem aktuell anfragenden Webclient dann eine Fehlermeldung schicken und zum Abarbeiten des nächsten Requests übergehen.
Diese Kommunikation übernimmt das Context-Konstrukt, das in diesem Zusammenhang Einzug in Gos Standard-Library hielt. Mit »context.Background()« erzeugt und initialisiert Listing 10 einen Channel, aus dem jede parallel laufende Goroutine in einer »select«-Anweisung permanent zu lesen versucht. Stülpt das Hauptprogramm den großen Löschnapf über die Goroutine, ruft die die »Cancel()«-Funktion des Contexts auf, was den internen Channel zusammenfaltet, was wiederum mit einem Schlag alle lauschenden Goroutinen mit einem Fehler aus ihrer Blockierung reißt. So können sie schnell ihre allokierten Ressourcen freigeben, und sich beenden. Aus Sicht des Hauptprogramms lässt sich mit einer einzigen Anweisung alles in einem Schlag zuverlässig aufräumen – Komfort der Extraklasse.
Listing 10
ctx.go
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(
context.Background())
for i := 0; i < 10; i++ {
bee(i, ctx)
}
time.Sleep(time.Second)
cancel()
fmt.Println("")
}
const tick = 200 * time.Millisecond
func bee(id int, ctx context.Context) {
go func() {
for {
fmt.Printf("%d", id)
select {
case <-ctx.Done():
return
case <-time.After(tick):
}
}
}()
}
Listing 10 feuert zur Illustration in der For-Schleife ab Zeile 13 zehn parallel laufende Goroutinen ab. Sie springen alle die Arbeitsbienenfunktion »bee()« ab Zeile 24 an, um dort in einer Endlosschleife ihren Integer-Wert auszugeben, in Zeile 31 mit »time.After()« 200 Millisekunden zu warten, und dann das Ganze wiederholen.
Die »select«-Anweisung ab Zeile 28 wartet aber nicht nur auf den wiederholt auslaufenden Timer, sondern auch auf Ereignisse im Channel »ctx.Done()«, dem Kommunikationskanal des Contexts. Schließt das Hauptprogramm ihn, springt die zugehörige Case-Anweisung an, und die Goroutine verabschiedet sich mit »return«.
Die Ausgabe des Programms sieht nun folgendermaßen aus: »097851234646392…« Es verabschiedet sich zuverlässig nach etwa einer Sekunde, wenn der Timer der Hauptfunktion in Zeile 17 des Hauptprogramms abläuft und es die von »context.WithCancel()« zurückgegebene Löschnapffunktion »cancel()« aufruft.
Aufmerksame Leser merken: Funktionen in Go können Funktionen zurückgeben; es handelt sich um Datentypen erster Klasse.
Maulig bei Nichtgebrauch
In anderen Sprachen sammeln sich im Lauf der Entwicklung oft ungenutzte Variablen und unnötig hereingezogene Header-Dateien. Nun hat Go sich aufgemacht, diesem Wildwuchs auf Teufel komm raus beizukommen. Wer eine Variable deklariert, aber nicht benutzt, bekommt sie vom Compiler um die Ohren gehauen; wer ein Fremdpaket mit »import« hereinholt, aber nirgendwo eine Funktion daraus verwendet, ebenfalls.
Das ist bei Programmen kurz vor der Veröffentlichung sicher eine gute Idee, nervt aber während der Entwicklung tierisch. Läuft etwas nicht wie gewünscht, liegt es nahe, eine »Printf()«-Anweisung einzubauen, die allerdings einen Import des Pakets »fmt« erfordert. Verschwindet die »Printf()«-Anweisung anschließend nach dem Beheben des Problems, steht im Import-Bereich immer noch »”fmt”«, und der Compiler weigert sich, den Source-Code zu übersetzen, bis auch diese Zeile verschwindet.
Zum Glück gibt es ein Schlupfloch: Definierte, aber ungenutzte Funktionen meckert der Compiler nicht an. Wer also Code-Snippets zur späteren Verwendung zwischenzeitlich bunkern möchte, verpackt sie einfach in eine neue, nie genutzte Funktion. Es soll übrigens auch Cowboy-Go-Coder geben, die von aufgerufenen Funktionen zurückgereichte Fehlercodes ignorieren, indem sie sie der Pseudovariablen »_« (Unterstrich) zuweisen (siehe auch Listing 4); doch das ist nun wirklich eine Frechheit, die verboten gehört.
Initialisierung mit Tücken
Vor dem ersten Gebrauch einer Variablen besteht Go darauf, deren Typ zu kennen. Das kann der Programmierer dem Programm entweder durch eine explizite Deklaration der Variablen verklickern, so wie »var text string« die Variable »text« vom Typ »string« deklariert.
Aber auch die erste Zuweisung eines Werts an eine Variable kann deren Typ darlegen, wenn statt des »=«-Zeichens der Operator »:=« verwendet wird: Steht im Code »foo := “”«, weiß der Compiler, dass die Variable »foo« vom Typ »string« ist. Ein etwas anspruchsvolleres Beispiel: Bei »bar := map[string]int{“a”: 1, “b”: 2}« steht fest, dass »bar« vom Typ einer Hash-Tabelle (»map«) ist, die Strings Integer-Werten zuordnet und dabei gleichzeitig dem Eintrag »”a”« den Wert 1 und »”b”« den Wert 2 zuweist.
Wer Variablen nicht auf einem dieser Wege deklariert, erhält einen Rüffel vom Compiler – allerdings auch, falls auf der linken Seite des Operators »:=« lauter bereits deklarierte Variablen stehen, denn dann verlangt Go stattdessen eine einfache Zuweisung mit »=«.
Die kurze Deklaration mit »:=« (im Gegensatz zur ausführlichen mit »var«) führt übrigens manchmal zu Missverständnissen: Ein Codestück wie das in Listing 11, das versehentlich eine außerhalb des (immer wahren) If-Blocks schon gesetzte Variable »num« zusammen mit einer neuen »str« auf der linken Seite einer Deklaration/Zuweisung mit »:=« setzt, arbeitet wahrscheinlich nicht wie gewünscht. Go interpretiert die Zuweisung als Definition zweier neuer Variablen innerhalb des If-Blocks und überschreibt nur die lokale Version von »num« mit dem Wert 2, während die Variable nach dem If-Block unverändert bleibt.
Listing 11
var1.go
package main
import (
"fmt"
)
func main() {
num := 1
if true {
num, str := 2, "abc"
fmt.Printf("num=%d str=%s\n", num, str) // 2, "abc"
}
fmt.Printf("num=%d\n", num) // 1
}
Wer tatsächlich vorhat, mit der äußeren Definition von »num« zu arbeiten, und ihr innerhalb des »if«-Blocks einen neuen Wert zuweisen möchte, darf in dieser Konstellation nicht den Operator »:=« verwenden. Stattdessen muss er die neue Variable »str« innerhalb des If-Blocks mit »var« deklarieren und statt »:=« den reinen Zuweisungsoperator »=« verwenden. Dann arbeitet Go nur mit einer Version von »num« (Abbildung 3). In Listing 11 müsste man dazu Zeile 11 zu »var str string« und Zeile 12 in »num, str = 2, “abc”« ändern.

Abbildung 3: Die Ausgaben beider Versionen des Programms aus Listing 11 zur Laufzeit.
So viel mehr
Es gäbe noch viel mehr zu berichten; zum Beispiel, dass Go statt typischer Objektorientierung nur Structs als Instanzvariablen bietet, die es mit methodenartigen Funktionen anspricht, oder das genial einfache Reader-Interface, mit dem Funktionen Daten transparent bearbeiten können, egal ob sie aus einer Datei, einer Internet-Verbindung oder einem String kommen. Reflexion über den eigenen Code wäre noch so ein Thema, die elegante »defer«-Anweisung und vieles andere mehr, aber das muss aus Platzmangel auf ein andermal verschoben werden.
Insgesamt handelt es sich bei Go um eine sehr durchdachte Sprache, die auf der Tradition althergebrachter und überwältigend erfolgreicher Sprachen wie C aufsetzt, aber deren Mängel beseitigt und Programmierprofis ein modernes Werkzeug für das 21. Jahrhundert an die Hand gibt. ((uba)/jlu)
Infos
- “The Go Programming Language”: https://www.pearson.com/us/higher-education/program/Donovan-Go-Programming-Language-The/PGM234922.html
- “The Journey of Go’s Garbage Collector”: https://blog.golang.org/ismmkeynote
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/07/snapshot/








