Context in Go ist ein mächtiges Werkzeug. Es erleichtert nicht nur den Umgang mit einigen Variablen, sondern kann auch die Performance der gesamten Anwendung erheblich steigern. Ich habe mich zu Beginn damit schwer getan, deshalb hier alles wesentliche.
In diesem Artikel gehen wir kurz darauf ein, was der Context in Go (Golang) eigentlich ist. Dann kümmern wir uns um Performance und welchen riesigen Vorteil Go Context hier bietet. Zum Schluss geht es um die hervorragende Möglichkeit, langlebige Variablen zu speichern und abzurufen.
Was ist Context in Go und wie verwende ich ihn?
Nun es handelt sich um ein Interface welches - um Kommentare gekürzt - nicht einmal besonders kompliziert wirkt.
1type Context interface {
2 Deadline() (deadline time.Time, ok bool)
3 Done() <-chan struct{}
4 Err() error
5 Value(key any) any
6}
Mir persönlich hat diese Darstellung ehrlicherweise allerdings nie viel geholfen. Deshalb nähern wir uns doch über die übliche Verwendung 😉 Gute Beispiele sind für mich oft besser und schneller.
Wir erstellen einen Context!
Das gestaltet sich einfach:
1ctx := context.Background()
2
3// context.TODO() tut dasselbe, indiziert aber, dass Beispielcode vorliegt
Man tut gut daran, den jeweiligen Context zu Beginn eines Programms zu erstellen und ihn dann überall durch zuleiten.
Dabei ist im Go Umfeld üblich, den Context ctx
zu nennen und ihn stets als ersten Parameter zu führen:
1func killSth(ctx context.Context, processID string) {
2 //...
3}
Was, wenn ich einen weiteren Context benötige?
Einen erstellten Context kann man nicht ändern (immutable). Das braucht man aber auch nicht. Man erstellt einen neuen, ‘abgeleitet’ vom alten.
1
2// mutter context
3parentContext := context.Background()
4
5// kind context
6zweiterContext, cancelFunc := context.WithTimeout(parentContext, time.Second*5)
7defer cancelFunc()
Aber wofür kann ich einen zweiten Context gebrauchen?
Gute Frage! Performance!
Performance
Betrachten wir uns hierzu einmal folgenden Fall: Ein Anwender schickt eine Anfrage an einen Go-Service. Dieser überprüft zunächst einen Cache mit dem Inhalt der Anfrage. Im Cache findet er nicht das Gewünschte. Daher muss er seine Anfrage direkt an die Datenbank richten. Mit den erhaltenen Daten wird erfolgreich eine Mail verschickt, dass die Anfrage erfolgt ist. Ganz zum Schluss benachrichtigt der Go Service den Anwender über den Erfolg der Aktion. Letzteres könnte gut in Form des HTTP-Rückgabecodes passieren.

Erfolgreicher Go Service Workflow, User bricht keine Verbindung ab
Was wenn der User vorzeitig seine Request abbricht?
Wie ist der Fall gelagert, wenn die Go Application sich keinerlei Context zunutze macht und der Nutzer die Verbindung kurz nach Senden der Anfrage abbricht?

Fehlerhafter Go Service Workflow läuft komplett durch, obwohl ursprüngliche Verbindung schon abgebrochen ist
Obwohl der Nutzer die Verbindung getrennt hat - es mangels Übergabepunkt des Ergebnisses keinen Sinn macht - werden alle Schritte durchgeführt. Das kostet CPU, Speicher sowie IO (Netzwerk und Storage). Alles Dinge, die Geld kosten und die man nicht für sinnlose Aktionen verschwenden will!
“Das fällt doch gar nicht ins Gewicht!”
Nun das kommt auf den jeweiligen Fall an. Insbesondere wenn teure Aktionen wie aufwendige Berechnungen etc. folgen, kann es schon sehr ärgerlich sein, wenn sie umsonst sind. Und sei es nur, weil dem Kunden mal der Handyempfang weggebrochen ist oder es einen Netzwerkschluckauf gab.
Wenn man den Context cancelt
Viel erfreulicher wird dieser Fall mit einem korrekt implementierten Context. Bricht dem Anwender die Verbindung weg, wird der Context der gesamten Verbindung gecancelt. Folgeaktionen finden nicht mehr statt, weil sie in diesem Fall niemanden mehr hätten, dem sie den Erfolg melden könnten.

Fehlerhafter Go Service Workflow bricht erfreulicherweise schon beim Verbindungsaufbau zum Cache ab
Datenbank und auch E-Mail Service werden geschont. Controlling freut sich, dass die Server doch nicht teurer werden.
Wie läuft das mit dem gecancelten Context denn konkret im Code?
Dazu ein kleines Beispiel:
1func IncomingWebRequest(ctx context.Context, request string) {
2
3 standardTimeout := time.Second * 10
4
5 // wir wollen nur den context der einzelnen (!) Request canceln
6 zweiterContext, cancelFunc := context.WithTimeout(ctx, standardTimeout)
7
8 // falls wir diese Funktion aus Versehen verlassen, wollen wir kein Memory Leak etc.
9 // -> dann wird automatisch gecancelt
10 defer cancelFunc()
11
12 go func(ctx context.Context) {
13 getCacheResult(ctx, request)
14 }(zweiterContext)
15
16 // warten auf Ergebnisse
17}
18
19func getCacheResult(ctx context.Context, request string) {
20 getCacheResultChannel := cache.getResultChannel(request)
21
22 select {
23 case <-ctx.Done():
24 // context wurde gecancelt
25 return
26 case result<-getCacheResultChannel:
27 // cache erfolgreich abgefragt
28 // -> arbeite weiter (fiktiv), rufe weiteren Code auf
29 }
30}
Gecancelt wird hier nur der Context, der für diese einzelne Abfrage erstellt wurde. Ganz in unserem Sinne! Und wie man in Zeile 23 schön sehen kann, ist es sehr einfach auf einen gecancelten Context zu prüfen. context.Done()
liefert einfach ein Channel auf dem ein Element ankommt, wenn der Context gecancelt wurde.
Natürlich ist dieses Beispiel etwas realitätsfremd. Im echten Leben wären ein paar Dinge anders:
- Hier wird händisch mit
WithTimeout
gecancelt. Normalerweise würde das Canceln ja das entsprechende HTTP Framework bzw. der Router erledigen. (Wie z.B. go-chi) - Dabei würde inbesondere ein Verbindungsabbruch zum Canceln führen. Ein Timeout ist allerdings auch oft konfigurierbar (und sinnvoll!).
- Insgesamt würde der Router hier einige Details übernehmen.
Warum geben viele Context Funktionen eine cancel Funktion zurück?
Die Idee hierbei ist Sicherheit. Für den Fall, dass man die Funktion “ungeplant” verlässt bekommt man eine Funktion an die Hand, mit der man den Context trotzdem noch canceln kann. Diese wird deshalb üblicherweise mit defer
verwendet. So wird sie auf jeden Fall aufgerufen, wenn die Funktion beendet wird.
Wenn man die Funktion nämlich verlassen würde obwohl aufgerufener Code (go routine?) weiter läuft, hätten wir ein blödes Memory Leak geschaffen. Eine Zombie go routine würde ewig weiter laufen und ihren Speicher niemals freigeben. Bei möglicherweise wochen- oder monatelanger Laufzeit der Anwendung kann das in einen OOM-Kill münden: Die Anwendung überschreitet ihre Memory Limits und wird deshalb gekillt.
Nun zu dem weiteren, sehr praktischen Feature von Go Contexten.
Zweites Großes Feature: Langlebige Variablen bereitstellen
Mal angenommen man hat eine Anwendung, die Spieleserver erstellt, konfiguriert und Endnutzern bereitstellt (rein fiktives Beispiel 😉). Zu diesem Zweck gibt es mindestens zwei wichtige IDs:
userID
- Sie speichert - wer hätte es gedacht? - die ID des jeweiligen Nutzers. Sie ist eindeutig.serverID
- Speichert natürlich die eindeutige ID des jeweiligen Servers.
Wenn ein entsprechender Workflow abläuft, wäre es nicht enorm praktisch wenn diese beiden Variablen jederzeit für Logging oder sogar Tracing verfügbar wären?
Einfacher Ansatz: Ohne Context
Nun könnte man natürlich beide Variablen überall durchschleifen:
1
2func ProvideServer(
3 ctx context.Context,
4 userID string,
5 serverID string,
6 request string,
7 serverType ServerType
8 ) error {
9
10 // tue Dinge
11
12 // rufe andere Funktionen auf
13 ret, err := createServer(ctx, userID, serverID, serverType)
14
15 // tue weitere Dinge
16
17 return nil
18
19}
Nun, ich finde das persönlich nicht besonders ansprechenden Code. Gerade weil man userID
und serverID
ja im Grunde überall durchschleifen muss. Und dabei muss man konsequent sein, wenn man nicht nachträglich fluchend noch eine Reihe weiterer Codeänderungen haben möchte.
Schönerer Ansatz: Context to the rescue
Eine bessere Lösung für diesen Ansatz ist es Variablen über den Context verfügbar zu machen:
1
2func ProvideServer(
3 ctx context.Context,
4 userID string,
5 ) {
6
7 ctxWithVar := context.WithValue(ctx, "userID", userID)
8
9 someFunc(ctxWithVar)
10}
11func someFunc(ctx context.Context) {
12
13 userID := ctx.Value("userID") // nil wenn nicht gesetzt
14
15 fmt.Printf("server %s started", userID)
16}
Sieht gleich viel besser aus oder?
- Der Code ist übersichtlicher und leichter zu lesen.
- Man kann an zentraler Stelle - optimalerweise zu Beginn eines Workflows - die Variable(n) setzen und muss sich um nichts mehr kümmern.
Leider ist die Geschichte hier noch nicht zu Ende…
Nachteile wenn man es übertreibt
Es wird - nicht zuletzt in dem direkten Kommentar Block von WithValue()
- berechtigterweise vor einigen Dingen gewarnt:
Zum einen sollte man nur langlebige Variablen verwenden, die optimalerweise in einem direkten Zusammenhang mit dem aktuellen Workflow stehen. Das können zum Beispiel TraceIDs oder User IDs sein.
Es ist initial verlockend hier auch andere Variablen oder zum Beispiel optionale Variablen zu speichern. Wie schon oben gezeigt sieht der Code dadurch ersteinmal besser aus.
Allerdings verliert man dadurch Transparenz und Übersicht:
- Welche Variablen werden gerade transportiert? Kann man nur sehr aufwändig herausfinden.
- Wo werden diese an den Context gehängt? Ein Abenteuer beginnt …
- Wo werden die Variablen überall verwendet? 🤨
Was auf den ersten Blick besser aussieht, verschlechtert der Code tatsächlich. Wenn man es hier übertreibt, schleust man eine glorifizierte Blackbox durch seinen Workflow. Man kann sich nicht sicher sein, was da eigentlich gerade enthalten ist. Und will man sich sicher sein, muss man detektivisch den gesamten, vorangegangenen Workflow durchsuchen.
Ein letzter Hinweis zu den Variablen im Context
Der oben verwendete Code ist tatsächlich nicht optimal.
1func ProvideServer(
2 ctx context.Context,
3 userID string,
4 ) {
5
6 ctxWithVar := context.WithValue(ctx, "userID", userID)
7
8 someFunc(ctxWithVar)
9}
Wir verwenden hier einen String mit dem Wert “userID” als Key für das Speichern der Variable. Das Problem dabei ist, dass nachgelagerte Bibliotheken ihrerseits auch auf die Idee kommen könnten diesen Key zu verwenden. Diese bekommen ja denselben Context übergeben und würden dann - zum Beispiel - unsere ID überschreiben.
Erfreulicherweise lässt sich dieses Problem relativ einfach lösen: Wir erschaffen einfach einen eigenen Typ und benutzen den als Key.
1func ProvideServer(
2 ctx context.Context,
3 userID string,
4 ) {
5 type OurSpecialType string
6
7 ctxWithVar := context.WithValue(ctx, OurSpecialType("userID"), userID)
8
9 someFunc(ctxWithVar)
10}
Im Grunde ist der Key immer noch ein string
. Aber wenn man den dazu gehörigen Wert jetzt aus versehen überschreiben will, muss man dafür auch den genauen Typ kennen, sonst hat man damit keinen Erfolg. Und zufällig ist das so gut wie ausgeschlossen 😎.
TLDR
Mit dem Go Context kann man Workflows bequem abbrechen und so wertvolle Systemressourcen sparen. Man kann an den Context auch bequem Variablen hängen und die später abrufen. Dies sollte man sich aber in jedem Einzelfall gut überlegen. Der eigene Code könnte sonst deutlich unübersichtlicher werden.
Wie wäre es jetzt mit ein bisschen Entspannung bei deinem Lieblingsgame? Wir von Gameserver Express bieten Gameserver schon ab 9 Cent pro Stunde. Spare massiv Geld, weil du keinen Leerlauf bezahlst! Spielstände werden natürlich gespeichert.