1 Introduction aux Goroutines

1.1 Concepts de base de la concurrence et de la parallélisme

La concurrence et la parallélisme sont deux concepts courants en programmation multi-thread. Ils sont utilisés pour décrire des événements ou des exécutions de programme qui peuvent se produire simultanément.

  • Concurrence fait référence à l'exécution de plusieurs tâches dans la même période, mais seule une tâche s'exécute à un moment donné. Les tâches passent rapidement l'une à l'autre, donnant à l'utilisateur l'illusion d'une exécution simultanée. La concurrence convient aux processeurs monocœur.
  • Parallélisme fait référence à l'exécution véritablement simultanée de plusieurs tâches en même temps, ce qui nécessite le support de processeurs multi-cœurs.

Le langage Go est conçu en gardant la concurrence à l'esprit comme l'un de ses objectifs principaux. Il réalise des modèles de programmation concurrente efficaces à travers les Goroutines et les Channels. Le runtime de Go gère les Goroutines et peut planifier ces Goroutines sur plusieurs threads système pour atteindre un traitement parallèle.

1.2 Goroutines dans le langage Go

Les Goroutines sont le concept principal pour réaliser la programmation concurrente dans le langage Go. Ce sont des threads légers gérés par le runtime de Go. Du point de vue de l'utilisateur, ils ressemblent à des threads, mais consomment moins de ressources et démarrent plus rapidement.

Les caractéristiques des Goroutines incluent :

  • Légèreté : Les Goroutines occupent moins de mémoire de pile par rapport aux threads traditionnels, et leur taille de pile peut s'étendre ou se réduire dynamiquement selon les besoins.
  • Faible surcharge : La surcharge pour la création et la destruction des Goroutines est bien inférieure à celle des threads traditionnels.
  • Mécanisme de communication simple : Les Channels fournissent un mécanisme de communication simple et efficace entre les Goroutines.
  • Conception non bloquante : Les Goroutines ne bloquent pas les autres Goroutines lors de certaines opérations. Par exemple, pendant qu'une Goroutine attend des opérations d'E/S, d'autres Goroutines peuvent continuer à s'exécuter.

2 Création et gestion des Goroutines

2.1 Comment créer une Goroutine

En langage Go, vous pouvez facilement créer une Goroutine en utilisant le mot-clé go. Lorsque vous préfixez un appel de fonction avec le mot-clé go, la fonction s'exécute de manière asynchrone dans une nouvelle Goroutine.

Jetons un œil à un exemple simple :

package main

import (
	"fmt"
	"time"
)

// Définition d'une fonction pour imprimer Bonjour
func direBonjour() {
	fmt.Println("Bonjour")
}

func main() {
	// Démarrer une nouvelle Goroutine en utilisant le mot-clé go
	go direBonjour()

	// La Goroutine principale attend pendant un moment pour permettre à direBonjour de s'exécuter
	time.Sleep(1 * time.Second)
	fmt.Println("Fonction principale")
}

Dans le code ci-dessus, la fonction direBonjour() sera exécutée de manière asynchrone dans une nouvelle Goroutine. Cela signifie que la fonction main() n'attendra pas que direBonjour() se termine avant de continuer. Par conséquent, nous utilisons time.Sleep pour mettre en pause la Goroutine principale, permettant à l'instruction d'impression dans direBonjour de s'exécuter. Ceci est juste à des fins de démonstration. Dans le développement réel, nous utilisons généralement des channels ou d'autres méthodes de synchronisation pour coordonner l'exécution de différentes Goroutines.

Remarque : Dans des applications pratiques, time.Sleep() ne devrait pas être utilisé pour attendre la fin d'une Goroutine, car ce n'est pas un mécanisme de synchronisation fiable.

2.2 Mécanisme de planification des Goroutines

En Go, la planification des Goroutines est gérée par le planificateur du runtime de Go, qui est responsable de l'allocation du temps d'exécution sur les processeurs logiques disponibles. Le planificateur Go utilise la technologie de planification M:N (plusieurs Goroutines mappées sur plusieurs threads OS) pour obtenir de meilleures performances sur les processeurs multi-cœurs.

GOMAXPROCS et processeurs logiques

GOMAXPROCS est une variable d'environnement qui définit le nombre maximal de CPU disponibles pour le planificateur du runtime, la valeur par défaut étant le nombre de cœurs de CPU sur la machine. Le runtime Go attribue un thread OS pour chaque processeur logique. En définissant GOMAXPROCS, nous pouvons restreindre le nombre de cœurs utilisés par le runtime.

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

Le code ci-dessus définit un maximum de deux cœurs pour planifier les Goroutines, même lors de l'exécution du programme sur une machine ayant plus de cœurs.

Fonctionnement du planificateur

Le planificateur fonctionne en utilisant trois entités importantes : M (machine), P (processeur) et G (goroutine). M représente une machine ou un thread, P représente le contexte de planification, et G représente une goroutine spécifique.

  1. M : Représente une machine ou un thread, servant d'abstraction des threads du noyau OS.
  2. P : Représente les ressources nécessaires pour exécuter une goroutine. Chaque P a une file de goroutines locale.
  3. G : Représente une goroutine, comprenant sa pile d'exécution, son jeu d'instructions et d'autres informations.

Les principes de fonctionnement du planificateur Go sont :

  • M doit avoir un P pour exécuter G. S'il n'y a pas de P, M sera renvoyé au cache de threads.
  • Lorsque G n'est pas bloquée par une autre G (par exemple, dans des appels système), elle s'exécute sur le même M autant que possible, aidant à maintenir les données locales de G "chaudes" pour une utilisation plus efficace du cache CPU.
  • Lorsqu'une G est bloquée, M et P se sépareront, et P cherchera un nouveau M ou réveillera un nouveau M pour servir d'autres G.
go func() {
    fmt.Println("Bonjour depuis la goroutine")
}()

Le code ci-dessus montre le démarrage d'une nouvelle goroutine, ce qui amènera le planificateur à ajouter cette nouvelle G à la file d'attente pour exécution.

Ordonnancement préemptif des goroutines

Aux premiers stades, Go utilisait un ordonnancement coopératif, ce qui signifiait que les goroutines pouvaient affamer d'autres goroutines si elles s'exécutaient longtemps sans renoncer volontairement au contrôle. Maintenant, le planificateur Go implémente un ordonnancement préemptif, permettant de mettre en pause les G longues pour donner à d'autres G la possibilité de s'exécuter.

2.3 Gestion du cycle de vie des goroutines

Pour garantir la robustesse et les performances de votre application Go, comprendre et gérer correctement le cycle de vie des goroutines est crucial. Démarrer des goroutines est simple, mais sans une gestion appropriée, elles peuvent entraîner des problèmes tels que des fuites de mémoire et des conditions de concurrence.

Démarrage sécurisé des goroutines

Avant de démarrer une goroutine, assurez-vous de comprendre sa charge de travail et ses caractéristiques d'exécution. Une goroutine devrait avoir un début et une fin clairs pour éviter de créer des "orphelins de goroutines" sans conditions de terminaison.

func travailleur(fait chan bool) {
    fmt.Println("Travail en cours...")
    time.Sleep(time.Second) // simule une tâche coûteuse
    fmt.Println("Travail terminé.")
    fait <- true
}

func main() {
    // Ici, le mécanisme de canal en Go est utilisé. Vous pouvez tout simplement considérer le canal comme une file de messages de base, et utiliser l'opérateur "<-" pour lire et écrire des données dans la file d'attente.
    fait := make(chan bool, 1)
    go travailleur(fait)
    
    // Attendez que la goroutine se termine
    <-fait
}

Le code ci-dessus montre une façon d'attendre qu'une goroutine se termine en utilisant le canal fait.

Note : Cet exemple utilise le mécanisme de canal en Go, qui sera détaillé dans les chapitres ultérieurs.

Arrêt des goroutines

En général, la fin de l'ensemble du programme terminera implicitement toutes les goroutines. Cependant, dans les services à longue durée d'exécution, nous pouvons avoir besoin d'arrêter activement des goroutines.

  1. Utiliser des canaux pour envoyer des signaux d'arrêt : Les goroutines peuvent inspecter les canaux pour vérifier les signaux d'arrêt.
arret := make(chan struct{})

go func() {
    for {
        select {
        case <-arret:
            fmt.Println("Signal d'arrêt reçu. Arrêt en cours...")
            return
        default:
            // exécuter une opération normale
        }
    }
}()

// Envoyer un signal d'arrêt
arret <- struct{}{}
  1. Utiliser le paquet context pour gérer le cycle de vie :
ctx, annuler := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Signal d'arrêt reçu. Arrêt en cours...")
            return
        default:
            // exécuter une opération normale
        }
    }
}(ctx)

// lorsque vous voulez arrêter la goroutine
annuler()

L'utilisation du paquet context permet un contrôle plus flexible des goroutines, offrant des fonctionnalités de temporisation et d'annulation. Dans les grandes applications ou microservices, context est le moyen recommandé de contrôler les cycles de vie des goroutines.