Blog

Un peu de contexte

11/06/2022 | 9 minutes de lecture

Tags: go

Le package context de Go est un package très central et peut être très puissant si il est utilisé correctement. Par exemple, les package net/http ou grpc reposent sur lui. D’un autre coté, ses concepts peuvent être un peu difficiles à comprendre au début et peuvent être confusants, poussant les développeurs à l’ignorer ou mal l’utiliser.

Essayons d’explorer ces concepts.

Comme son nom le suggère, context permet de passer un contexte à un programme ou une partie de celui-ci, comme une fontion, un handler de requête ou une goroutine ; un contexte peut être des données arbitraires ou définies ou une deadline.

Par exemple, un contexte peut être :

  • Un temps maximum pour exécuter un appel réseau (aka “timeout” ou “dealine”)
  • Des valeurs spécifiques à une requête HTTP comme une authentification
  • Une configuration d’application passée à un autre package

Un contexte est porté par une variable implémentant l’interface context.Context, qui, par convention ou best practice, est passée aux fonctions comme premier argument, par exemple :

func myContextedFunc(ctx context.Context, foo string, bar interface{}) error {
    // ...
    return nil
}

De cette manière, un développeur sait qu’une fonction qui prend un context.Context comme premier argument prendra en compte et utilisera ce contexte et le passera en paramètre si elle appelle d’autres fonctions.

Les contextes sont presque tous dérivés d’un autre contexte, comme des poupées russes, ce qui veut dire qu’ils sont créés en disant “Je prends ce contexte, j’ajoute ou supprime ces propriétés, et voici le nouveau contexte qui en résulte”.

Les deux exeptions à ceci sont context.Background() et context.TODO().

Ces deux fonctions créent un nouveau contexte vide, avec aucune valeur ou deadline, et ne prennent aucun argument :

ctx := context.Background()
ctx2 := context.TODO()

Dans les faits, si l’on regarde les sources du package context, on peut voir qu’elles retournent une valeur identique, emptyCtx, un contexte vide.

Alors quelle est la différence etre context.Background() et context.TODO() ? Principalement lexicale.

context.Background() ne devrait être appellée qu’une seule fois par programme, au plus haut niveau, comme la fonction main() ou un package d’initialisation. Dans l’image des poupées russes, ce serait la plus grande poupée, celle qui contient toutes les autres. Le contexte retourné doit être envoyé à la première fonction prenant un contexte qui en créera un nouveau dérivant de ce contexte vide :

package main

func main() {
    ctx := context.Background()

    ctx = otherPackageA.Init(ctx)
    ctx = otherPackageB.Init(ctx)

    // ...
}

Le contexte créé dans cette fonction de plus haut niveau va désormais “couler” le long du programme, chaque partie héritant du contexte précedent en y ajoutant ses spécificités.

context.TODO() doit être utilisé lorsque l’on se dit “Je dois utiliser un contexte, mais je ne sais pas lequel encore.. On peut voir cette fonction comme écrire le commentaire suivant :

// @TODO : On envoit un nouveau contexte ici, il devrait être défini plus tard
package.FuncReceivingContext(context.TODO(), otherArg)

FuncReceivingContext va recevoir un nouveau contexte, non dérivé des autres, avec aucune valeur ni deadline.

Certains linters et outils d’analyse statique comme contextcheck s’assurent que tous les contextes utilisés dérivent d’un seul context.Background() et échouent si le code crée un autre contexte Backgound() quelque part. C’est ici que context.TODO() peut être utilisé.

Le package context offre 4 fonctions qui créent des contextes dérivés.

Un appel à WithCancel retourne un nouveau contexte derivé de celui passé en premier argument et une fonction.

Un appel à la méthode Done() du nouveau contexte retourne un channel qui sera fermé quand on appelle la fonction retrounée, nommée la cancel function.

Une fois que le channel retourné par Done() est fermé, la méthode Err() du nouveau contexte retourne l’erreur context.Canceled (dont le message est “context canceled”).

Ce pattern est utilisé pour indiquer à une fonction quand elle doit s’arrêter, par exemple :

package main

import (
	"context"
	"fmt"
	"time"
)

func counter(ctx context.Context) {
	start := time.Now()

	// Attend que le channel retourné par Done() soit fermé
	<-ctx.Done()
	// Et on récupere le temps écoulé depuis qu'on a lancé la fonction
	fmt.Printf("Vous avez attendu %.2f seconde avant d'appeller cancel()\n", time.Since(start).Seconds())
}

func main() {
	// On crée un Cancel contexte depuis context.Background()
	ctx, cancel := context.WithCancel(context.Background())
	// On lance counter() dans une goroutine
	go counter(ctx)
	// On attend 0,3 seconde
	time.Sleep(300 * time.Millisecond)
	// Et on appelle cancel() pour stopper l'exécution de counter()
	cancel()

	// Un sleep en plus est rajouté pour laisser le temps a fmt.Printf
	time.Sleep(time.Millisecond)
}

// Vous avez attendu 0.30 seconde avant d'appeller cancel()
(essayer ce code !)

WithCancel est souvent utilisé avec defer() pour exécuter des taches en arrière plan pendant l’exécution du programme:

package main

import (
	"context"
	"fmt"
	"time"
)

func runInBackground(ctx context.Context) {
    tick := time.NewTicker(60 * time.Second)
    go func() {
        for {
            select {
            case <-ctx.Done():
                // main s'est arrêté, on arrête cette tâche en arrière plan
                tick.Stop()
                return // return pour arrêter l'exécution de la goroutine (goroutine leak)
            case <- tick.C:
                // Faire quelque chose chaque minute pendant l'exécution du programme
            }
        }
    }()
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    runInBackground(ctx)

    // reste du programme ...
}

Si le contexte passé en argument à WithCancel est déjà un cancel contexte, le channel de Done() sera fermé quand la cancel function retournée sera appellée ou celle du parent, quelle que soit celle qui est appellée en premier.

WithDeadline crée un cancel contexte (voir ci-dessus), mais avec l’assurance que Done() sera fermé quand la deadline passée en second argument sera passée.

Si le channel de Done() est fermé suite à un appel à la cancel function, la méthode Err() m retournera une erreur context.Canceled, mais si il est fermé car la deadline est dépassée, il retournera une erreur context.DeadlineExceeded (dont le message est “context deadline exceeded”).

Ce pattern est utilisé pour donner à une fonction une deadline pour finir :

package main

import (
	"context"
	"fmt"
	"time"
)

func faitUnTrucLent(ctx context.Context) {
	select {
	case <-time.After(1 * time.Second):
		fmt.Printf("j'ai pu faire un truc lent\n")
	case <-ctx.Done():
		fmt.Printf("je n'ai pas pu faire un truc lent car %s\n", ctx.Err())
	}
}

func faitUnTrucRapide(ctx context.Context) {
	select {
	case <-time.After(1 * time.Microsecond):
		fmt.Printf("j'ai pu faire un truc rapide\n")
	case <-ctx.Done():
		fmt.Printf("je n'ai pas pu faire un truc rapide car %s\n", ctx.Err())
	}
}

func main() {
	deadline := time.Now().Add(500 * time.Millisecond)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	go faitUnTrucLent(ctx)
	go faitUnTrucRapide(ctx)

	time.Sleep(time.Second)
}

// j'ai pu faire un truc rapide
// je n'ai pas pu faire un truc lent car context deadline exceeded happened
(essayer ce code !)

Si le contexte parent (celui passé en premier argument) a déjà une deadline, le nouveau contexte sera défini avec la deadline la plus récente des deux.

Même si la deadline est dépassée, il est toujours de la responsabilité de la fonction qui crée le contexte avec deadline d’appeller la cancel function, car elle permet de libérer les ressources nécessaires au fonctionnement de ce contexte. Donc TOUJOURS appeller cancel() quand on crée des contextes de deadline.

WithTimeout fonctionne de la même façon que WithDeadline, a la seule différence qu’elle prend non pas une date (time.Time) mais une durée (time.Duration), qui sera ajoutée à l’instant présent (time.Now()) pour définir la deadline.

A la place de faire

deadline := time.Now().Add(500 * time.Millisecond)
newContext, cancel := context.WithDeadline(previousContext, deadline)
defer cancel()

On fait juste

newContext, cancel := context.WithTimeout(previousContext, 500 * time.Millisecond)
defer cancel()

WithValue est utilisée pour créer un nouveau contexte contenant une valeur. Cela peut être très utile si c’est bien utilisé, en respectant quelques consignes.

De base, n’importe quelle valeur peut être utilisée pour key and value du moment qu’elles sont d’un type qui est comparabale.

newContext := context.WithValue(previousContext, "réponse", 42)

Mais comme best practice (vraiment, respectez celui-ci), c’est important de ne jamais utiliser un type built-in (string, number, struct ou interface connues) pour la valeur de key. Pour un encore meilleur best practice, utilisez toujours un type non exporté du package qui utilise cette valeur.

La raisons à cela est que tout le monde peut ajouter ou remplace une valeur à un contexte en utilisant WithValue, cela veut dire que si vous utilisez une string pour key par exemple, une collision avec n’importe quel autre package utilisant la même valeur pour arriver et remplacer la valeur que vous avez définie.

Une implémentation typique de cette consigne est la suivante :

package monpackage

type contextValueKeyType string

var contextValueKey = contextValueKeyType("mon nom de clé")

func SetContextValue(ctx context.Context, value string) context.Context {
    return context.WithValue(ctx, contextValueKey, value)
}

func GetContextValue(ctx context.Context) (string, error) {
    v, ok := context.Value(ctx, contextValueKey).(string)
    if !ok {
        return "", errors.New("la valeur n'est pas dans le contexte")
    }
    return v, nil
}

Donc cette valeur pour être définie / lue par un autre package sans aucune collision possible :

ctx := context.Background()

value, err := monpackage.GetContextValue(ctx)
// err = errors.New("la valeur n'est pas dans le contexte")

ctx = myownpackage.SetContextValue(ctx, "test")
value, err := myownpackage.GetContextValue(ctx)
// value = "test"

Il est important de garder en tete que les valeurs de contexte ne sont pas faites pour passer des paramètres à une fonction mais pour conserver une/des valeur(s) dans une contexte donné, par exemple, un résultat d’authentification dans la gestion d’une requête HTTP ou une configuration dans l’ensemble d’un programme.

package main

import (
	"context"
	"net/http"
	"time"
)

func main() {
    // On crée un contexte avec une deadline dans deux secondes
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    // cancel() est appellée après l'exécution pour libérer les ressources
	defer cancel()
    
    // on passe le contexte a la requête HTTP
	req, err := http.NewRequestWithContext(ctx, "GET", "http://www.google.com", nil)

	if err != nil {
		panic(err)
	}

	client := http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		if err == context.DeadlineExceeded {
			panic("le fetch a pris plus de 2 secondes")
		}
		// une autre erreur est arrivée
		panic(err)
	}
	println(resp)
}

package main

import (
	"context"
	"fmt"
	"time"
)

// slowFunction est une fonction qui prend 200ms s'exécuter
func slowFunction() int {
	time.Sleep(200 * time.Millisecond)
	return 42
}

// wrapWithDeadline va s'assurer que slowFunction() est exécutée avant la deadline du contexte
// si celui-ci en a une.
func wrapWithDeadline(ctx context.Context) (int, error) {
	// On lance slowFunction() dans une goroutine, le resultat sera envoyé dans un channel
	res := make(chan int)
	go func() {
		res <- slowFunction()
	}()

	// Si le channel de résultat recoit le retour de slowFunction en premier, le résultat est retourné
	// Si la deadline expire en premier, l'erreur context.DeadlineExceeded est retournée
	select {
	case <-ctx.Done():
		return 0, ctx.Err()
	case v := <-res:
		return v, nil
	}
}

func main() {
	ctx := context.Background()

	ctx1, cancel1 := context.WithTimeout(ctx, 100*time.Millisecond)
	defer cancel1()
	res, err := wrapWithDeadline(ctx1)
	fmt.Println(res) // 0
	fmt.Println(err) // context deadline exceeded

	ctx2, cancel2 := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel2()
	res, err = wrapWithDeadline(ctx2)
	fmt.Println(res) // 42
	fmt.Println(err) // <nil>
}
(essayer ce code !)