Un exemple Golang de résolution de tâche parallèle

30/10/2015

J’ai participé aux BlendWebMix 2015 en tant que “speaker” avec un grand plaisirs. J’y ai présenté “Golang pour le web” afin d’expliquer comment ce langage est en train d’entrer dans les moeurs et va permettre de développer des applications Web performantes. Je vais vous montrer un exemple que j’ai traité lors d’une démo improvisée le lendemain sur un coin de bureau. Le fait est qu’on m’a beaucoup parlé après la conférence au stand “Smile” et qu’une question récurrente m’était posé: “As-tu un exemple concret que je ne peux pas résoudre en PHP par exemple, dans mon application/site Web ?”. J’en avais quelques-uns, et j’ai décidé de montrer le suivant: des miliers de personnes veulent remplir un formulaire de contact en simultané, comment je vais gérer tout ça en parallèle sans planter mon serveur de mail SMTP ? Voilà la démo, revue et corrigée.

Pour remettre les choses dans leur contexte, la conférence présentait le développement d’un prototype de site avec Martini. Mais j’ai laissé entendre que Go pouvait permettre la résolution de tâche concurrente de manière simple. Bien entendu les connaisseurs du langage savent très bien que toute la saveur de Go se trouve dans ce principe de gestion et de synchronisation de “goroutines”.

Conférence Golang - BlendWebMix 2015

J’ai donc improvisé une démo hors conférence le lendemain en 10 minutes, entre deux pilones, sur un coin de bureau. Et comme je suis un éternel insatisfait (laissant une coquille en fin de démo pour avoir le temps de conclure), je ne peux pas aller dormir sans mettre au clair mon explication. La voici.

Mettre le contact

Prenons un exemple de page de “contact” qui permet d’envoyer un mail à un destinataire. La fonction qui va récupérer le formulaire ressemblerait à cela (avec mon framework Kwiscale):

// je passe le détail du handler, 
// on traite ici la méthode "POST" 
func (handler *ContactHandler) Post() {
    from := handler.GetPost("from")
    message := handler.GetPost("message")
    
    // on envoie le message
    sendMail(from, message)
    
    //on signale que c'est ok
    handler.Redirect("/contact/ok")
}

Pour clarifier, bien que ce soit peu utile à mon avis, on récupère les champs de formulaire “from” et “message” et on appelle la fonction “sendMail()” (que nous allons coder plus bas), puis on signale à l’utilisateur que le message a été envoyé.

La fonction “sendMail()” peut être simulée par une tâche longue, par exemple ici on va faire en sorte qu’elle prenne 3 secondes à s’exécuter:

func sendMail(from, message string) {
    log.Println("Envoi du message...")
    time.Sleep(3 * time.Second)
    log.Println("Message envoyé !")
}

Que va-t-il se passer ?

L’utilisateur va remplir son formulaire, cliquer sur “Envoyer” et attendre sagement 3 secondes que le mail parte pour enfin voir un message lui signifiant que le message est envoyé.

Ce fonctionnement est répandu… et complètement idiot…

Attendre est inutile

Qu’on soit clair. En PHP (pour ne citer que lui) vous déroulez le code en partant du haut vers le bas sans vous poser de question. C’est simple, clair mais pas efficace. Si la connexion au serveur SMTP est contrainte, lente, foireuse, ou je ne sais quel adjectif employer, vous allez faire poireauter l’utilisateur pendant des plombes. Et pendant tout ce temps vous avez votre handler (ou votre controlleur) qui traine des pieds dans la mémoire. C’est encombrant et ça sert strictement à rien.

La plus efficace des solutions consiste à envoyer le mail dans une tâche concurrente, ce que nous appelons une “goroutine” en Go, et que PHP (je suis désolé de lui casser du sucre sur le dos) ne sait (à ce jour) pas faire “facilement”.

En go, c’est simple: on ajoute le fabuleux mot clef “go” devant l’appel:

// je passe le détail du handler, 
// on traite ici la méthode "POST" 
func (handler *ContactHandler) Post() {
    from := handler.GetPost("from")
    message := handler.GetPost("message")
    
    // on envoie le message *en parallèle*
    go sendMail(from, message)
    
    //on signale que c'est ok
    handler.Redirect("/contact/ok")
}

Cette fois, la redirection est faite alors que le mail est en cours de traitement. Que l’envoi foire ou pas !

Question très pertinente d’un de mes auditeurs “oui mais si le mail ne part pas, on ne peut pas prévenir l’utilisateur et gérer le problème dans notre éxécution principale”.

Je suis d’accord avec le principe mais pas sur l’approche, ma réponse est la suivante:

L’utilisateur ne saurait quoi faire de l’erreur. La routine peut gérer l’erreur dans son coin en sauvegardant le message quelque part, en déportant une nouvelle tentative d’envoi plus tard, en loguant l’erreur pour les admins. Mais en aucun cas l’utilisateur n’a besoin de savoir, ici dans notre cas, que le mail est bien parti, il s’en moque je dirai, pire ça le génerai. Je suis conscient que parfois vous aurez besoin de traiter l’erreur et de faire un retour visuel à l’utilisateur. Mais dans bien des cas, et c’est notre cas, ce n’est absolument pas pertinent puisque nous sommes en mesure de gérer ces plantages sans embêter l’utilisateur.

Ça suffit ?

Dans 90% des cas, cette simple modification suffit.

Mais imaginons maintenant que nous ayons une contrainte de volume. Par exemple, nous savons que nous allons avoir des centaines d’utilisateurs qui vont se connecter en même temps et tenter d’envoyer un mail avec notre formulaire de contact.

Ces centaines de mail vont être traités en même temps, dans autant de goroutines que de message à envoyer. Et ça, c’est pas bon pour les nerfs de notre serveur SMTP.

Il va falloir “mettre en queue” les messages et envoyer un nombre limité de mails en simultané.

J’ai déjà tenté de résoudre ce souci en Python, ça a été épique mais pas insurmontable. En PHP je n’ai pas de solution simple qui me vienne à l’esprit. En Go par contre, c’est limpide.

Faites la queue !

Le principe:

  • la fonction “sendMail()” ne va plus envoyer directement le message mais déposer le message dans un canal (”chan”)
  • on va lancer une goroutine qui va lire le canal en continu et envoyer les mails au fur et à mesure
  • voilà…

Donc on commence par notre “consomateur de message”:


// une structure pour mieux empaqueter le message.
type Mail struct {
    From string
    Message string
}

// on crée un canal de mails.
// Pour des questions de perd, on va utiliser l'adresse de la structure
// et non pas la valeur. Donc "*Mail" => pointeur.
var mailsQueue = make(chan *Mail)

// la fonction qui va traiter les messages.
func MailSender(){
    // lit continellement le canal et traite le message
    for mail := range mailsQueue {
        time.Sleep(3 * time.Second)
        log.Println("Message envoyé", mail.From, mail.Message)
    }
}

A noter que j’aurai put écrire:

// la fonction qui va traiter les messages
func MailSender(){
    // lit continellement le canal et traite le message
    for  {
        mail <- mailsQueue
        time.Sleep(3 * time.Second)
        log.Println("Message envoyé", mail.From, mail.Message)
    }
}

On modifie maintenant “sendMail“:

func sendMail(from, message string) {
    log.Println("Envoi du message...")
    mailsQueue <- &Message{from, message}
}

On envoie donc notre pointeur sur “Message” (l’adresse…) dans la queue, et on attend que le canal soit lut pour quitter la fonction.

Rappelez vous que dans notre handler nous avons lancé “sendMail()” en tant que “goroutine”. Donc, même si “mailsQueue” est plein à craquer, on a déjà répondu à l’utilisateur que le mail est traité.

On aura bien des centaines de goroutines qui vont attendre de pouvoir écrire dans le canal mais ça ne coute pas grand chose. Une goroutine ne prend que quelques kilo-octets en mémoire. Ce qui compte c’est que nous n’aurons pas des centaines de connexions simultanées sur notre SMTP.

Bref, il reste un dernier point, lancer autant de “MailSender” qu’on veut pour gérer l’envoi en simultané. On peut le mettre dans notre fonction “main“:

func main(){
    // on lance 10 MailSender en parallèle 
    // et on passe à la suite
    for i := 0 ; i < 10; i++ {
        go MailSender()
    }
    
    // on lance notre service web
    kwiscale.NewAppFromConfig()
    kwiscale.ListenAndServe()
}

C’est fini.

  • Nous avons 10 goroutines qui tentent de lire le canal “mailsQueue”.
  • Quand un client poste un message, on crée une goroutine “sendMail()” qui envoie un “Mail” dans “mailsQueue
  • On répond tout de suite au client que le mail est traité, sans attendre la fin de la goroutine
  • Pendant ce temps (à Veracruz) “MailSender” lit le canal et envoie le message

Si 11 clients appuient en même temps sur “Envoyer” de notre formulaire:

  • Les 10 premiers appels vont ajouter un “Mail” dans le canal “mailsQueue
  • Les 10 goroutines “MailSender” en parallèle vont lire le canal “mailsQueue” et traiter la demande pendant 3 secondes
  • Le 11ième “Mail” se retrouve bloqué dans “sendMail” au moment d’écrire dans le canal (car personne ne le lit pour le moment)
  • La première goroutine “MailSender” qui a traité un des “Mail” va alors lire de nouveau “mailsQueue
  • Et par conséquent la 11ième goroutine “sendMail” se débloque puisque le canal dans lequel elle écrit vient d’être lut

On a traité le souci. On ne traite que 10 mails en parallèle, on ne bloque pas le client et on a soulagé le SMTP.

En bref

Les BlendWebMix 2015 m’ont donné l’occasion de parler à un grand nombre de développeurs en tout genre. Java, PHP, Ruby, AnjularJS, ReactJS, etc. Mais aussi de décisionnaires, de gestionnaires et de professionnels du marketing. Ce que je leur ai montré a, selon eux, donné dujet à discussion, ouvert des possibilités pour pas mal de problématiques à résoudre et c’est le contrat que je voulais remplir. Certes Golang ne répond pas à tout. Il a aussi ses défauts, ses contraintes, ses complexités. Mais dans l’ensemble, je suis très content d’avoir démontré l’intelligence de ce langage et qu’il n’a rien d’archaïque, de compliqué et qu’il n’est pas fait que pour le système.

Petit et grands sites peuvent utiliser Go sans souci: ce blog est codé en Go, et un grand nombre de grand comptes se sont penché sur cette technologie pour différentes raisons (scalling, streaming, …).

En attendant la mise en ligne de la vidéo de ma conférence, vous pouvez toujours visiter le slide interfactif sur GoTalks: “Golang pour le web” en prenant bien en compte que les exemples Martini ne peuvent pas tourner sur le serveur GoTalks.

Les exemples se trouvent sur Github: github.com/metal3d/blendwebmix2015/

Ça peut vous intéresser aussi


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

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


Go-Pipe, streamez à la unix/like en Go

Vous connaissiez les “pipelines” de Gulp, et bien voici celles ...


Golang, comment définir un destructeur

Si vous avez un peu bourlingué sur Go, vous savez ...


Assigner une variable lors de la compilation en Go

Je viens de faire une release de mon outil idok et ...

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.