Golang, comment définir un destructeur

16/03/2017

Si vous avez un peu bourlingué sur Go, vous savez qu’il n’existe pas de “destructeur” sur les structures. Mais en cherchant un peu, vous allez pouvoir utiliser le garbage collector et simuler un destructeur.

Je me suis rendu compte en utilisant un peu Go pour créer mon blog, que si je ne faisais pas attention je pouvais avoir des centaines de connections à la base Mongo ouvertes et jamais fermées. C’est évidemment un truc à éviter, mais le design de Go n’aide pas des masses. Du moins quand il s’agit de bosser avec des connections persistantes.

Il faut dire que le pattern très répandu en Go d’utiliser un “defer xxx.Close()” peut parfois passer à la trappe, personne n’est parfait et on peut simplement oublier de le faire. Et le résultat peut être relativement lourd, voir dangereux pour votre RAM.

Je vais réduire le problème à un cas simple, on va se créer un petit benchmark tout bête pour comprendre ce qu’il se passe.

Le bout de code qui fait mal

En premier je démarre une base mongo via Docker, c’est rapide à faire, ça fout pas en l’air le système, c’est jetable: j’aime ça!

docker run --rm -it -p 27017:27017 mongo

La base est là, elle attend plus que vous.

Voici maintenant un bout de code Go qui va exploser rapidement:

package main

import (
    "log"
    "time"

    mgo "gopkg.in/mgo.v2"
)

// Returns a NEW mongo session
func connect() *mgo.Session {
    s, _ := mgo.Dial("127.0.0.1")
    return s
}

// Create data in mongo
func createData(i interface{}) {
    s := connect()
    s.DB("data").C("foo").Insert(i)
    log.Println("create done")
}

func main() {
    for i := 0; i < 2000; i++ {
        createData(map[string]string{
            "user": "foo",
            "date": time.Now().String(),
        })
        time.Sleep(50 * time.Millisecond)
    }
}

Pour la faire courte, la fonction “connect()” crée une connexion à Mongo, la fonction “createData()” va appeler cette fonction et ajouter du contenu en base. Sauf que…

Vous l’avez remarqué, j’oublie de fermer la session (pas de “s.Close()”) ce qui a pour effet de laisser la connexion active.

En lançant le programme (go run main.go) après quelques secondes… plantage ! la base n’arrive plus à gérer toutes les connexions !

Il existe heureusement un moyen de virer la connexion si elle n’est plus utilisée, et ce en demandant au garbage collector (le “ramasse miette” dans notre chère langue) mais il faut chercher un peu.

La fonction runtime.SetFinalizer()

Dans le paquet “runtime”, dans lequel on trouve la fonction “GC” qui permet de forcer une passe de nettoyage, on trouve la fonction “SetFinalizer” qui peut nous aider.

Le principe est simple, en premier argument on lui passe une référence (une adresse) et en second, une fonction qui prend en argument le pointeur.Je fais bien la distinction ici (même si au final c’est pareil), pour bien vous indiquer que le premier argument sert bien au GC à trouver “l’adresse” de la variable qui va être géré. La fonction doit quant à elle connaitre le type pointé. Mais arrêtez de vous embrouiller le cerveau et regarder le bout de code ci-dessous:

runtime.SetFinalizer(s, func(s *mgo.Session) {
    log.Println("Close db")
    s.Close()
})

Cela indique au garbage collector que la référence “s” qu’il va pouvoir éliminer “s” de la mémoire en passant par la fonction indiquée. Et dans cette fonction, on appelle “s.Close()” qui va fermer la connexion. Pratique hein, parce que si plus aucune fonction n’utilise ma connexion, alors elle sera coupée, purement et simplement.

Comment ça se passe: La fonction “connect” crée un pointeur sur “mgo.Session”, l’adresse est donc référencée par cette fonction, le GC ne l’a pas encore collecté. Ensuite, elle est récupérée par “createData()”, l’adresse est donc encore référencée, toujours pas de collectage. Après que les deux fonctions aient utilisé cette adresse “s”, plus personne ne l’utilise. À partir de maintenant, le collector trouve cette adresse contenant un “objet” que personne n’utilise, il va pouvoir utiliser la fonction qu’on lui a donné pour effectuer les opérations qu’on lui demande. Et par conséquent, ici, on ferme la connexion.

Après cela, la mémoire est libérée.

Mais y’a un effet de bord à prendre en compte… Si jamais l’utilisateur a pensé à couper la connexion, le “finalizer” sera quand même appelé (car la référence est toujours présente lors de la passe de GC). On ajoute donc une opération (la fonction en argument) à exécuter lors d’une passe de GC. C’est le prix à payer…

Avant d’aller plus loin, je vous met en garde et je vous demande de lire ce très bon article:

Il faut effectivement utiliser cette fonction dans des cas précis, si et seulement si c’est vraiment nécessaire.

Un autre article intéressant:

le nouveau code

On a donc maintenant ce code:

package main

import (
    "log"
    "runtime"
    "time"

    mgo "gopkg.in/mgo.v2"
)

// Returns a NEW mongo session
func connect() *mgo.Session {
    s, _ := mgo.Dial("127.0.0.1")
    // clean session when GC pass
    runtime.SetFinalizer(s, func(s *mgo.Session) {
        log.Println("Close db")
        s.Close()
    })
    return s
}

// Create data in mongo
func createData(i interface{}) {
    s := connect()
    s.DB("data").C("foo").Insert(i)
    log.Println("create done")
}

func main() {
    for i := 0; i < 2000; i++ {
        createData(map[string]string{
            "user": "foo",
            "date": time.Now().String(),
        })
        time.Sleep(50 * time.Millisecond)
    }
}

Coupez le conteneur Docker mongo (parce qu’il est pété là hein…) et relancez le. Puis on relance le programme: go run main.go

Après un certain temps (chez moi, après 200 connexions environs), je vois une pile de connexions se fermer. Je vous d’ailleurs le compteur de connexion active dans les logs Mongo descendre d’un coup.

On a donc ici une manière assez élégante de nettoyer les connexions orphelines. Le but n’est pas de permettre à un utilisateur de ne pas fermer lui-même la connexion, mais si il l’a oublié alors on indique au GC qu’il pourra faire le ménage de cette manière. En gros, c’est “en cas d’oubli, on fixe plus tard”.

Aller un peu plus loin

Il est possible de forcer le garbage collector à nettoyer la mémoire en appelant “runtime.GC()”, dans le cas où votre programme est relativement chargé, cela peut avoir son effet. Il faut simplement prévoir à quel moment lancer la commande, voir créer une goroutine qui le fait régulièrement.

C’est en général une mauvaise idée, car normalement le GC passe nettoyer la mémoire tout seul comme un grand assez régulièrement. Mais il se peut que dans de rares cas ce soit vraiment nécessaire (j’ai eu ce cas malheureusement), et dans ce cas vous pouvez demander un nettoyage forcé.

Mais on le fait ou pas ?

En général, il faut éviter. Gardez à l’esprit que le développeur qui bosse avec vous doit avoir les informations nécessaires pour faire ce qu’il faut. De ce fait, il faut préciser qu’après chaque connexion à la base de données il sera nécessaire de faire un bon vieux:

defer s.Close()

Sauf que comme je le dis plus haut, un oubli peut arriver. Et si vous utilisez fortement des opérations qui ont un risque de faire un leak par oubli du développeur, là franchement je conseille de protéger l’exécution avec un “finalizer”. Si votre équipe a pensé à fermer les connexions, ça n’impactera pas les performances. Si ne serait-ce qu’une connexion est oubliée, au moins vous êtes certain qu’elle sera fermée du moment ou elle n’est plus du tout utilisée.

Je précise un point important quand même: dans notre exemple, si je ferme la connexion après utilisation tout se passe bien. Mais si j’oublie de la fermer, elle reste active le temps que je GC fasse une passe. D’où l’intérêt de ne pas attendre que le GC passe et de fermer la connexion manuellement (avec un defer). C’est bien plus optimal de faire cette opération au plus tôt !

Ce que je dis, et je sais que j’insiste, c’est qu’un finalizer est une solution “de secours”, ça ne doit pas être le fonctionnement par défaut !

Simuler un destructeur

Je suis pas fan de la simulation de POO en Go, mais comme le langage permet de faire du pseudo objet, on peut parfois avoir envie de travailler à la manière d’une classe avec destructeur.

Dans l’absolu, un “finalizer” est aussi un moyen de simuler un destructeur, qu’on va appeler “Close” dans l’exemple ci-après:

type  User struc {
    Name string
}

func (u *User) Close() {
    log.Println("Destruction of user", u.Name)
}

func NewUser() *User {
    u := &User()
    runtime.SetFinalizer(u, func (u *User) {
        u.Close()
    })
    return u
}

Si on pense bien à utiliser “NewUser” (le constructeur), alors l’objet retourné peut être détruit quand le garbage collector fera une passe, et seulement si l’adresse “u” n’est plus utilisée. Aussi, pensez à laisser la fonction de destruction accessible aux développeur pour qu’il puisse supprimer manuellement l’objet (ce qui est encore une fois la recommandation !).

Donc, on pourra faire:

// là le GC ne passera jamais (on a pas utilise de constructeur)
u := &User{}
defer u.Close()

// ou

u := NewUser()
// et laisser faire le GC 
// ou utiliser u.Close() manuellement

La première méthode étant la plus judicieuse car elle n’attend pas une passe du GC. Mais encor faut-il que le développeur pense bien à utiliser “u.Close()”…

Une autre manière de voir les choses

Puisqu’on veut éviter de manipuler le GC, on peut adapter des pattern venus d’autres langages pour réduire l’impact. J’apprécie fortement le pattern “with” de Python (qui existe dans d’autres langages) et on peut très facilement l’implémenter en Go.

En premier lieu, on se prévoit une interface “Closer” qui sait “fermer quelque chose”:

type Closer interface {
    Close()
}

Reste à imaginer une fonction “With” qui va imiter ce que fait Python:

func With(c Closer, f func()) error {
    defer c.Close()
    f()
}

Admettez que c’était pas compliqué hein.

“mgo.Session” contient justement une méthode “Close” qui respecte l’interface, c’est voulu.

Du coup, on peut coder notre fonction de création de données ainsi:

sess := connect()
With(sess, func() {
    sess.DB("data").C("foo").Insert(i)
    log.Println("create done")
})

La cloture fonctionnera comme désiré, pas la peine de passer par de la manipulation de GC.

Voilà l’exemple complet:

package main

import (
    "log"
    "time"

    mgo "gopkg.in/mgo.v2"
)

type Closer interface {
    Close()
}

func With(c Closer, f func()) {
    defer c.Close()
    f()
}

func connect() *mgo.Session {
    s, _ := mgo.Dial("127.0.0.1")
    return s
}

func createData(i interface{}) {
    sess := connect()
    With(sess, func() {
        sess.DB("data").C("foo").Insert(i)
        log.Println("create done")
    })
}

func main() {
    for i := 0; i < 2000; i++ {
        createData(map[string]string{
            "user": "foo",
            "date": time.Now().String(),
        })
        time.Sleep(50 * time.Millisecond)
    }
}

Cette fois, plus de problème, les connexions sont fermées en sortie de With, et le code est relativement clair.

Conclusion

J’arrête pas de le dire, mais il faut que ce soit clair: il faut éviter d’utiliser SetFinalizer. Cela-dit, je sais que vous avez certainement rencontré un souci de variable non supprimée, de connexion qui persiste sans intérêt. Ainsi, on peut faire en sorte de contrôler le GC, de l’appeler explicitement et même de forcer des comportements de destruction.

Dans la mesure du possible, j’estime qu’un développeur Go doit être informé des spécificités du langage et par conséquent il doit connaitre les problématiques de fermeture de descripteurs. Prendre l’habitude d’ajouter un “defer p.Close()” n’est pas une complexité insurmontable. Mais je suis aussi un défenseur du principe KISS, et je suis absolument d’accord avec le fait d’utiliser des patterns si et seulement si ils s’avèrent vraiment utiles et que le langage pose un souci. J’ai beau adorer Go, je ne peux pas défendre le fait qu’il soit aussi user-friendly que Python ou JS. Dans ce cas, j’admet volontier que certains patterns soient utiles et adaptés. Mais j’en reviens à mon article sur les design patterns - donc, adapter un code source pour utiliser “With” tel que je le montre, ou ajouter un “finalizer” ne me pose pas plus de problème que ça à condition que ça ne devienne pas un foutoir ingérable et imbitable.

Faites juste attention de ne pas en abuser. Soyez judicieux, simplement !

Ça peut vous intéresser aussi


Mise à jour du blog en Go

Si vous connaissiez mon blog, vous avez remarqué qu’à ...


Golang, résoudre le souci d'indexation de type défini

Golang permet de créer ses prores types et notamment de ...


Installer localement Golang

Toujours en cours d’écriture d’un livre sur le ...


Créer une API en GO avec Gorilla

Bon, on va parler Go ou Golang (faudra me dire ...

Merci de m'aider à financer mes services

Si vous avez apprécié cet article, je vous serai reconnaissant de m'aider à me payer une petite bière :)

Si vous voulez en savoir plus sur l'utilisation de flattr sur mon blog, lisez cette page: Ayez pitié de moi

Commentaires

Ajouter un commentaire

Ajouter un commentaire

(*) Votre e-mail ne sera ni revendu, ni rendu public, ni utilisé pour vous proposer des mails commerciaux. Il n'est utilisé que pour vous contacter en cas de souci avec le contenu du commentaire, ou pour vous prévenir d'un nouveau commentaire si vous avez coché la case prévue à cet effet.