defer, panic und recover in Go: wann man sie nutzt

1. Übersicht

Go hat kein try/catch. Stattdessen bietet die Sprache drei Werkzeuge: defer zum Bereinigen von Ressourcen, panic um nicht wiederherstellbare Fehler zu signalisieren, und recover um panic an einer bestimmten Grenze abzufangen.

Dieser Artikel erklärt den Mechanismus jedes Werkzeugs, häufige Fallstricke, und am wichtigsten: wann man sie nutzt und wann nicht. Nach dem Lesen werden Sie wissen, wie man defer richtig einsetzt, wann panic sinnvoll ist, und wie man recover nutzt, um einen Server vor dem Absturz durch einen fehlerhaften Request zu bewahren.

2. defer

defer verzögert einen Funktionsaufruf bis zum Ende der umgebenden Funktion. Die Funktion kann durch normales return enden oder wegen eines panic. In beiden Fällen werden alle aufgeschobenen Aufrufe ausgeführt.

2.1. LIFO-Ausführungsreihenfolge

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

Jedes Mal wenn auf defer trifft, schiebt Go den Funktionsaufruf auf einen eigenen Stack. Wenn main endet, werden die Aufrufe in umgekehrter Reihenfolge entnommen:

done
defer 3
defer 2
defer 1

Diese LIFO-Reihenfolge hat einen Grund. Ressourcen, die später geöffnet werden, hängen normalerweise von denen ab, die zuerst geöffnet wurden. Beispielsweise öffnen Sie zuerst eine Connection, dann eine Transaction, daher muss die Transaction zuerst geschlossen werden.

2.2. Argumente werden sofort ausgewertet

Das ist die erste Falle. Die Argumente der aufgeschobenen Funktion werden zum Zeitpunkt des defer Befehls berechnet, nicht wenn der Aufruf tatsächlich ausgeführt wird.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

Beim ersten Aufruf wird der Wert 10 sofort erfasst, wenn auf defer trifft. Die Closure beim zweiten Aufruf ist anders. Sie referenziert die Variable total, daher liest sie bei Ausführung den aktuellen Wert 20.

Wenn Sie den endgültigen Wert benötigen, etwa um die Zeit zu messen oder den Status zu protokollieren, verwenden Sie eine Closure.

2.3. Rückgabewert durch benannte Ergebnisse ändern

Eine aufgeschobene Closure kann benannte Ergebnisse der Funktion lesen und schreiben. Diese Technik ist sehr nützlich, wenn Close() einen Fehler zurückgeben kann, etwa beim Schreiben einer Datei, wo Daten tatsächlich erst beim Schließen geleert werden.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

Die Anweisung return err weist einen Wert der Variable err zu, danach wird die aufgeschobene Closure ausgeführt. Die Closure fasst den Fehler von f.Close() in err ein mit errors.Join, verfügbar seit Go 1.20. errors.Join gibt nil zurück, wenn beide Fehler nil sind.

Wenn Sie nur defer f.Close() schreiben, wird der Fehler beim Schließen der Datei verschluckt und niemand bemerkt es.

Hinweis: Für Read-Only-Dateien ist defer f.Close() ausreichend. Fehler beim Schließen einer Read-Only-Datei haben kaum Einfluss auf die Daten.

2.4. Falle: defer in Schleifen

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // FALSCH: wird nur geschlossen, wenn processAll endet
		// ... f lesen ...
	}
	return nil
}

defer ist an eine Funktion gebunden, nicht an einen Block oder eine Schleife. Bei 10.000 Dateien hält dieser Code 10.000 Datei-Deskriptoren gleichzeitig offen und führt sehr wahrscheinlich zu einem too many open files Fehler.

Die Lösung ist, den Schleifenrumpf in eine separate Funktion auszulagern:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // wird geschlossen, wenn processFile beendet
	// ... f lesen ...
	return nil
}

3. panic

panic beendet die normale Ausführung der aktuellen Goroutine. Go beginnt unwinding des Stacks: jede Funktion kehrt zurück, und die defer Anweisungen jeder Funktion werden trotzdem ausgeführt. Wenn sie die Spitze des Goroutine-Stacks erreichen und niemand recover nutzt, druckt das Programm einen Stack-Trace und beendet sich mit Exitcode 2.

3.1. Unwinding-Prozess beobachten

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

Sie sehen, dass beide defer Anweisungen ausgeführt werden, bevor das Programm stirbt. Das ist genau der Grund warum defer mu.Unlock() oder defer tx.Rollback() auch bei einem panic sicher sind.

Panic kann auch von der Runtime kommen, nicht nur von expliziten panic() Aufrufen. Häufige Fälle sind Zugriff auf einen Index außerhalb des Bereichs, Dereferenzierung eines nil-Zeigers, Schreiben in eine nil Map oder Division durch 0.

3.2. Wann man panic nutzen sollte

Allgemeine Regel von Go: vorhersehbare Fehler sollten error zurückgeben. Beispiele sind: Datei existiert nicht, ungültiges Eingabeformat oder Netzwerk-Timeout. Verwenden Sie panic nur in folgenden Fällen.

1. Programmierungsfehler, d.h. eine Invariante wird verletzt. Dies ist ein Zustand der nicht vorkommen darf, wenn der Code korrekt ist, und das Weiterlaufen würde nur Daten beschädigen.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. MustXxx Funktionen zur Initialisierung. Die Standardbibliothek hat bereits regexp.MustCompile und template.Must. Sie können ähnliche Funktionen für die Konfiguration schreiben:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

Wenn die Konfiguration falsch ist, sollte die Anwendung nicht starten. Panic beim Start hilft Ihnen, Fehler früh zu erkennen, statt den Server mit defektem Zustand laufen zu lassen.

Hinweis: Verwenden Sie nicht MustXxx mit Benutzerdaten oder Anfrage-Daten. Eine ungültige Eingabe darf nicht zum Absturz des gesamten Prozesses führen.

4. recover

recover() stoppt den Unwinding-Prozess und gibt den Wert zurück, der an panic übergeben wurde. Diese Funktion hat nur Wirkung, wenn sie aufgerufen wird direkt innerhalb einer deferred Function. Wenn sie anderswo aufgerufen wird, gibt sie nil zurück und tut nichts.

4.1. Panic in Error konvertieren

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

Wenn b == 0, wirft die Runtime einen panic. Die aufgeschobene Closure wertet den panic mit recover() ab und weist ihn dem benannten Ergebnis err zu. Die Funktion gibt normal zurück mit result behält den Nullwert 0.

Dieses Beispiel dient nur zu Demonstrationszwecken. In der Praxis sollten Sie b == 0 überprüfen und direkt einen error zurückgeben.

4.2. Falle: recover muss direkt aufgerufen werden

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK: logPanic ist selbst die deferred Function
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // FUNKTIONIERT NICHT: recover liegt eine Ebene tiefer
	}()
	panic("boom B")
}

handlerA wertet den panic erfolgreich ab. handlerB stürzt immer noch ab, weil recover innerhalb von logPanic aufgerufen wird, und logPanic wiederum wird durch eine deferred Function aufgerufen aber ist selbst nicht eine deferred Function.

4.3. Falle: recover fängt panic in anderen Goroutinen nicht ab

Jede Goroutine hat ihren eigenen Stack. Ein panic in einer untergeordneten Goroutine stürzt den gesamten Prozess ab, unabhängig davon, ob main einen recover hat oder nicht.

func main() {
	defer func() { recover() }() // nutzlos für die Goroutine unten

	go func() {
		panic("worker crashed") // Process stirbt trotzdem
	}()
	time.Sleep(time.Second)
}

Wenn Sie einen Background-Job ausführen, den Sie nicht vollständig kontrollieren, wrappen Sie ihn ein:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() befindet sich im Paket runtime/debug. Es hilft dabei, vollständige Stack-Traces zu protokollieren, damit Sie später debuggen können.

4.4. Recover an der HTTP-Grenze

Ein sinnvoller Ort um recover zu platzieren ist die Request-Grenze. Ein fehlerhafter Request sollte nicht andere Requests beeinträchtigen.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // damit net/http die Abbruchbehandlung wie geplant durchführt
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http wertet ohnehin schon panic für jede Connection ab, aber es protokolliert nur und schließt die Verbindung, daher erhält der Client keine anständige Antwort. Die obige Middleware gibt einen klaren 500er Code zurück und protokolliert auch gleichzeitig Methode und Pfad zur leichten Nachverfolgung.

5. Tests für panic und recover schreiben

Go verwendet das Standard testing Paket, keine externen Abhängigkeiten erforderlich.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

Der zweite Test verwendet defer und recover um zu behaupten, dass die Funktion panicked, und mit der richtigen Nachricht. Wenn Sie testify verwenden, gibt assert.PanicsWithValue ähnliche Ergebnisse mit weniger Code.

6. Wann man NICHT verwenden sollte

  • Verwenden Sie panic/recover nicht wie Exceptions. Das Schreiben von panic(ErrNotFound) und recover in einer oberen Schicht macht die Kontrollfluss schwer zu lesen und verstößt gegen Go-Konventionen. Geben Sie stattdessen einen error zurück.
  • Panik Sie nicht in öffentlichen Bibliotheken wenn der Fehler von der Eingabe des Aufrufers stammt. Die Benutzer Ihrer Bibliothek erwarten nicht, dass ihr Prozess abstürzt.
  • Verwenden Sie recovery nicht, um es stillschweigend zu ignorieren. Protokollieren Sie zumindest den Panic-Wert und den Stack-Trace. Wenn der Zustand beschädigt ist, zum Beispiel wenn freigegebene Daten unvollständig geschrieben wurden, lassen Sie den Prozess sterben und den Orchestrator ihn neu starten.
  • Verwenden Sie defer nicht in langen Schleifen (siehe Abschnitt 2.4).
  • Machen Sie sich nicht zu viele Gedanken über Performance von defer. Ab Go 1.14 kostet open-coded defer praktisch nichts in den meisten Fällen. Optimieren Sie nur, wenn der Profiler zeigt, es ist ein Hotspot.

Hinweis: Ab Go 1.21 macht panic(nil) nicht mehr, dass recover() nil zurückgibt. Stattdessen gibt recover() *runtime.PanicNilError zurück. Alter Code, der recover() == nil verwendet um auf “kein panic” zu prüfen ist daher jetzt genauer.

7. Fazit

Diese drei Werkzeuge haben sehr unterschiedliche Rollen:

  • defer ist ein alltägliches Werkzeug zum Aufräumen von Ressourcen. Denken Sie an zwei Dinge: Argumente werden sofort ausgewertet wenn auf defer trifft, und defer ist an eine Funktion gebunden, nicht an eine Schleife. Kombiniert mit benannten Ergebnissen hilft defer Ihnen, keinen Fehler von Close() zu übersehen.
  • panic ist für Programmierungsfehler und nicht wiederherstellbare Initialisierungsfehler. Für andere Fehler geben Sie einen error zurück.
  • recover sollte nur an der Grenze angezeigt werden: HTTP-Middleware, Worker-Goroutine, oder an dem Ort, wo aufgerufen wird Code, den Sie nicht kontrollieren. Es muss direkt innerhalb einer deferred Function aufgerufen werden und fängt nur panic dieser Goroutine ein.

Wenn Sie diese Grenzen verstehen, werden Sie Go-Code schreiben können der sowohl sicher als auch dem Geist der Sprache entspricht: Fehler sind Werte, während panic echte Ausnahmen sind.

golangdeferpanicrecovererror-handling