GoSuda

Les Fondamentaux des Goroutines

By hamori
views ...

Goroutine

Il existe des articles fréquemment cités sur la concurrence (Concurrency), qui est souvent mise en avant lorsque l'on demande aux Gophers de parler des avantages de Go. Le fondement de ce contenu réside dans la goroutine, légère et facile à gérer. J'ai rédigé un bref aperçu à ce sujet.

Concurrence (Concurrency) vs. Parallélisme (Parallelism)

Avant d'aborder la goroutine, je souhaite clarifier deux concepts qui sont souvent confondus.

  • Concurrence : La concurrence concerne le traitement de nombreuses tâches à la fois. Il ne s'agit pas nécessairement d'une exécution simultanée au sens strict, mais plutôt d'un concept structurel et logique où plusieurs tâches sont divisées en petites unités et exécutées alternativement, donnant à l'utilisateur l'impression que plusieurs tâches sont traitées simultanément. La concurrence est possible même avec un seul cœur.
  • Parallélisme : Le parallélisme est le fait de « traiter plusieurs tâches simultanément sur plusieurs cœurs ». Il s'agit littéralement de progresser dans le travail de manière parallèle, en exécutant différentes tâches en même temps.

La goroutine permet d'implémenter facilement la concurrence via le planificateur d'exécution de Go, et elle exploite naturellement le parallélisme grâce au paramétrage de GOMAXPROCS.

Le multithreading Java, couramment utilisé et à forte utilisation, est un concept emblématique du parallélisme.

Pourquoi la Goroutine est-elle bénéfique ?

Légèreté (lightweight)

Le coût de création est très faible par rapport à d'autres langages. La question se pose alors de savoir pourquoi Go en utilise si peu ; c'est parce que l'emplacement de création est géré en interne par le runtime Go. La raison en est qu'il s'agit d'un fil logique léger, plus petit que l'unité de thread du système d'exploitation (OS thread), nécessitant une pile initiale d'environ 2 Ko, et étant dynamiquement variable par l'ajout de piles selon l'implémentation de l'utilisateur.

Gérée par unités de pile, la création et la suppression sont très rapides et peu coûteuses, permettant un traitement sans surcharge même avec des millions de goroutines en cours d'exécution. Grâce à cela, la Goroutine peut minimiser l'intervention du noyau du système d'exploitation (OS kernel) grâce à son planificateur d'exécution (runtime scheduler).

Performance élevée (performance)

Premièrement, comme expliqué ci-dessus, la Goroutine implique moins d'intervention du noyau du système d'exploitation, ce qui rend le coût du changement de contexte (context switching) au niveau utilisateur (User-Level) inférieur à celui de l'unité de thread OS, permettant une commutation rapide des tâches.

De plus, elle est gérée par affectation à des OS threads en utilisant le modèle M:N. Il est possible de traiter les tâches avec un petit nombre de threads, sans nécessiter un grand nombre de threads, en créant un pool de threads OS. Par exemple, si un état de blocage survient, tel qu'un appel système, le runtime Go exécute une autre goroutine sur l'OS thread, permettant à l'OS thread de ne pas rester inactif et d'utiliser efficacement le CPU pour un traitement rapide.

C'est pourquoi Golang peut atteindre des performances supérieures à celles d'autres langages, en particulier pour les opérations d'I/O.

Concision (concise)

Le fait de pouvoir gérer facilement une fonction avec le mot-clé go lorsqu'une concurrence est nécessaire est également un avantage majeur.

Il est nécessaire d'utiliser des mécanismes de verrouillage complexes tels que Mutex ou Semaphore, et l'utilisation de verrous impose inévitablement de considérer l'état de DeadLock (interblocage), ce qui complexifie la phase de conception avant le développement.

La Goroutine encourage le transfert de données via des Channel selon la philosophie de "ne pas communiquer en partageant la mémoire, mais partager la mémoire en communiquant", et SELECT prend en charge la fonctionnalité de traiter les canaux dès que les données sont prêtes, en combinaison avec les Channel. De plus, l'utilisation de sync.WaitGroup permet d'attendre facilement que toutes les goroutines se terminent, simplifiant ainsi la gestion du flux de travail. Grâce à ces outils, il est possible de prévenir les problèmes de compétition de données entre threads et d'assurer une gestion de la concurrence plus sûre.

En outre, l'utilisation du contexte (context) permet de contrôler le cycle de vie, l'annulation, le délai d'attente (timeout), la date limite (deadline) et la portée de la requête au niveau utilisateur (User-Level), assurant ainsi un certain niveau de stabilité.

Travail parallèle de la Goroutine (GOMAXPROCS)

J'ai mentionné les avantages de la concurrence de la goroutine, mais vous pourriez vous demander si elle ne prend pas en charge le parallélisme. Le nombre de cœurs des CPU récents dépasse les deux chiffres, contrairement au passé, et même les PC domestiques disposent d'un nombre non négligeable de cœurs.

Cependant, la Goroutine effectue également des tâches parallèles, et c'est le rôle de GOMAXPROCS.

Si GOMAXPROCS n'est pas configuré, il est défini différemment selon la version.

  1. Avant la version 1.5 : La valeur par défaut est 1 ; une configuration est obligatoire si plus de 1 est nécessaire, par exemple avec runtime.GOMAXPOCS(runtime.NumCPU()).

  2. De la version 1.5 à 1.24 : La valeur a été modifiée pour correspondre au nombre total de cœurs logiques disponibles. À partir de ce moment, les développeurs n'ont plus besoin de le configurer, sauf en cas de contraintes majeures.

  3. Version 1.25 : Fidèle à sa réputation de langage utilisé dans les environnements de conteneurs, Go vérifie le CPU constraint défini pour le conteneur en examinant cGroup sur Linux.

    Ainsi, si le nombre de cœurs logiques est de 10 et que la contrainte CPU est de 5, GOMAXPROCS est défini sur la valeur inférieure, soit 5.

La modification de la version 1.25 est très significative. Elle a augmenté l'utilité du langage dans les environnements de conteneurs. Cela permet de réduire la création inutile de threads et les changements de contexte, prévenant ainsi l'étranglement du CPU (CPU throttling).

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: début\n", name) // Goroutine %d: début
14	time.Sleep(10 * time.Millisecond) // Délai pour simuler une tâche
15	fmt.Printf("Goroutine %d: fin\n", name) // Goroutine %d: fin
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // Utiliser seulement 2 cœurs CPU
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("Attente de la fin de toutes les goroutines...") // Attente de la fin de toutes les goroutines...
29	wg.Wait()
30	fmt.Println("Toutes les tâches sont terminées.") // Toutes les tâches sont terminées.
31
32}
33

Planificateur de la Goroutine (Modèle M:N)

En revenant à la section précédente sur l'affectation et la gestion aux OS threads via le modèle M:N, nous abordons plus spécifiquement le modèle GMP de la goroutine.

  • G (Goroutine) : La plus petite unité de travail exécutée dans Go.
  • M (Machine) : L'OS Thread (l'emplacement réel de l'exécution).
  • P (Processor) : Le processeur logique géré par le runtime Go.

P possède en outre une file d'exécution locale (Local Run Queue) et agit comme un planificateur qui attribue le G alloué au M. En termes simples, la goroutine suit le processus :

Le processus de fonctionnement du GMP est le suivant :

  1. Lorsqu'une G (Goroutine) est créée, elle est allouée à la file d'exécution locale (Local Run Queue) du P (Processor).
  2. Le P (Processor) alloue le G (Goroutine) de sa file d'exécution locale au M (Machine).
  3. Le M (Machine) retourne l'état du G (Goroutine) : bloqué (block), complété (complete), ou préempté (preempted).
  4. Work-Stealing (Vol de travail) : Si la file d'exécution locale d'un P devient vide, un autre P vérifie la file globale. S'il n'y a pas non plus de G (Goroutine) là-bas, il "vole" le travail d'un autre P local (Processor), garantissant que tous les M fonctionnent sans interruption.
  5. Traitement des appels système (Blocking) : Si un G (Goroutine) est bloqué pendant son exécution, le M (Machine) passe en état d'attente. À ce moment, le P (Processor) se désolidarise du M (Machine) bloqué et se couple à un autre M (Machine) pour exécuter la G (Goroutine) suivante. Cela évite le gaspillage de CPU même pendant le temps d'attente des opérations d'I/O.
  6. Si une G (Goroutine) occupe le processeur (preempted) trop longtemps, elle cède l'opportunité d'exécution à une autre G (Goroutine).

Le GC (Garbage Collector) de Golang s'exécute également sur une Goroutine, ce qui lui permet de nettoyer la mémoire de manière parallèle tout en minimisant l'interruption de l'exécution de l'application (STW), utilisant ainsi les ressources système de manière efficace.

Enfin, Golang possède de nombreux avantages majeurs, dont celui-ci, et j'espère que de nombreux développeurs apprécieront Go.

Merci.