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 channelstop
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’unSIGTERM
).
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 ;)
Add a comment
If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).