3 décembre 2018

Golang: gérez l'arrêt de vos systèmes avec Tomb

On cite souvent Go comme un langage de programmation facilitant la programmation concurrente et parallèle via les goroutines. Les goroutines ne sont pourtant pas si faciles que ça à utiliser correctement. Voyons comment utiliser la bibliothèque tomb pour les contrôler.

Une goroutine réalisant des requêtes HTTP

Voici un simple programme réalisant plusieurs choses:

  • Un channel nommé stop est initialisé.

  • Une goroutine est démarrée. Cette goroutine va réaliser une requête HTTP sur https://mcorbin.fr toutes les 2 secondes.

  • Une deuxième goroutine est démarrée. Cette goroutine écoute les signaux SIGTERM envoyés à l’application et poussera une valeur dans le channel stop lors de la réception d’un signal.

  • ←stop bloquera tant qu’une valeur n’aura pas été poussée dans ce channel (ce qui ne se produit qu’en cas de réception d’un SIGTERM).

package main

import (
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	stop := make(chan string)
	go func() {
		for {
			time.Sleep(2 * time.Second)

			r, err := http.Get("https://mcorbin.fr")
			if err != nil {
				fmt.Println(err)
			}
			fmt.Printf("%d\n", r.StatusCode)
		}
	}()
	go func() {
		sig := make(chan os.Signal, 1)
		signal.Notify(sig, syscall.SIGTERM)
		s := <-sig
		fmt.Printf("received signal %s\n", s)
		stop <- "done"
	}()
	<-stop
}

Si vous compilez et lancez ce programme, vous devrez avoir cet output:

$ ./example
200
200
200
200
...

En récupérant le PID du processus (avec ps aux par exemple), vous pouvez envoyer un signal SIGTERM avec la commande kill PID. L’output de votre programme devrait être:

...
200
received signal terminated

Ce programme semble fonctionner comme attendu mais présente un défaut majeur. Ici, la goroutine réalisant les requêtes HTTP sera terminée brutalement lors de l’arrêt du programme. Ce n’est pas très grave, mais imaginons que votre goroutine fasse des choses plus importantes. Peut être aimeriez-vous la terminer proprement ?

Cela serait par exemple possible en utilisant un autre channel qui lui contrôlera l’arrêt de la goroutine. Le programme suivant réalise cela:

package main

import (
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	stop := make(chan string)
	done := make(chan string)
	go func() {
		for {
			time.Sleep(2 * time.Second)
			select {
			case <-done:
				fmt.Println("terminate the goroutine")
				stop <- "done"
				return
			default:
				r, err := http.Get("https://mcorbin.fr")
				if err != nil {
					fmt.Println(err)
				}
				fmt.Printf("%d\n", r.StatusCode)
			}
		}
	}()
	go func() {
		sig := make(chan os.Signal, 1)
		signal.Notify(sig, syscall.SIGTERM)
		s := <-sig
		fmt.Printf("received signal %s\n", s)
		done <- "done"
	}()
	<-stop
}

Ici, la goroutine gérant les signaux poussera une valeur dans le channel done en cas de SIGTERM. Ce channel est ensuite utilisé dans la première goroutine, qui captera cette valeur, poussera une nouvelle valeur dans le channel stop ce qui terminera le programme. De cette façon, vous avez la garantie que le traitement dans la clause default du select se terminera avant l’arrêt du programme.

L’inconvénient de ce genre de système est la multiplication des channels, et le manque de gestion d’erreurs (la goroutine ne peut pas informer si elle s’est correctement terminée ou non).

Tomb

Tomb est une petite bibliothèque permettant de gérer de façon efficace l’arrêt de vos goroutines. Voici le programme précédent réécrit en utilisant tomb:

package main

import (
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"gopkg.in/tomb.v2"
)

func main() {
	var t tomb.Tomb
	t.Go(func() error {
		for {
			time.Sleep(2 * time.Second)
			select {
			case <-t.Dying():
				fmt.Println("terminate the goroutine")
				return nil
			default:
				r, err := http.Get("https://mcorbin.fr")
				if err != nil {
					fmt.Println(err)
				}
				fmt.Printf("%d\n", r.StatusCode)
			}
		}
	})
	t.Go(func() error {
		sig := make(chan os.Signal, 1)
		signal.Notify(sig, syscall.SIGTERM)
		s := <-sig
		fmt.Printf("received signal %s\n", s)
		t.Kill(nil)
		return nil
	})
	err := t.Wait()
	if err != nil {
		fmt.Println(err)
	}
}

Tout d’abord, une variable t tomb.Tomb est déclarée. Pas besoin de plus pour initialiser une tomb, les valeurs par défaut suffisent.

On voit ensuite que les goroutines sont démarrées via t.Go(…​). Cette fonction est semblable à la fonction go de Golang, sauf qu’ici la tomb "controlera" la goroutine. De plus, l’argument de t.Go(…​) doit forcément être une fonction retournant une erreur.

Autre changement, nous vérifions si notre goroutine se termine en consommant le channel t.Dying().
Ensuite, dans notre goroutine gérant les signaux, la fonction t.Kill(nil) est appelé en cas de SIGTERM. Cette fonction placera la tomb dans l’état dying, et fermera le channel t.Dying() (ce qui terminera donc notre première goroutine).
Le paramètre de la fonction Kill est la raison de l’état de l’arrêt de la goroutine, et doit être une error ou nil.

Gestion des erreurs

La fonction passée en paramètre de t.Go doi, comme dit précédemment, forcément retourner une erreur. D’ailleurs, voici ce que la documentation de tomb indique à son sujet:

If f returns a non-nil error, t.Kill is called with that error as the death reason parameter.

Il est donc possible de terminer une tomb en retournant une erreur depuis la goroutine, la fonction Kill n’a donc pas pas être appelée explicitement.

La raison (e.g l’erreur) de la mort de la goroutine peut donc avoir deux sources: l’appel manuel à t.Kill en passant une valeur non nil, ou bien via une goroutine retournant une erreur.

Dans mon code précédent, j’appelle également err := t.Wait(). La fonction Wait() va bloquer jusqu’à ce que la tomb meurt, et que toutes les goroutines gérées par la tomb soient terminées. La fonction retourne ensuite la raison de la mort de la goroutine.
Cette fonction a l’avantage de nous garantir que les goroutines sont bien terminées lorsqu’elle se "débloque" (mais attention aux deadlocks !).

D’autres fonctions existent, comme Alive ou Dead, et sont très bien expliquées dans la documentation.

Petite précision sur Wait(): la fonction bloquera pour toujours si aucune goroutine n’est managée par la tomb (cf cette issue).

Context !

En Go, on utilise généralement les context pour annuler/arrêter une requêtes, gérer des timeouts etc…​ Cet article n’a pas vocation à présenter les context en détail, mais il est à noter que tomb supporte les context.

Le bloc default de notre première goroutine pourrait par exemple ressembler à ça:

ctx := t.Context(nil)
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequest("GET", "https://mcorbin.fr", nil)
if err != nil {
	return err
}
req = req.WithContext(timeoutCtx)
client := http.DefaultClient
r, err := client.Do(req)
if err != nil {
	return err
}
fmt.Printf("%d\n", r.StatusCode)

Ici, nous créons un premier context depuis la tomb, puis un second context (gérant le timeout) depuis le premier context.
Lorsque la tomb sera tuée, le context sera automatiquement terminé également, ce qui peut s’avérer utile pour être sûr que certains appels (ici notre appel HTTP) soient terminés le plus vite possible.

Vous pourrez par exemple voir ce genre de messages lorsque vous envoyez un SIGTERM à l’application:

$ ./example
200
received signal terminated
Get https://mcorbin.fr: context canceled

Conclusion

tomb est une bibliothèque extrêmement pratique, et est une brique de base pour mes projets Go. N’hésitez pas à utiliser plusieurs tomb dans vos programmes, rien de nous force à contrôler toutes vos goroutines avec la même tomb (ce qui peut s’avérer dangereux).

J’espère vous avoir convaincu de l’utilité de cette bibliothèque ;)

Tags: programming golang
Top of page