Principe de fonctionnement des goroutines en Go : pile variable de 2 Ko et ordonnanceur GMP de type M:N

Goroutine
Lorsqu'on demande aux Gophers de parler des avantages de Golang, un sujet revient souvent : la concurrence (Concurrency). Son fondement repose sur la goroutine (goroutine), qui permet un traitement léger et simple. Voici un bref aperçu à ce sujet.
Concurrence (Concurrency) vs Parallélisme (Parallelism)
Avant de comprendre les goroutines, nous souhaitons d'abord clarifier deux notions souvent confondues.
- Concurrence : La concurrence concerne le traitement de nombreuses tâches à la fois. Elle ne signifie pas nécessairement une exécution réellement simultanée, mais constitue une notion structurelle et logique qui divise plusieurs tâches en unités plus petites et les exécute en alternance, donnant ainsi l'impression aux utilisateurs que plusieurs tâches sont traitées simultanément. La concurrence est possible même sur un seul cœur.
- Parallélisme : Le parallélisme correspond à « le traitement simultané de plusieurs tâches sur plusieurs cœurs ». Au sens propre, il s'agit de faire progresser le travail en parallèle, en exécutant d'autres tâches simultanément.
Les goroutines permettent de mettre en œuvre facilement la concurrence grâce au planificateur d'exécution (runtime scheduler) de Go, et exploitent naturellement le parallélisme grâce au paramètre GOMAXPROCS.
Le multithreading (Multi thread) de Java, très utilisé, est un concept représentatif du parallélisme.
Pourquoi les goroutines sont-elles avantageuses ?
Légèreté (lightweight)
Leur coût de création est nettement inférieur à celui des autres langages. On peut alors se demander pourquoi Golang en utilise si peu. La raison est que leur création est gérée à l'intérieur du runtime Go, car il s'agit de threads logiques légers. Elles sont plus petites qu'une unité de thread OS, leur pile initiale ne nécessite qu'environ 2 Ko, et la pile peut être étendue dynamiquement selon l'implémentation.
Gérées par unités de pile, leur création et leur destruction sont très rapides et peu coûteuses, ce qui permet d'exécuter sans difficulté des millions de goroutines. Grâce au planificateur du runtime, les goroutines permettent également de minimiser l'intervention du noyau du système d'exploitation.
Performance (performance)
Tout d'abord, comme expliqué précédemment, les goroutines impliquent peu d'interventions du noyau OS ; leur changement de contexte au niveau utilisateur (User-Level) coûte moins cher que celui d'un thread OS, ce qui permet de basculer rapidement entre les tâches.
De plus, grâce au modèle M:N, elles sont assignées à des threads OS et gérées ainsi. En créant un pool de threads OS, il n'est pas nécessaire de disposer d'un grand nombre de threads : un petit nombre suffit pour le traitement. Par exemple, lorsqu'une goroutine entre dans un état d'attente, comme lors d'un appel système, le runtime Go exécute une autre goroutine sur le thread OS ; ainsi, le thread OS ne reste pas inactif et utilise le CPU de manière efficace, permettant un traitement rapide.
C'est pourquoi Golang peut atteindre des performances supérieures à celles d'autres langages, notamment pour les opérations d'E/S (I/O).
Concision (concise)
Le fait de pouvoir traiter une fonction simplement avec le seul mot-clé go lorsque la concurrence est nécessaire constitue également un avantage majeur.
Il faut utiliser des verrous complexes tels que Mutex et Semaphore, et l'utilisation de verrous impose de prendre en compte les situations d'interblocage (DeadLock), ce qui rend nécessaire une étape de conception complexe dès la phase de conception, avant même le développement.
Les goroutines suivent la philosophie « Ne communiquez pas en partageant la mémoire ; partagez la mémoire en communiquant », et recommandent donc le transfert de données via les canaux (Channel). SELECT combiné aux canaux (Channel) permet de traiter en priorité le canal dont les données sont prêtes. De plus, sync.WaitGroup permet d'attendre simplement la fin de plusieurs goroutines, ce qui facilite la gestion du flux de travail. Grâce à ces outils, on peut prévenir les problèmes de concurrence sur les données entre threads et traiter la concurrence de manière plus sûre.
En outre, le contexte (context) permet de contrôler, au niveau utilisateur (User-Level), le cycle de vie, l'annulation, les délais d'expiration, les échéances et la portée des requêtes, ce qui garantit un certain niveau de stabilité.
Traitement parallèle des goroutines (GOMAXPROCS)
Vous vous demandez peut-être que, si les goroutines offrent de bons atouts en matière de concurrence, elles ne prennent pas en charge le parallélisme. Le nombre de cœurs des processeurs récents dépasse désormais la dizaine, et même les PC domestiques intègrent un nombre de cœurs considérable.
Cependant, les goroutines réalisent aussi le traitement parallèle ; c'est le rôle de GOMAXPROCS.
Si GOMAXPROCS n'est pas défini, sa valeur par défaut varie selon les versions.
Avant la 1.5 : valeur par défaut de 1 ; pour utiliser davantage, il est indispensable de le définir avec une méthode telle que
runtime.GOMAXPOCS(runtime.NumCPU())De la 1.5 à la 1.24 : il est passé au nombre de cœurs logiques disponibles. À partir de ce moment, il n'est pas nécessaire de le définir, sauf si des contraintes particulières l'exigent.
1.25 : comme il sied à un langage réputé dans les environnements de conteneurs, il vérifie la configuration
CPU制限(limite de CPU) définie pour le conteneur via cGroup sous Linux.Ainsi, si le nombre de cœurs logiques est de 10 et que la limite de CPU est de 5,
GOMAXPROCSest défini à 5, la valeur la plus basse.
La modification de la 1.25 est très importante, car elle améliore l'utilisation du langage dans les environnements de conteneurs. Elle permet ainsi de réduire la création inutile de threads et les changements de contexte, et d'éviter le throttling du CPU.
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: 開始\n", name)
14 time.Sleep(10 * time.Millisecond) // シミュレーション de tâche : délai pour simuler le travail
15 fmt.Printf("Goroutine %d: 開始\n", name)
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // Utiliser seulement 2 cœurs de 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("En attente de la fin de toutes les goroutines...")
29 wg.Wait()
30 fmt.Println("Toutes les tâches sont terminées.")
31
32}
33
Planificateur des goroutines (modèle M:N)
Pour préciser le point précédent concernant l'affectation aux threads OS via le modèle M:N, il existe le modèle GMP des goroutines.
- G (Goroutine) : la plus petite unité de travail exécutée en Go
- M (Machine) : thread OS (lieu réel d'exécution du travail)
- P (Processor) : processus logique géré par le runtime Go
Voilà. P possède en outre une file d'exécution locale (Local Run Queue) et joue le rôle de planificateur en affectant les G assignés aux M. En bref, une goroutine fonctionne ainsi.
Le fonctionnement du GMP est le suivant :
- Lorsqu'une G (Goroutine) est créée, elle est assignée à la file d'exécution locale d'un P (Processor).
- Le P (Processor) affecte la G (Goroutine) présente dans sa file d'exécution locale à un M (Machine).
- Le M (Machine) renvoie l'état de la G (Goroutine) : block, complete ou preempted.
- Vol de travail (Work-Stealing) : si la file d'exécution locale d'un P devient vide, les autres P consultent la file globale. Si elle ne contient pas non plus de G (Goroutine), ils volent le travail d'une autre file locale de P (Processor), afin que tous les M fonctionnent sans interruption.
- Traitement des appels système (Blocking) : lorsqu'une G (Goroutine) se bloque pendant son exécution, le M (Machine) passe en attente. Le P (Processor) se détache alors de ce M bloqué, se rattache à un autre M (Machine) et exécute la G (Goroutine) suivante. Ainsi, aucun CPU n'est gaspillé pendant les temps d'attente des opérations d'E/S.
- Lorsqu'une G (Goroutine) monopolise l'exécution pendant trop longtemps (preempted), elle cède la place à une autre G (Goroutine).
Le GC (Garbage Collector) de Golang s'exécute également au-dessus des goroutines ; il peut ainsi nettoyer la mémoire en parallèle, en interrompant le moins possible l'application (STW), ce qui permet d'utiliser efficacement les ressources système.
Enfin, Golang est l'un des points forts majeurs du langage, et il possède encore de nombreux autres atouts. Nous souhaitons que de nombreux développeurs profitent de Go.
Merci.