Les goroutines dans le langage de programmation Go

Ce chapitre vous explique les goroutines en GoLang. Elles permettent de créer des programmes multi-threads simplement. Vous allez d'abord comprendre la différence entre concurrence et parallélisme et ensuite apprendre à créer et gérer vos différentes goroutines sur le langage de programmation Go.

Concurrence et parallélisme

Avant d'aborder des goroutines il est essentiel de comprendre la différence entre concurrence et parallélisme.

Concurrence

La concurrence est la capacité de traiter plusieurs de choses à la fois, par exemple :

Un humain normal doit d'abord finir sa bouchée avant de pouvoir parler, une fois qu'il aura fini sa bouchée il pourra parler ensuite une fois qu'il aura fini de parler il pourra encore une fois reprendre une autre bouchée et reparler juste après.

Dans cet exemple la personne est capable capable de gérer plusieurs de choses (manger et parler) dans un intervalle de temps différent.

Parallélisme

Le parallélisme permet de traiter beaucoup de choses en même temps, je m'explique :

L'humain normal de l'exemple précèdent devient un humain mutant avec un bras et une bouche en plus. Cette fois-ci il est capable de manger et de parler en même temps.

Le fonctionnement d'un point de vue informatique

Maintenant que vous avez compris ce qu'est la concurrence et comment elle diffère du parallélisme en utilisant des exemples de la vie réelle, il est temps maintenant de comprendre qu'est ce ça donne d'un point de vue technique.

Imaginons que vous codez votre propre navigateur Web et qu'un utilisateur utilise votre navigateur pour visiter une page web afin de télécharger une vidéo. Il clique alors sur le bouton de téléchargement de la page et en attendant la fin de son téléchargement il visite d'autres pages du site.

Votre navigateur doit dans ce cas gérer deux taches de manière indépendante, une tâche pour le téléchargement et une autre tache pour le rendu des pages web que l'utilisateur va visiter. Cette tâche indépendante est ce qu'on appelle un thread.

Lorsque votre navigateur est exécuté dans un processeur avec un seul cœur (ou processeur multicœur), le processeur bascule entre les deux tâches (chaque tâche va s'exécuter un petit moment puis une autre, puis une autre, puis on revient à la première, etc) ceci est connu sous le nom de la concurrence. Dans ce cas, le téléchargement et le rendu commencent à différents moments et leurs exécutions se chevauchent.

Disons que le même navigateur est exécuté sur une autre machine avec un processeur multicœur et que la tâche de téléchargement et la tâche de rendu HTML s'exécute simultanément sans chevauchement dans des cœurs différents alors ceci est plus connu sous le nom de parallélisme.

Schéma sur le fonctionnement de la concurrence 
	et le parallélisme sur un Processeur avec un seul cœur et deux cœurs.

Schéma sur le fonctionnement de la concurrence et le parallélisme sur un Processeur avec un seul cœur et deux cœurs.

Vos tâches peuvent parfois avoir besoin de communiquer entre eux. Par exemple dans votre navigateur dès que l'utilisateur aura fini son téléchargement une popup indiquant à l'utilisateur que le téléchargement c'est bien déroulé apparaîtra au-dessus du rendu si et seulement si la page courante n'est pas en plein écran. Dans ce cas on parle de concurrence et l'avantage de cette dernière c'est que les différentes tâches peuvent potentiellement accéder à des données partagées cependant il peut y avoir un risque de décohérence entre les deux.

Les goroutines

Pourquoi les goroutines ?

L'un des aspects les plus intéressants dans Go est son modèle de concurrence, il rend la création de programmes multi-threads simples.

Go est capable d'effectuer plusieurs opérations simultanément. C'est particulièrement important sur les processeurs multicœurs actuels. Les programmes n'utilisant qu'un seul cœur laisse une grande partie de la puissance de traitement perdue, coup de chance car Go nous permet d'utiliser pleinement les cœurs de notre processeur grâce aux goroutines.

Pratiquons un peu !

Pour mieux comprendre les gains de performances avec les goroutines, laissez moi vous présenter un programme standard sans goroutines qui attend 3 secondes pour chaque appel de la fonction run().

package main

import (
    "fmt"
    "time"
)

func run(name string) {
    for i := 0; i < 3; i++ {
        time.Sleep(1 * time.Second) // attendre 1 seconde
        fmt.Println(name, " : ", i)
    }
}

func main() {
    debut := time.Now()
    run("Hatim")
    run("Robert")
    run("Alex")
    fin := time.Now()
    fmt.Println(fin.Sub(debut))

}

Résultat :

Hatim  :  0
Hatim  :  1
Hatim  :  2
Robert  :  0
Robert  :  1
Robert  :  2
Alex  :  0
Alex  :  1
Alex  :  2
9.0095154s

Sans grande surprise le temps d'exécution est d'un peu après 9 secondes. Maintenant essayons d'améliorer les performances de notre programme en utilisant les goroutines.

Pour créer une goroutine il faut placer le mot clé go avant un appel de fonction, exemple :

package main

import (
    "fmt"
    "time"
)

func run(name string) {
    for i := 0; i < 3; i++ {
        time.Sleep(1 * time.Second)
        fmt.Println(name, " : ", i)
    }
}

func main() {
    debut := time.Now()
    go run("Hatim")
    go run("Robert")
    run("Alex")
    fin := time.Now()
    fmt.Println(fin.Sub(debut))

}

Avertissement

J'ai volontairement pas placé le mot clé go avant la ligne run("Alex"), vous allez comprendre pourquoi plus tard.

Résultat :

Robert  :  0
Hatim  :  0
Alex  :  0
Hatim  :  1
Robert  :  1
Alex  :  1
Robert  :  2
Hatim  :  2
Alex  :  2
3.0022266s

Les problèmes des goroutines

Hé hé, vous voyez c'est un sacré gain de temps d'exécution 😵. Il ne faut pas se réjouir trop vite car maintenant je vais rajouter une goroutine à la ligne run("Alex").

package main

import (
    "fmt"
    "time"
)

func run(name string) {
    for i := 0; i < 3; i++ {
        time.Sleep(1 * time.Second)
        fmt.Println(name, " : ", i)
    }
}

func main() {
    debut := time.Now()
    go run("Hatim")
    go run("Robert")
    go run("Alex")
    fin := time.Now()
    fmt.Println(fin.Sub(debut))

}

Résultat :

0s

Oulala 😨, 0 seconde et aucune fonction run() qui s'exécute ?! Mais que s'est-il passé ?

Pour répondre à ces questions répondrez d'abord à celle la : "Selon vous combien de goroutines se sont exécutés ?"

La réponse est 4 !

Il y a certes les 3 goroutines de la fonction run() mais aussi une goroutine principale, je m'explique. Lorsqu'un programme Go démarre, une goroutine commence à s'exécuter immédiatement, c'est la goroutine principale de votre programme, c'est celle qui est exécutée lorsque votre programme commence à s'exécuter. C'est la goroutine à partir duquel d'autres goroutines enfants seront générées (ici les goroutines enfants sont ceux de la fonction run()).

Lorsque l'exécution de la goroutine principale est terminée, les goroutines enfants sont abandonnées aussi

Ce qui s'est passé c'est que le thread principal s'est alors terminé après l'exécution de la fonction fmt.Println(fin.Sub(debut)) et c'est qui a tué les goroutines enfants.

La Solution

Pour éviter ce type problème il est possible d'utiliser la fonction time.Sleep(temps d'exécution de vos goroutines) avant la fin d'exécution de votre goroutine principale, mais le problème avec cette technique c'est qu'il faut connaître à l'avance connaître le temps d'exécution de la totalité de toutes vos goroutines.

Il existe une façon beaucoup plus simple pour synchroniser nos threads en utilisant la structure WaitGroup de bibliothèque sync. Je vous dévoile d'abord le code et ensuite je vous l'explique.

package main

import (
    "fmt"
    "sync"
    "time"
)

var wg sync.WaitGroup // instanciation de notre structure WaitGroup

func run(name string) {
    defer wg.Done()
    for i := 0; i < 3; i++ {
        time.Sleep(1 * time.Second)
        fmt.Println(name, " : ", i)
    }
}

func main() {
    debut := time.Now()

    wg.Add(1)
    go run("Hatim")
    wg.Add(1)
    go run("Robert")
    wg.Add(1)
    go run("Alex")

    wg.Wait()
    fin := time.Now()
    fmt.Println(fin.Sub(debut))
}

Résultat :

Hatim  :  0
Alex  :  0
Robert  :  0
Alex  :  1
Robert  :  1
Hatim  :  1
Alex  :  2
Robert  :  2
Hatim  :  2
3.0108647s

La structure WaitGroup vous permet d'attendre la fin d'exécution d'une collection de goroutines. La méthode Add() permet de définir le nombre de goroutines à attendre (on l'incrémente de 1 à chaque création de goroutine). Puis chacune des goroutines s'exécute et appelle la méthode Done() lorsque la goroutine a terminé de s'exécuter. Dans le même temps, la méthode Wait() est utilisée pour empêcher l'exécution d'autres lignes de code jusqu'à ce que toutes les goroutines soient terminées.

Ici le mot-clé defer est extrêmement important ! La fonction qui est placée après le mot-clé defer s'exécutera à chaque fois qu'on quittera notre fonction même en cas de panique (plantage) de la fonction ! Le mot-clé defer nous garantit alors l'exécution de la méthode Done(). Si vous supprimez le mot-clé et que votre programme panique (plante) alors la méthode Done() ne sera jamais exécutée et votre programme tournera en boucle.

Information

Pour information vous pouvez utilisez la méthode Wait() autant de fois que vous voulez.

Espace commentaire

Écrire un commentaire

Rejoignez la discussion

Vous devez être connecté pour poster un message.

25 commentaires

ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

D'ailleurs, si vous avez des soucis de race condition, je vous conseille vivement de lancer vos tests avec le flag -race : go test -race ./.... Ça sauve des vies.

06/04/2019 à 21:18
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Exactement. Vérifie tes appels Add() et Done(). Si tu appelles Done() sans avoir fait Add(), ça explose.

06/04/2019 à 14:04
marie64
Membre Actif
Avatar de marie64
marie64
Membre Actif

J'ai une erreur panic: sync: negative WaitGroup counter. J'ai dû appeler Done() trop souvent ?

06/04/2019 à 07:24
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Utilise le package context. C'est la méthode standard pour annuler proprement des goroutines après un délai.

06/04/2019 à 02:16
victor-barre
Membre Actif
Avatar de victor-barre
victor-barre
Membre Actif

Ton exemple avec time.Sleep est simple, mais en prod on fait comment pour gérer le timeout d'une goroutine ?

05/04/2019 à 20:56
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Non, heureusement. C'est la force de Go : les goroutines sont des threads légers multiplexés sur un petit nombre de threads OS.

05/04/2019 à 15:12
raymond-legendre
Membre Actif
Avatar de raymond-legendre
raymond-legendre
Membre Actif

Si je lance 10000 goroutines, est-ce que ça va créer 10000 threads OS ?

05/04/2019 à 09:43
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Oui, c'est le propre de la concurrence. L'ordonnanceur de Go décide quel thread s'exécute, tu ne maîtrises pas l'ordre d'affichage.

05/04/2019 à 03:41
ines-gillet
Membre Actif
Avatar de ines-gillet
ines-gillet
Membre Actif

Je n'arrive pas à comprendre pourquoi mon programme affiche des résultats dans le désordre après l'ajout de go. C'est normal ?

04/04/2019 à 22:03
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Oui, mais c'est moins lisible et plus sujet aux erreurs. Utilise Done(), c'est fait pour ça.

04/04/2019 à 15:40
jacqueline68
Membre Actif
Avatar de jacqueline68
jacqueline68
Membre Actif

Est-ce qu'on peut utiliser wg.Add(-1) au lieu de wg.Done() ?

04/04/2019 à 09:42
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Classique. La goroutine principale est volatile. Toujours s'assurer que les enfants ont fini avec wg.Wait() avant de sortir du main.

04/04/2019 à 04:50
veronique-ledoux
Membre Actif
Avatar de veronique-ledoux
veronique-ledoux
Membre Actif

Merci pour l'explication sur la goroutine principale qui tue les enfants. Ça m'a fait perdre 2 heures la semaine dernière.

04/04/2019 à 00:05
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Tu as besoin d'un worker pool. Tu crées un canal tamponné qui sert de sémaphore pour limiter le nombre de goroutines actives.

semaphore := make(chan struct{}, 10) // Limite à 10 goroutines
03/04/2019 à 19:30
louise-morin
Membre Actif
Avatar de louise-morin
louise-morin
Membre Actif

Le go run, c'est bien, mais comment je limite le nombre de goroutines lancées en même temps ? J'ai peur de saturer ma RAM.

03/04/2019 à 12:47
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Carrément. La variable globale est une mauvaise pratique. Passe-le en argument par pointeur, c'est beaucoup plus propre et testable.

03/04/2019 à 05:44
marthe-robert
Membre Actif
Avatar de marthe-robert
marthe-robert
Membre Actif

J'ai essayé de déclarer mon wg à l'intérieur du main au lieu d'une variable globale, ça passe très bien. C'est mieux pour le scope, non ?

02/04/2019 à 22:52
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Le WaitGroup est fait pour attendre la fin d'un groupe de tâches. Les channels, c'est pour communiquer des données entre goroutines. Ne mélange pas les deux.

02/04/2019 à 14:52
margaret68
Membre Actif
Avatar de margaret68
margaret68
Membre Actif

Question bête : pourquoi utiliser un WaitGroup plutôt qu'un canal ?

02/04/2019 à 09:29
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Normal. Le parallélisme dépend du nombre de cœurs physiques. Si tu veux forcer le runtime à utiliser plusieurs threads, regarde du côté de runtime.GOMAXPROCS.

02/04/2019 à 02:37
camille-becker
Membre Actif
Avatar de camille-becker
camille-becker
Membre Actif

Sympa le comparatif concurrence/parallélisme. Par contre sur mon vieux CPU dual-core, je ne vois pas de différence énorme de perfs. C'est normal ?

01/04/2019 à 21:42
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Justement, le defer est là pour ça. Il s'exécute même si la fonction plante. C'est indispensable pour éviter que ton wg.Wait() ne reste bloqué indéfiniment.

01/04/2019 à 17:35
torres-hugues
Membre Actif
Avatar de torres-hugues
torres-hugues
Membre Actif

J'ai testé l'exemple avec le defer wg.Done(). Est-ce qu'il y a un risque si ma fonction run() panique à l'intérieur ?

01/04/2019 à 10:46
ajdaini-hatim
Auteur Rédacteur Secouriste Actif
Avatar de ajdaini-hatim
ajdaini-hatim
Auteur Rédacteur Secouriste Actif

Oui, c'est logique. En Go, tu dois passer le pointeur de ton WaitGroup : *sync.WaitGroup. Si tu passes la valeur, il copie la structure et ça ne synchronise rien du tout.

01/04/2019 à 05:29
mpetit
Membre Actif
Avatar de mpetit
mpetit
Membre Actif

Super article, clair sur les goroutines. Par contre j'ai une erreur de compilation quand j'essaie de passer mon sync.WaitGroup en argument à ma fonction run(). C'est normal ?

01/04/2019 à 00:27

Rejoindre la communauté

Recevoir les derniers articles gratuitement en créant un compte !

S'inscrire