Die Golumne

Go – Im richtigen Context

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

In nebenläufigen Serveranwendungen ist es ein gängiges Muster, dass Anfragen in unterschiedlichen Prozessen, in Go also Goroutinen, verarbeitet werden. Für eine überlappende Ausführung sind sie gleicher Natur, für unterschiedliche Bestandteile von Aufträgen wiederum individuell: Ein Netzwerk von Goroutinen, das sich um immer wieder neue Aufgaben kümmert, jede in einem eigenen Kontext stehend. In Go findet sich der entsprechende Kontext in context.Context wieder.

In einem Blogbeitrag von 2014 betrachtete das Go-Team das Thema zusammenarbeitender Goroutinen. Sie beschreiben darin Web-Requests, die in den registrierten Handlern eines Webservers ankommen und über Backends wie Datenbanken oder via RPC-gekoppelten externen Systemen beantwortet werden. Dabei stellte der Eintrag auch den Typ Context vor, der seinerzeit noch im externen Package golang.org/x/net/context implementiert war. Nach weitreichenderen Erfahrungen mit dem Typ in internen und externen Projekten, wie zum Beispiel bei Docker und Kubernetes, rückte der Context mit Veröffentlichung von Go 1.7 in die Standard-Library. Gleichzeitig wurden die Packages net, net/http und os/exec um die Nutzung des Kontexts erweitert.

Rahmenabkommen

Doch worum handelt es sich beim Typ context.Context eigentlich? Dreh- und Angelpunkt des Packages context ist dieses eine Interface, unterschiedliche Implementierungen im Paket sind dem Nutzer verborgen. Dazu kommen zwei Funktionen für die Erzeugung initialer Kontexte, beide ohne weitere Argumente. Dabei handelt es sich um Background() Context für die Wurzel aller weiteren abgeleiteten Kontexte im Rahmen der Auftragsverarbeitung oder alternativ TODO() Context, falls für die Nutzung eines API mit Kontext noch nicht klar ist, welcher Kontext genutzt werden soll. Als Beispiel sei an dieser Stelle die Funktion CommandContext() in os/exec genannt. Der erzeugte Context ist dabei das erste Argument. So weit, so gut – doch wie unterscheidet sich das von der Funktion Command(), die ohne einen Kontext auskommt? Hier lohnt sich ein Blick auf die im Interface definierten Methoden.

Die Idee hinter dem Typ ist, die zusammenhängenden Aspekte der oben bereits angesprochenen Anfragen oder Aufträge an die beteiligten Funktionen und Goroutinen weiterzugeben. Eine Facette ist hier die aktive Beendigung einer solchen Anfrage bei allen Beteiligten. Das kann im Rahmen eines Timeouts oder mit einem manuell ausgelösten Abbruch geschehen. Bei einer individuellen Goroutine, die als eine Art Server in meiner Anwendung arbeitet, kann ich dies leicht über einen individuellen Channel erreichen, den ich zum Stoppen der Goroutine schließe. Doch wie ist es dann mit einem einfachen Funktionsaufruf wie dem CommandContext() von oben?

DevOpsCon Istio Cheat Sheet

Free: BRAND NEW DevOps Istio Cheat Sheet

Ever felt like service mesh chaos is taking over? As a follow-up to our previous cheat sheet featuring the most important commands and functions, DevOpsCon speaker Michael Hofmann has put together the 8 best practices you should keep in mind when using Istio.

Ein eventuelles Timeout könnte ein Argument sein, als Dauer oder als Zeitpunkt. Doch es kann ja auch sein, dass ich die Funktion mit verschiedenen nebenläufig ablaufenden starte, und bei einem Fehler möchte ich sie, wie alle anderen auch, beenden. Anstelle einer Vielzahl von Varianten der Funktion mit unterschiedlichen Argumenten wird hier als erstes Argument unser Context mitgegeben, normal mit dem Bezeichner ctx. Dieser bietet dann die Methode ctx.Done() <-chan struct{}, die in einem select-Statement für die Prüfung genutzt werden kann, ob der Kontext noch aktiv ist. Ebenso kann mit ctx.Err() error angefragt werden, warum der Kontext eventuell beendet wurde.

Doch wie komme ich jetzt dazu, diesem Kontext mein Timeout mitzugeben oder ihn aktiv stoppen zu können? Die beiden bereits genannten Konstruktoren Background() und TODO() verfügen nicht über derartige Argumente und entsprechende Setter gibt es ebenfalls nicht. Zum Glück, wären es doch sonst im Rahmen der Nebenläufigkeit wunderbare Fallen für Race Conditions und konkurrierende Settings. Ein Blick in das Package zeigt daher vier weitere Funktionen, die jeweils einen Kontext und, je nach Zweck, mehr Argumente entgegennehmen sowie einen Kontext und eventuell noch mehr zurückgeben. Diese Funktionen folgen alle dem Namensmuster With…(). Sie erzeugen jeweils passende neue Kontexte, in denen der übergebene Kontext als Parent enthalten ist und die die gewünschten Funktionalitäten hinzufügen. So wird der Kontext entsprechend der jeweiligen Call Paths wie ein Baum auf- und nach dem Ende des Scopes wieder abgebaut.

Starten wir als erstes mit einem Szenario, in dem innerhalb meiner Businessfunktion eine weitere Funktion mit go in den Hintergrund geschickt werden soll. Abhängig vom Programmverlauf im Vordergrund soll diese Funktion jedoch auch wieder gestoppt werden können, zum Beispiel im Falle eines Fehlers. Insofern erhält meine äußere Funktion selbst einen Kontext von ihrem Aufrufer, dazu eine Reihe weiterer Argumente zur eigentlichen Verarbeitung (Listing 1).

func DoSomething(ctx context.Context, xs []X, ys []Y) error {
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()

  go processXs(ctx, xs)
  
  for _, y := range ys {
    select {
    case <-ctx.Done():
      return ctx.Err()
    default:
      if err := processY(y); err != nil {
        return err
      }
    }
  }

  return nil
}

In dem Moment, in dem sich DoSomething() beendet, zum Beispiel durch einen internen Fehler bei der Verarbeitung eines y, wird die Funktion cancel() über defer aufgerufen und so die Goroutine processXs() – sofern sie noch arbeitet – abgebrochen. Dabei muss natürlich auch der Kontext via <-ctx.Done() ausgewertet werden. Der Kontext, der DoSomething() vom Aufrufer mitgegeben wurde, kann auch schon eine Cancel-Funktion besitzen. Der Typ dieser Funktion ist im context Package als type CancelFunc func() definiert.

Doch es gibt noch mehr. Mit der Funktion WithTimeout() kann ein Kontext auch mit einem Timeout in Form einer time.Duration ausgestattet werden. Diese erzeugt gleichzeitig auch eine Cancel-Funktion, die dem Aufruf zurückgegeben wird. Nach Ablauf der Dauer wird diese Funktion intern aufgerufen und damit der Context beendet. Der Aufruf der Funktion DoSomething() wird in Listing 2 bei Bedarf nach 5 Sekunden abgebrochen.

timeout := 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

err := DoSomething(ctx, xs, ys)

Jetzt haben wir bereits zwei der erweiternden Funktionen kennengelernt: context.WithCancel() und context.WithTimeout(). Das Setzen eines festen Zeitpunkts ist mit der zweiten verwandt: Die Funktion lautet context.WithDeadline() und das zweite Argument ist time.Time. Für beide Fälle kann der Empfänger des Context mit dessen Methode Deadline() (deadline time.Time, ok bool) einen eventuell gesetzten Endzeitpunkt abfragen. In einem solchen Fall ist ok == true, ohne ein Timeout oder eine Deadline ist ok == false. Im Package geschieht das intern, um einerseits zu prüfen, ob ein übergebener Kontext nicht bereits abgelaufen ist, und andererseits, ob die Deadline des Kontexts im übergebenen vor oder nach einem bereits existierenden liegt. So können die eingesetzten Timer effizienter eingesetzt werden.

Bleibt noch eine letzte Variante: Mit der Funktion context.WithValue() kann einem Kontext ein Schlüsselwertpaar mitgegeben werden. Dieser Wert kann dann vom Nutzer des Context mit einem value := ctx.Value(key) abgefragt werden. Existiert dieser Schlüssel nicht, so wird nil zurückgegeben, andernfalls ein interface{}, es ist also noch ein Type Assert notwendig. Das wird vielleicht erst mit Go 2 und eventuellen Generics besser. Doch immerhin können wir den Test auf nil sowie den Type Assert in einem Einzeiler unterbringen. In einem Package, das seinem Kontext einen Wert mitgibt, lässt sich das schön in zwei Funktionen implementieren und so für den Nutzer leichter machen (Listing 3).

type key string

var metricsKey = key("metrics")

func WithMetrics(ctx context.Context, m *Metrics) context.Context {
  return context.WithValue(ctx, metricsKey, m)
}

func FromContext(ctx context.Context) (*Metrics, bool) {
  return ctx.Value(metricsKey).(*Metrics)
}

Bleibt die Frage, warum der Key über einen eigenen Typ verfügt. Eine der wesentlichen Anforderung an Schlüssel ist ihre Vergleichbarkeit, um zu schauen, ob der Kontext über den Schlüssel verfügt. Gleichzeitig sollen sich Schlüssel aber auch nicht überlagern können. Wäre der Schlüsseltyp nur ein string, so könnte ein späteres WithValue() meinen zuvor gesetzten Schlüssel überdecken. Daher werden sie nur als Empty Interfaces gespeichert und der Nutzer ist gehalten, sich einen eigenen privaten Typ zu definieren.

Während einer Abfrage via ctx.Value() wird der Schlüssel im aktuellen Kontext mit dem übergebenen verglichen. Sollten beide in Typ und Wert nicht übereinstimmen, wird die Abfrage des Value() im Elternkontext wiederholt. Letztendlich landet eine solche Abfragekette ohne einen gültigen Schlüssel im Background oder im TODO, wo immer nil zurückgegeben wird.

Im Einsatz

In der Nutzung des context.Context ist der Name immer wörtlich zu nehmen. Den Rahmen bilden Funktionen und Goroutinen, die in einem gemeinsamen Kontext stehen, und die bei Bedarf unterbrochen werden sollen oder orthogonal Informationen benötigen, die jenseits der regulären Funktionsargumente liegen. Sie alle sollten über einen Kontext als erstes Argument verfügen, wie etwa DoSomething() in Listing 1. Ein initialer Aufrufer kann, solange nicht bekannt ist, was noch alles benötigt wird, erst einmal einen TODO-Kontext mitgeben. Später wird dieser dann auf Basis des Backgroundkontexts verfeinert.

Hier sprechen das einfache WithCancel() oder die beiden Geschwister WithTimeout() und WithDeadline() für sich. Sie erlauben dem Aufrufer, die Kontrolle über die Laufzeit der beteiligten Komponenten zu behalten, sie also aktiv zu beenden. Das bezieht sich auf den ganzen Baum der Kontexte, von denen ich als Nutzer ja nicht einmal weiß, ob sie intern nebenläufig sind oder nicht. So kann eine für mich reguläre Funktion intern vielleicht mehrere Goroutinen starten und auf ihre Rückgaben warten. Ich führe sie synchron aus, kann ihr aber ein Timeout mitgeben (Listing 4).

// Process in 3rd party package "processor".
func Process(ctx context.Context, args ...Arg) ([]Result, error) {
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()
  resultC := make(chan Result, 1)
  results := []Result{}

  for _, arg := range Args {
    go processArg(ctx, arg, resultC)
  }

  for len(results) < len(args) {
    select {
    case <-ctx.Done():
      return nil, ctx.Err()
    case result := <-resultC:
      if result.Err != nil {
        // First error stops all goroutines.
        return nil, result.Err
      }
      results = append(results, result)
    }
  }

  return results, nil
}

// main in my package "main".
func main() {
  timeout := 30 * time.Second
  ctx, cancel := context.WithTimeout(context.Background(), timeout)
  defer cancel()

  args, err := reader.ReadArgs(ctx)
  if err != nil {
    // Ouch!
  }
  results, err := processor.Process(ctx, args...)
  if err != nil {
    // Ouch!
  }

  fmt.Printf("Results: %v", results)
}

In dieser Form erlauben sie dem Empfänger eines Kontexts, sich von außen zur Laufzeit beenden zu lassen. Allerdings ist dieser Typ nicht dafür gedacht, einer als Server dienenden Goroutine einen Stopmechanismus mitzugeben. Das sollte lieber über chan struct{} oder einer ähnlichen Funktionalität realisiert werden.

Mit WithValue() ist es etwas anders. Die Funktion verleitet dazu, in ihr Argumente für den unmittelbaren Empfänger zu transportieren, insbesondere dessen optionale Argumente. Doch dafür ist diese Funktionalität nicht gedacht. Vielmehr sollte man überlegen, was übergreifend für mehrere der beteiligten Komponenten in meinem jeweiligen Scope ist. Die Go-Truppe hat in ihrem Blogbeitrag ein Beispiel, in dem innerhalb eines HTTP Handlers aus einem Request die IP-Adresse des Clients extrahiert und im Kontext gespeichert wird. So können die weiteren Komponenten diese Information für sich nutzen. Es könnten aber auch zum Beispiel die aus einem JSON Web Token erlangten Informationen über Tokengültigkeit, Nutzer und Rechte sein. Oder eine aus einem Pool bezogene Verbindung zu einem externen Service, die am Ende der Verarbeitung dem Pool zurückgegeben wird. Ebenso spannend ist ein Typ zur Sammlung von Metriken innerhalb der Verarbeitung eines Request. Ideen gibt es hier genug.

Ein kompaktes Package mit einem nach außen hin kleinen, aber feinen Typen. Doch die Stärke von Go, die Nebenläufigkeit, bringt auch so manche Herausforderung mit sich. Hier hilft der Context meinen ansonsten einfachen Call Stack im Blick zu behalten und steuern zu können. Die Nutzung in eigenen Funktionen ist nicht immer leicht, die Values als Empty Interfaces nicht elegant und ein Rückkanal, ob und wie sich einer der Kindkontexte beendet hat, ist nicht enthalten. Hier muss Go 2 abgewartet werden. Doch bereits jetzt ist context.Context ein praktischer Helfer.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: