Contactez-nous

Création et gestion des goroutines

Maîtrisez la création et la gestion des goroutines en Go : lancement, fonctions anonymes, WaitGroup, gestion des erreurs, modèles d'annulation et bonnes pratiques pour un code concurrent robuste.

Techniques avancées de création de goroutines : Au-delà du simple go

Si le mot-clé go est la base de la création de goroutines en Go, il existe des techniques plus avancées et idiomatiques pour créer et lancer des goroutines de manière plus flexible et plus structurée, en particulier lorsqu'il s'agit de gérer des scénarios complexes de concurrence, de synchronisation et de gestion du cycle de vie des goroutines.

Ce chapitre explore ces techniques avancées de création et de lancement de goroutines, en allant au-delà du simple go maFonction(). Nous examinerons l'utilisation de fonctions anonymes (closures) pour créer des goroutines "in-line" et capturer des variables de l'environnement extérieur, l'emploi de WaitGroups pour synchroniser et attendre la terminaison d'ensembles de goroutines, les stratégies pour la gestion des erreurs dans les goroutines, et les modèles d'annulation de goroutines pour un contrôle plus fin de leur exécution. L'objectif est de vous outiller avec un éventail de techniques pour créer et gérer les goroutines de manière experte et adaptée aux besoins spécifiques de vos applications concurrentes Go.

Goroutines et fonctions anonymes (closures) : Flexibilité et portée lexicale

L'utilisation de fonctions anonymes (closures) en combinaison avec le mot-clé go offre une grande flexibilité et expressivité pour la création de goroutines en Go. Les fonctions anonymes permettent de définir des goroutines "in-line", directement à l'endroit où elles sont lancées, et de capturer des variables de l'environnement lexical environnant, offrant un moyen puissant de passer des données et de partager un contexte entre la goroutine appelante et la goroutine lancée.

Avantages des goroutines avec fonctions anonymes :

  • Définition in-line : Les fonctions anonymes permettent de définir le code de la goroutine directement à l'endroit de son lancement, améliorant la localité et la lisibilité du code, en particulier pour les goroutines courtes et spécifiques à un contexte local.
  • Capture de variables de l'environnement : Les closures permettent à la goroutine de capturer et d'accéder aux variables de l'environnement lexical de la fonction appelante. Cela facilite le passage de données à la goroutine et le partage d'un contexte entre la goroutine appelante et la goroutine lancée. Les variables capturées sont partagées par référence, ce qui signifie que les modifications apportées à ces variables dans la goroutine peuvent être visibles dans la fonction appelante (et vice versa, attention aux race conditions !).
  • Réduction de la verbosité : Pour les goroutines simples et courtes, l'utilisation de fonctions anonymes peut être plus concise et moins verbeuse que de définir une fonction nommée séparément et de l'appeler ensuite avec go.

Exemple de goroutines avec fonctions anonymes :

package main

import (
    "fmt"
    "time"
)

func main() {
    message := "Bonjour depuis la goroutine anonyme"

    // Lancement d'une goroutine avec une fonction anonyme (closure)
    go func() {
        fmt.Println("Goroutine anonyme :", message) // Capture de la variable 'message' de l'environnement extérieur
    }() // Appel immédiat de la fonction anonyme (et lancement de la goroutine)

    fmt.Println("Programme principal continue...")

    time.Sleep(1 * time.Second)
}

Dans cet exemple :

  • Une fonction anonyme est définie directement comme argument du mot-clé go. Cette fonction anonyme sera exécutée dans une nouvelle goroutine.
  • La fonction anonyme capture la variable message de l'environnement extérieur (la fonction main). Elle peut accéder à cette variable et afficher sa valeur.
  • L'appel de la fonction anonyme est suivi de () pour l'exécuter immédiatement (et lancer la goroutine).

L'utilisation de goroutines avec des fonctions anonymes est une technique courante et idiomatique en Go, en particulier pour les tâches concurrentes simples et contextuelles.

WaitGroup : Synchroniser et attendre la fin d'un groupe de goroutines

Dans de nombreux scénarios concurrents, il est nécessaire d'attendre que plusieurs goroutines se terminent avant de poursuivre l'exécution du programme principal. Le package sync de la bibliothèque standard Go fournit le type sync.WaitGroup, un outil puissant pour synchroniser et attendre la fin d'un groupe de goroutines.

Fonctionnement de sync.WaitGroup :

sync.WaitGroup fonctionne comme un compteur. Il permet de :

  • Ajouter (incrémenter) le compteur : Pour chaque goroutine que vous lancez et que vous souhaitez attendre, vous incrémentez le compteur du WaitGroup avec la méthode Add(delta int). delta est généralement 1 (pour ajouter une goroutine), mais peut être supérieur pour ajouter plusieurs goroutines à la fois.
  • Signaler la fin d'une goroutine (décrémenter) : Chaque goroutine, une fois qu'elle a terminé son travail, doit décrémenter le compteur du WaitGroup avec la méthode Done(). Done() est équivalent à Add(-1).
  • Attendre que le compteur atteigne zéro (attendre la fin de toutes les goroutines) : La fonction Wait() du WaitGroup bloque l'exécution de la goroutine appelante (généralement la goroutine principale) jusqu'à ce que le compteur du WaitGroup atteigne zéro. Cela signifie que toutes les goroutines ajoutées au WaitGroup ont terminé leur exécution et ont appelé Done().

Exemple d'utilisation de sync.WaitGroup :

package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Décrémenter le WaitGroup à la sortie de la goroutine (garantit l'exécution même en cas de panic)

    fmt.Printf("Worker %d démarré\n", id)
    time.Sleep(time.Duration(id) * time.Second) // Simuler un travail
    fmt.Printf("Worker %d terminé\n", id)
}

func main() {
    var wg sync.WaitGroup // Déclaration d'un WaitGroup

    // Lancement de 3 goroutines worker et ajout de chaque goroutine au WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1) // Incrémenter le WaitGroup avant de lancer la goroutine
        go worker(i, &wg)
    }

    fmt.Println("Programme principal : Attente de la fin des workers...")
    wg.Wait() // Bloquer jusqu'à ce que le WaitGroup soit à zéro (tous les workers aient terminé)
    fmt.Println("Programme principal : Tous les workers ont terminé.")
}

Dans cet exemple :

  • Un sync.WaitGroup est déclaré (var wg sync.WaitGroup).
  • Une boucle for lance 3 goroutines worker. Avant de lancer chaque goroutine, wg.Add(1) est appelé pour incrémenter le compteur du WaitGroup.
  • La fonction worker prend un *sync.WaitGroup en argument (pour pouvoir agir sur le WaitGroup). A l'intérieur de worker, defer wg.Done() est utilisé pour garantir que wg.Done() sera toujours appelé à la sortie de la goroutine, même en cas de panic. wg.Done() décrémente le compteur du WaitGroup.
  • Après avoir lancé les 3 goroutines, wg.Wait() est appelé dans la fonction main. Cela bloque l'exécution du programme principal jusqu'à ce que le compteur du WaitGroup atteigne zéro (c'est-à-dire que les 3 goroutines worker aient toutes appelé wg.Done()).
  • Une fois que wg.Wait() se débloque, le programme principal continue son exécution et affiche le message "Programme principal : Tous les workers ont terminé.".

sync.WaitGroup est un outil essentiel pour synchroniser et attendre la terminaison de groupes de goroutines, permettant de coordonner l'exécution concurrente de tâches et de s'assurer que toutes les opérations concurrentes sont terminées avant de poursuivre l'exécution du programme principal.

Gestion des erreurs dans les goroutines : Collecter et propager les erreurs

La gestion des erreurs dans les goroutines requiert une attention particulière, car les erreurs qui se produisent à l'intérieur d'une goroutine ne sont pas automatiquement propagées à la goroutine appelante (généralement la goroutine principale). Si une goroutine déclenche une panique, cela peut potentiellement faire planter toute l'application, à moins que la panique ne soit récupérée (recovered) de manière contrôlée.

Il est donc important de mettre en place des mécanismes pour collecter les erreurs qui se produisent dans les goroutines et les propager à la goroutine principale (ou à un autre point de gestion des erreurs) afin de pouvoir les traiter de manière appropriée (logging, retries, fallback, etc.).

Techniques pour la gestion des erreurs dans les goroutines :

  • Channels pour retourner les erreurs : La méthode la plus idiomatique et la plus sûre pour propager les erreurs depuis une goroutine vers la goroutine appelante est d'utiliser un channel pour retourner la valeur error. La goroutine worker envoie la valeur error (ou nil en cas de succès) sur un channel, et la goroutine appelante reçoit cette valeur depuis le channel et peut la traiter.
  • Fonctions de callback avec gestion d'erreurs : Dans certains cas, vous pouvez passer une fonction de callback à une goroutine, qui sera exécutée par la goroutine une fois son travail terminé. La fonction de callback peut prendre en argument une valeur error (retournée par la goroutine) et gérer l'erreur dans le contexte de la goroutine appelante.
  • Groupes d'erreurs (Error Groups) : Pour gérer les erreurs provenant de plusieurs goroutines, vous pouvez utiliser un groupe d'erreurs (error group) qui permet de collecter les erreurs de toutes les goroutines et de retourner une erreur agrégée (par exemple, un slice d'erreurs) à la fin de l'exécution des goroutines. Le package go.sync/errgroup propose une implémentation de ErrorGroup.
  • Récupération de paniques (recover) dans les goroutines (avec prudence) : Dans certains cas exceptionnels, vous pouvez utiliser la fonction recover() à l'intérieur d'une fonction defer dans une goroutine pour intercepter une panique et éviter que la goroutine (ou toute l'application) ne plante brutalement. Cependant, l'utilisation de recover doit être faite avec prudence et réservée aux cas où vous avez une stratégie claire pour gérer la panique de manière contrôlée. Evitez de masquer ou d'ignorer les paniques sans les comprendre et les traiter correctement.

Exemple de gestion des erreurs avec channels :

package main

import (
    "fmt"
    "errors"
)

func workerRisque(id int, resultats chan<- string, erreurs chan<- error) {
    // Simuler une opération risquée qui peut échouer
    if id%2 == 0 {
        erreurs <- errors.New(fmt.Sprintf("Worker %d : Erreur lors du traitement", id)) // Envoi d'une erreur sur le channel 'erreurs'
        return
    }
    resultats <- fmt.Sprintf("Worker %d : Traitement réussi", id) // Envoi d'un résultat sur le channel 'resultats'
}

func main() {
    resultatsChan := make(chan string)
    erreursChan := make(chan error)

    // Lancement de 3 goroutines workerRisque
    for i := 1; i <= 3; i++ {
        go workerRisque(i, resultatsChan, erreursChan)
    }

    // Collecte des résultats et des erreurs depuis les channels
    for i := 1; i <= 3; i++ {
        select {
        case resultat := <-resultatsChan:
            fmt.Println("Résultat reçu :", resultat)
        case err := <-erreursChan:
            fmt.Println("Erreur reçue :", err)
        }
    }
}

Dans cet exemple, chaque goroutine workerRisque utilise deux channels : resultatsChan pour retourner un résultat en cas de succès, et erreursChan pour retourner une erreur en cas d'échec. La fonction main utilise une boucle select pour attendre et traiter les résultats ou les erreurs provenant des goroutines, en les collectant depuis les channels appropriés.

Patterns d'annulation de goroutines : Context et contrôle de l'exécution

Dans les applications concurrentes complexes, il est souvent nécessaire de pouvoir annuler l'exécution de goroutines en cours, par exemple, en cas d'erreur, de timeout, d'annulation par l'utilisateur, ou de fermeture du programme. Go propose le package context, un outil puissant et idiomatique pour gérer l'annulation et la propagation de signaux d'annulation à travers les goroutines.

Le package context : Gestion de l'annulation et des délais

Le package context introduit le type context.Context, qui permet de transporter des informations de contexte (comme des deadlines, des valeurs clés-valeur, et des signaux d'annulation) à travers les appels de fonctions et les goroutines. Il fournit des mécanismes pour :

  • Annulation : Permettre à une goroutine d'être annulée depuis l'extérieur, en signalant qu'elle doit arrêter son travail et se terminer proprement.
  • Délais (Timeouts) : Définir des délais (timeouts) pour les opérations concurrentes, pour limiter leur durée d'exécution et éviter les blocages infinis.
  • Valeurs contextuelles (Context Values) : Transporter des valeurs (clés-valeur) à travers la chaîne d'appels de fonctions et les goroutines, pour transmettre des informations contextuelles (par exemple, des identifiants de requête, des informations d'authentification) sans avoir à les passer explicitement comme arguments à chaque fonction.

Création d'un contexte annulable : context.WithCancel

Pour créer un contexte annulable, vous utilisez la fonction context.WithCancel(parent Context) (ctx Context, cancel CancelFunc). Elle prend en argument un contexte parent (généralement context.Background() pour un contexte racine) et retourne :

  • Un nouveau contexte enfant ctx, dérivé du contexte parent, qui est annulable.
  • Une fonction cancel de type CancelFunc. Appeler la fonction cancel() annule le contexte ctx et tous ses contextes enfants.

Propagation du signal d'annulation : Channel Done()

Un contexte annulable (créé avec context.WithCancel) expose une méthode Done() <-chan struct{} qui retourne un channel "done". Ce channel est fermé lorsque le contexte est annulé (via l'appel de la fonction cancel()). Les goroutines peuvent écouter ce channel Done() pour détecter le signal d'annulation et arrêter leur travail.

Exemple d'annulation de goroutines avec context.WithCancel :

package main

import (
    "context"
    "fmt"
    "time"
)

func workerAnnulable(ctx context.Context, id int) {
    fmt.Printf("Worker %d démarré\n", id)
    defer fmt.Printf("Worker %d terminé\n", id)

    for {
        select {
        case <-ctx.Done(): // Ecoute du channel 'Done()' pour détecter l'annulation
            fmt.Printf("Worker %d : Annulation détectée, arrêt du travail.\n", id)
            return // Sortie de la goroutine
        default:
            fmt.Printf("Worker %d : Travail en cours...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background()) // Création d'un contexte annulable
    
    // Lancement de 3 goroutines annulables
    for i := 1; i <= 3; i++ {
        go workerAnnulable(ctx, i)
    }

    time.Sleep(2 * time.Second) // Laisser les workers travailler pendant 2 secondes
    fmt.Println("Programme principal : Annulation des workers...")
    cancel() // Annulation du contexte (et de toutes les goroutines qui l'écoutent)

    time.Sleep(1 * time.Second) // Laisser le temps aux workers de s'arrêter proprement
    fmt.Println("Programme principal : Fin du programme.")
}

Dans cet exemple :

  • context.WithCancel(context.Background()) crée un contexte annulable ctx et une fonction cancel.
  • Chaque goroutine workerAnnulable prend le contexte ctx en argument et écoute le channel ctx.Done() dans une boucle select.
  • Dans la fonction main, après 2 secondes, cancel() est appelé pour annuler le contexte. Cela ferme le channel ctx.Done().
  • Les goroutines workerAnnulable détectent la fermeture du channel ctx.Done() dans leur boucle select (cas <-ctx.Done():) et arrêtent leur travail proprement en sortant de la fonction.

Le package context offre un mécanisme puissant et idiomatique pour gérer l'annulation des goroutines et contrôler leur cycle de vie, permettant de construire des applications concurrentes plus robustes et réactives.