Learning Go - mit defer Funktionsaufrufe verzögern


Für Go-Lernende: Was soll dieses verdammte defer überall im Go-Code bedeuten?

Wenn man als Go-Neuling wie ich ins kalte Wasser springen muss und “echten” Go-Code liest, dann wird man ständig von Sprachfeatures überrascht, die man noch nicht kennt. Zum Glück waren es bislang immer schöne Überraschungen. Eines der für mich überraschenden Go-Features wird durch das keyword defer eingeleitet, das vor einem Funktionsaufruf stehen kann.

Mein Go-Mentor hat mir zur Einordnung grob erklärt, defer führe dazu, dass die nachstehende Funktion nicht sofort aufgerufen wird, sondern erst zu einem späteren Zeitpunkt. Und er hat gesagt, ich solle dieses Sprachfeature doch mal genauer anschauen. Das lasse ich mir natürlich nicht zweimal sagen.

Es gibt ein GitHub-Repo zu diesem Artikel. Wo es sinnvoll war habe ich dort Beispielcode für Euch zum Ausprobieren reingepackt.

Kurz und knapp erklärt: A Tour of Go

Meine erste Anlaufstelle zu diesem Thema finde ich in Gos Einstiegstutorial A Tour of Go. (Auch das soll ich mir laut meines Go-Mentors dringend ansehen. Next mission accomplished. Zumindest punktuell √)

Dort steht geschrieben:

“A defer statement defers the execution of a function until the surrounding function returns. The deferred call’s arguments are evaluated immediately, but the function call is not executed until the surrounding function returns.”

Der beistehende Beispielcode lautet:

Code-Beispiel 1:

1
2
3
4
5
6
7
8
9
package main
	
import "fmt"

func main() {
  defer fmt.Println("world")

  fmt.Println("hello")
}

Der Aufruf der Funktion in Zeile 6 wird verzögert (Anmerkung: engl. to defer - aufschieben, verzögern) bis zum Ende der main()-Funktion. Beim Ausführen wird dadurch zunächst “hello” ausgegeben und erst danach “world” obwohl die Reihenfolge der Statements genau umgekehrt ist. Ok. Verstanden. √

defer verstanden?

Aber irgendwie habe ich das Gefühl, dass es zu dem Thema noch mehr zu wissen gibt. Zum Beispiel: Warum könnte man denn wollen, dass eine Funktion erst später ausgeführt wird als im eigentlichen Programmfluss vorgesehen? Und was sind typische Situationen, in denen mir ein defer helfen könnte?

Ausführliche Erklärung im Buch “The Go Programming Language”

Als nächstes greife ich zum Go-Lehrbuch “The Go Programming Language”. Kapitel 5.8. trägt den Namen Deferred Function Calls. Als Codebeispiel wird hier eine Funktion title(url string) aufgelistet. Die Funktion nimmt eine URL entgegen und extrahiert den Titel aus dem HTML der zugehörigen Webseite.

Code-Beispiel 2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func title(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return err
	}

	// Check Content-Type is HTML (e.g., "text/html; charset=utf-8").
	ct := resp.Header.Get("Content-Type")
	if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
		resp.Body.Close()
		return fmt.Errorf("%s has type %s, not text/html", url, ct)
	}

	doc, err := html.Parse(resp.Body)
	resp.Body.Close()
	if err != nil {
		return fmt.Errorf("parsing %s as HTML: %v", url, err)
	}

	visitNode := func(n *html.Node) {
		if n.Type == html.ElementNode && n.Data == "title" &&
			n.FirstChild != nil {
			fmt.Println(n.FirstChild.Data)
		}
	}
	forEachNode(doc, visitNode, nil)
	return nil
}

Original-Listing auf Github: https://github.com/adonovan/gopl.io/blob/master/ch5/title1/title.go

Wichtige Hintergrundinfo: geöffnete Ressourcen müssen wieder geschlossen werden

Wir sehen in der ersten Programmzeile, dass mit http.Get(url) eine URL abgefragt wird. Später (in den Zeilen 10 und 15) wird mit resp.Body.Close() der response body geschlossen. Darauf wird in der Dokumentation zum Package http gleich zweimal hingewiesen:

  1. “The client must close the response body when finished with it”
  2. “Caller should close resp.Body when done reading from it.”

Im Hintergrund wurde eine Netzwerkverbindung geöffnet, um den HTTP-Call durchführen zu können. Der garbage collector von Go kümmert sich allerdings nicht darum, nicht mehr benötigte Ressourcen freizugeben, die vom Betriebssystem zur Verfügung gestellt wurden. Dazu gehören geöffnete Netzwerkverbindungen genauso wie geöffnete Dateien. Das heißt, das Schließen von Netzwerkverbindungen (und auch von offenen Files) liegt im Verantwortungsbereich des Programmierers. (Quelle: “The Go Programming Language” auf Seite 125) Das ist auch im Fehlerfall relevant.

Doppelte Arbeit: resp.Body.Close() wird mehrfach aufgerufen

Im aktuellen Zustand enthält das Programm eine ungünstige Code-Wiederholung: Der Teil resp.Body.Close() muss zweimal aufgerufen werden, um sowohl im Fehlerfall als auch im normalen Programmablauf die geöffnete Netzwerk-Verbindung zu schließen. Sollte in der Programmlogik ein weiterer Fehlerfall behandelt werden müssen, dann muss die Netzwerk-Verbindung im neu entstehenden Programmzweig auch noch ein drittes Mal geschlossen werden. Es wäre auch denkbar, dass die Funktion deutlich mehr Code enthält und es schwer wird, alle Stellen im Blick zu behalten, an welchen das Schließen der Netzwerkverbindung notwendig ist. Je länger die Funktion wird, desto schwieriger wird es, all diese Stellen im Blick zu behalten. Insgesamt ist klar: Diese Art von Code-Wiederholungen könnten leicht vergessen werden bzw. sind nicht leicht zu warten. Das ist ein Problem. Mit defer bietet Go ein Mittel gegen solche Arten von Wiederholungen. Das beantwortet meine Frage, in welchen Situationen mir defer nützlich sein kann. √

Defer löst das “Aufräum-Problem” von zweiteiligen Operationen

Typischerweise kommt das defer-Statement zum Einsatz in Sitationen, in welchen Operationen zweiteilig sind. Also z.B. das Öffnen und Schließen von Files oder der Auf- und spätere Abbau einer Netzwerkverbindung. Damit soll sichergestellt werden, dass Ressourcen, auf die ein erster Zugriff stattgefunden hat, zu einem späteren Zeitpunkt einen zweiten Zugriff bekommen, der den ersten Zugriff wieder neutralisiert. defer kümmert sich immer um den zweiten Zugriff, der - wie ich finde - immer einen aufräumenden Charakter besitzt.

Wohin gehört ein defer?

Die richtige Stelle für ein defer-Statement ist direkt nachdem eine Ressource ins Spiel kommt bzw. fehlerfrei geöffnet wurde. Im Falle von http.Get(url) aus dem Beispiel von oben sollte das defer-Statement direkt nach der Fehlerbehandlung des GET-Aufrufes stehen, um die Netzwerkverbindung nach dem Ende der umliegenden Funktion wieder ordnungsgemäß zu schließen. Die überarbeitete Variante der title-Funktion von oben lautet daher:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func title(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	ct := resp.Header.Get("Content-Type")
	if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
		return fmt.Errorf("%s has type %s, not text/html", url, ct)
	}

	doc, err := html.Parse(resp.Body)
	if err != nil {
		return fmt.Errorf("parsing %s as HTML: %v", url, err)
	}

	// ...print doc's title element...
	//!-
	visitNode := func(n *html.Node) {
		if n.Type == html.ElementNode && n.Data == "title" &&
			n.FirstChild != nil {
			fmt.Println(n.FirstChild.Data)
		}
	}
	forEachNode(doc, visitNode, nil)
	//!+

	return nil
}
Ausführliches Programm hier: https://github.com/adonovan/gopl.io/blob/master/ch5/title2/title.go

Die Zeile mit dem defer wird nach dem return-Statement ausgeführt. Somit ist sichergestellt, dass in jedem Fall als allerletzter Schritt die Netzwerkverbindung geschlossen wird.

Das Buch enthält noch weitere Verwendungsweisen für defer, die ich in diesem Artikel aber überspringe. Man muss ja nicht gleich alles bis ins letzte Detail verstehen.

Zusatzinfo im Go-Blog: Abarbeitung in LIFO-Reihenfolge

Wenn Funktionen größer werden, dann kann es Probleme geben beim Schließen von geöffneten Files.

Mit dem defer-Statement wird sichergestellt, dass als allerletzter Schritt jedes geöffnete File geschlossen wird - egal was dazwischen passiert. Der Aufruf kann aber sofort geschrieben werden, nachdem das File geöffnet wurde. Die schließende Aktion wird erst ganz am Schluss ausgeführt - auch wenn sie viel früher aufgeschrieben wird. Sie kommt in die Liste mit Aktionen, die erst als Abschluss ausgeführt werden sollen.

Abschließend schaue ich mir an was im Go-Blog über defer steht. Dort erfahre ich ein interessantes Detail über die genaue Funktionsweise. Das defer-Statement schiebt den nachstehenden Funktionsaufruf in eine Liste und sammelt dort in einkommender Reihenfolge noch weitere Funktionsaufrufe, die ebenfalls mit defer markiert wurden. Die Abarbeitung dieser Liste geschieht - wie wir jetzt schon wissen - nachdem die umgebende Parentfunktion mit return beendet wurde. Jetzt stellt sich nur die Frage nach der Reihenfolge der Abarbeitung.

Zur Abwechslung hier mal ein selbst ausgedachtes Beispiel mit Pferden (aber nicht nur für Pferdesportbegeisterte):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import "fmt"

type Racehorses []string

func main() {
	racehorses := Racehorses{"Winner", "Dark horse", "Mediocre horse", "Lame duck"}
	executeNormalRace(racehorses)
	executeRaceWithDefer(racehorses)

}

func executeNormalRace(rh Racehorses) {
	fmt.Println("Start a normal horse race: 🏇🏇🏇🏇")
	var resultTemplate string
	for i, horse := range rh {
		if i == 0 {
			resultTemplate = "%v: %v 🥇\n"
		} else {
			resultTemplate = "%v: %v \n"
		}
		fmt.Printf(resultTemplate, i+1, horse)
	}
}

func executeRaceWithDefer(rh Racehorses) {
	fmt.Println("Start a horse race including defer: 🏇🏇🏇🏇")
	var resultTemplate string
	for i, horse := range rh {
		if i == 0 {
			resultTemplate = "%v: %v 🥇\n"
		} else {
			resultTemplate = "%v: %v \n"
		}
		defer fmt.Printf(resultTemplate, i+1, horse)
	}
}

Link auf GitHub: https://github.com/ChristianBartl/gs9-article-about-defer-in-go/blob/main/lifo/horserace.go

Die Ausgabe des Programms sieht so aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// executing the program
// go run main.go
Start a normal horse race: 🏇🏇🏇🏇
1: Winner 🥇
2: Dark horse
3: Mediocre horse
4: Lame duck
Start a horse race including defer: 🏇🏇🏇🏇
4: Lame duck
3: Mediocre horse
2: Dark horse
1: Winner 🥇

Wir sehen, in der ersten Methode werden die Pferde in derselben Reihenfolge aufgelistet, wie sie auch im Slice stehen.

In der zweiten Methode ist die Reihenfolge genau umgekehrt: Das letzte Pferd wird zuerst aufgelistet. Die Zeile mit defer führt dazu, dass der erste Schleifendurchlauf mit “Winner” in einem Stack gespeichert wird. Als nächstes kommt Dark horse in den Stack, dann folgen Mediocre horse und Lame duck. Beim Abarbeiten dieses Stacks wird zunächst das zuletzt im Stack eingefügte Element bearbeitet, also Lame duck. Deswegen wird Lame duck diesmal als erstes ausgegeben. Nach weiterer Abarbeitung des Stacks haben wir diesmal die Rennpferde in umgekehrter Reihenfolge aufgelistet.

Also Last In First Out: Wenn in einer Funktion mehrere Aufrufe mit defer markiert sind, dann wird die Reihenfolge am Ende LIFO abgearbeitet. √

Abbildung von Rennpferden Bild: By User:Fir0002, GFDL 1.2

Zusammenfassung

Fassen wir also zusammen:

  • Beim Ausführen von func main() { defer fmt.Println(“world”) fmt.Println(“hello”)} wird durch defer zunächst “hello” ausgegeben und erst danach “world” obwohl die Reihenfolge der Statements genau umgekehrt ist.

  • Mit defer bietet Go ein Mittel, lästige Code-Wiederholungen nicht zu vergessen, z.B. um an allen Stellen Files oder Netzwerkverbindungen auch wieder zu schließen.

  • defer stellt sicher, dass jedes geöffnete File wieder geschlossen wird – egal was dazwischen passiert. Und erst als allerletzter Schritt die Netzwerkverbindung. (Kann trotzdem direkt nach dem Öffnen geschrieben werden.)

  • Prinzip Last In First Out: Wenn in einer Funktion mehrere Aufrufe mit defer markiert sind, dann wird die Reihenfolge am Ende LIFO abgearbeitet.

Für mich nützlich waren beim Einstieg in Go: A Tour of Go, das Buch “The Go Programming Language” (https://www.gopl.io/), und als Begleitlektüre der Go Blog.

Und hier ist nochmal das Repo passend zu meinem Artikel.

Habt ihr es bis hierher geschafft? Yayy! Bekanntlich kommt im Abspann nochmal ein Extra:

Eigener Code: nur für Mitarbeiter

Wo im eigenen Code kam das vor?

Es ging um gRPC-Connections:

1
2
3
4
5
conn, err := grpc.Dial(addr, grpc.WithInsecure())
if err != nil {
	return err
}
defer conn.Close()

Den exakten Code dazu kann ich hier leider nicht verlinken (ist streng geheim, quasi top secret!), aber wenn Ihr bei uns im Go-Team anfangt, dann könnt Ihr es Euch ganz genau anschauen! -> “Go” ;-) to job-offer Go (Golang) Software Engineer (m/f/d/*)

Verrückte Welt // Skurriles Anwendungsbeispiel

Um den Lesenden zum Abschluss noch den Kopf ein bisschen zu verdrehen hier der Hinweis: Die Software mit der wir diesen Artikel und unseren gesament Blog verwalten ist natürlich in Go geschrieben und heißt Hugo https://github.com/gohugoio/hugo

Da dort Dateien geöffnet, gelesen und verarbeitet werden verwendet Hugo natürlich auch an der ein oder anderen Stelle defer. Das heißt also zusammengefasst: der Artikel über Gos defer läuft auf einer Go-Software, die mittels defer geöffnete Files schließt.

Ich hab das überprüft, es stimmt wirklich. Den Beweis findet Ihr hier: hugo/scripts/fork_go_templates/main.go / und auch bei allen weiteren Verwendungen von defer in hugo

Jetzt ihr

Welche Erfahrungen habt Ihr mit Go gemacht? Was hat Euch beim Einstieg geholfen? Verwendet Ihr defer (häufig)?

Lasst es uns wissen, in unserem IRC-channel #geekspace9 seid ihr jederzeit herzlich willkommen! Auch via E-Mail und Twitter freuen wir uns über eure Kontaktaufnahme. Und natürlich umso mehr über Eure Bewerbung fürs Go-Team, das Verstärkung sucht.