Menu

Goroutines en Go : fonctionnement et exemples

Comment exécuter des fonctions en concurrence avec le mot-clé go, attendre qu'elles se terminent, récupérer leurs résultats, et éviter les data races, les fuites et les plantages que les goroutines rendent faciles.

Cette page contient des éditeurs exécutables - modifiez, exécutez et voyez la sortie instantanément.

Lancer une goroutine

Placez go devant un appel de fonction et cet appel s'exécute en concurrence. L'instruction retourne immédiatement ; l'appelant n'attend pas.

Quatre goroutines exécutent shout en même temps. L'ordre dans lequel elles se terminent n'est pas défini, mais la sortie est toujours dans l'ordre d'origine, parce que chaque goroutine écrit à son propre indice et que main ne lit la slice qu'après le retour de wg.Wait().

Cet exemple contient déjà les trois choses dont presque tout programme à goroutines a besoin : un moyen de lancer le travail (go), un moyen de l'attendre (sync.WaitGroup), et un moyen de récupérer les résultats sans que deux goroutines touchent la même mémoire (un élément de slice chacune).

main n'attend pas

Quand main retourne, le programme se termine. Les goroutines encore en cours sont arrêtées là où elles en sont. Rien ne les attend.

Ce code n'affiche en général que from main. Parfois la goroutine est ordonnancée à temps et vous voyez les deux lignes. Ce « en général » est le problème : du code qui marche sur votre machine et échoue sur un serveur chargé.

Un time.Sleep à la fin de main fait afficher les deux lignes à la démo, et c'est la mauvaise correction. Il devine la durée du travail. Attendez le travail lui-même, avec un WaitGroup (la page WaitGroup le traite en détail) ou avec un channel.

Récupérer les résultats

Une instruction go jette les valeurs de retour de la fonction. x := go f() ne compile pas. Il existe deux façons standard de renvoyer des données.

Une case par goroutine, comme dans le premier exemple. Dimensionnez une slice à l'avance, donnez à chaque goroutine son indice, lisez après Wait. L'ordre est préservé et aucun verrou n'est nécessaire, car deux goroutines n'écrivent jamais le même élément.

Un channel. Chaque goroutine envoie son résultat ; le récepteur les collecte. Les résultats arrivent dans l'ordre de fin, pas dans l'ordre de lancement.

Recevoir exactement len(nums) valeurs fait aussi office d'attente : main ne peut pas sortir de la boucle tant que chaque goroutine n'a pas envoyé. L'ordre d'arrivée change d'une exécution à l'autre, donc le programme trie avant d'afficher quoi que ce soit qui dépend de l'ordre. La page sur les channels couvre les channels avec buffer, la fermeture, et range sur un channel.

Variables de boucle et closures (changement de Go 1.22)

Depuis Go 1.22, chaque itération d'une boucle for reçoit une nouvelle copie des variables de boucle. Une closure lancée dans une goroutine capture la valeur de cette itération, donc ce code est correct :

for i, w := range words {
	go func() {
		results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
	}()
}

Avant Go 1.22, toutes les itérations partageaient un seul i et un seul w, et chaque goroutine avait tendance à voir la dernière valeur. L'ancien code contourne cela en passant les valeurs en arguments, go func(i int, w string) { ... }(i, w), ou par masquage, i := i. Les deux sont sans danger avec Go 1.22 et plus, et vous les verrez encore dans du code existant. Le nouveau comportement s'applique quand le go.mod du module indique go 1.22 ou plus.

Les goroutines coûtent peu

Une goroutine démarre avec une petite pile (quelques kilo-octets) que le runtime agrandit et réduit au besoin. L'ordonnanceur de Go exécute les goroutines sur un pool de threads système, au plus GOMAXPROCS d'entre eux exécutant du code Go en même temps, et par défaut GOMAXPROCS vaut le nombre de CPU. Bloquer sur un channel, un mutex, un sleep ou des E/S réseau met la goroutine en attente et libère le thread pour une autre.

Lancer une goroutine par tâche convient donc même en grand nombre :

Cent mille goroutines se terminent en une fraction de seconde. La somme vaut toujours 4999950000, parce que atomic.Int64 rend chaque addition indivisible. Peu coûteux ne veut pas dire gratuit, cependant : chaque goroutine encore bloquée garde en vie sa pile et tout ce qu'elle référence.

Thread systèmeGoroutine
Créé parle noyaule runtime Go
Pile initialefixe, souvent 1 Mo ou plusquelques Ko, grandit à la demande
Changement de contextechangement de contexte noyauordonnanceur Go, en espace utilisateur
Identitéa un identifiant de threadaucun identifiant lisible, volontairement
Nombre typiquedes centainesdes milliers à des millions

Data races

Deux goroutines qui accèdent à la même variable au même moment, dont au moins une en écriture, c'est une data race. Le résultat est imprévisible, pas seulement « un peu faux » : des mises à jour se perdent, et une race sur une valeur string, slice, map ou interface peut faire planter le programme ou corrompre la mémoire.

Sur une machine multicœur, ce code affiche un nombre différent inférieur à 10000 la plupart du temps, parce que deux goroutines lisent la même ancienne valeur et écrivent toutes deux cette valeur plus un. Sur un seul cœur, il peut afficher 10000, ce qui est pire : le bug passe votre test et apparaît en production.

Go est livré avec un détecteur de races. Lancez votre programme ou vos tests avec -race :

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
  main.main.func1()
      /tmp/race/main.go:16 +0x94

Previous write at 0x00c000090038 by goroutine 6:
  main.main.func1()
      /tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66

Il indique la ligne exacte (counter++) et les deux goroutines. Il ne signale que les races qui se produisent réellement pendant l'exécution, donc lancez-le sur des tests qui exercent les chemins concurrents. Il ralentit le programme de plusieurs fois, il est donc fait pour les tests et la préproduction, pas pour la production.

Les corrections, de la plus simple à la plus générale :

  • Ne partagez pas. Donnez à chaque goroutine ses propres données et combinez à la fin (le motif une case par goroutine).
  • Utilisez sync/atomic pour un simple compteur ou un flag : var n atomic.Int64; n.Add(1).
  • Utilisez un sync.Mutex autour de tout ce qui est plus gros, comme une map ou une struct à plusieurs champs. La page sur les mutex couvre aussi RWMutex et sync.Once.
  • Envoyez les données sur un channel pour qu'une seule goroutine en soit propriétaire à la fois.

Un panic dans une goroutine tue le programme

Si une goroutine provoque un panic et que rien ne le récupère dans cette même goroutine, tout le programme plante, y compris main et toutes les autres goroutines. Un recover dans main n'aide pas, parce que recover n'attrape que les panics de sa propre goroutine.

Récupérer ainsi a du sens à la périphérie d'un serveur de longue durée, où une mauvaise requête ne doit pas faire tomber le reste. Dans du code ordinaire, un panic signale généralement un bug, et planter bruyamment est le bon résultat.

Fuites de goroutines

Une goroutine qui bloque indéfiniment ne se termine jamais et ne libère jamais sa mémoire. La cause classique est un envoi que personne ne recevra jamais :

func firstResult(urls []string) string {
	ch := make(chan string) // unbuffered
	for _, u := range urls {
		go func() { ch <- fetch(u) }()
	}
	return <-ch // takes the first result; the other senders block forever
}

Chaque appel fait fuir len(urls) - 1 goroutines. Dans un serveur qui traite cette requête des milliers de fois, la mémoire grimpe jusqu'à la mort du processus. Deux corrections : donnez au channel une taille suffisante pour que chaque émetteur puisse terminer (make(chan string, len(urls))), ou donnez aux goroutines un moyen d'abandonner, généralement un context.Context plus un select sur ctx.Done(). Vous pouvez surveiller les fuites avec runtime.NumGoroutine() dans les tests.

Limiter le nombre de goroutines simultanées

« Une goroutine par élément » convient pour 10 000 calculs légers. Cela ne convient pas pour 10 000 requêtes HTTP vers le même serveur ou 10 000 fichiers ouverts. Plafonnez la concurrence avec un channel avec buffer utilisé comme sémaphore :

Le channel avec buffer contient au plus 3 jetons, donc au plus 3 goroutines ont passé la ligne sem <- à tout instant. Le pic ne peut jamais dépasser 3, et avec douze tâches qui font chacune un sleep, il atteint 3 en pratique. Un pool fixe de goroutines workers qui lisent un channel de tâches est l'autre forme courante ; la page WaitGroup en construit un.

Hors de la bibliothèque standard, golang.org/x/sync/errgroup combine un WaitGroup, la première erreur, l'annulation par context et une limite de concurrence (g.SetLimit(n)) dans un seul type. C'est le choix habituel en production quand on a besoin des quatre.

Erreurs courantes

  • Oublier d'attendre. main retourne et le travail n'a jamais lieu, sans bruit. Chaque instruction go a besoin d'un moyen correspondant de savoir qu'elle a terminé.
  • Appeler wg.Add dans la goroutine. Wait peut s'exécuter avant Add, voir un compteur à zéro et retourner trop tôt. Appelez Add avant l'instruction go.
  • Partager une variable sans synchronisation. Les maps sont le cas courant : des écritures concurrentes dans une map sont généralement détectées par le runtime et font planter le programme avec fatal error: concurrent map writes, que recover ne peut pas attraper.
  • Supposer un ordre. Les goroutines s'exécutent dans l'ordre choisi par l'ordonnanceur. Si la sortie doit être ordonnée, collectez et triez, ou écrivez dans des cases indexées.
  • Utiliser time.Sleep pour synchroniser. Cela ralentit les tests et les laisse instables. Attendez l'événement, pas une estimation.
  • Lancer une goroutine sans moyen de l'arrêter. Tout ce qui boucle ou attend des E/S devrait prendre un context.Context pour que l'appelant puisse l'annuler.

Questions fréquentes

Qu'est-ce qu'une goroutine en Go ?

Une goroutine est un appel de fonction qui s'exécute en concurrence avec le reste du programme. Vous en lancez une en plaçant go devant un appel : go work(). Les goroutines sont gérées par le runtime Go, pas par le système d'exploitation, et le runtime en répartit un grand nombre sur un petit nombre de threads système, donc en lancer des milliers est normal.

Comment attendre la fin des goroutines en Go ?

Utilisez un sync.WaitGroup : appelez wg.Add(1) avant chaque instruction go, defer wg.Done() en haut de la goroutine, et wg.Wait() là où vous avez besoin qu'elles soient toutes terminées. Si les goroutines produisent des valeurs, recevoir une valeur par goroutine depuis un channel fait aussi office d'attente.

Quelle est la différence entre une goroutine et un thread ?

Un thread système a une pile fixe (souvent 1 Mo ou plus) et il est ordonnancé par le noyau. Une goroutine démarre avec une pile de quelques kilo-octets qui grandit au besoin, et l'ordonnanceur de Go passe d'une goroutine à l'autre en espace utilisateur. Le runtime exécute les goroutines sur au plus GOMAXPROCS threads à la fois (par défaut, le nombre de CPU).

Comment récupérer une valeur de retour d'une goroutine ?

Une instruction go jette les valeurs de retour de la fonction. Envoyez le résultat sur un channel (results <- compute(x)) ou écrivez-le dans votre propre case d'une slice pré-dimensionnée (out[i] = compute(x)) et lisez-le après wg.Wait().

Pourquoi mon programme Go se termine-t-il avant que la goroutine affiche quoi que ce soit ?

Quand main retourne, le programme se termine et toutes les autres goroutines sont arrêtées sans exécuter le reste de leur code. Rien n'attend automatiquement les goroutines. Bloquez main jusqu'à la fin du travail avec un WaitGroup ou une réception sur un channel. Ajouter time.Sleep ne fait que masquer le problème.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER