Concurrency Go : Goroutines et Channels - Guide Complet

Maîtrisez la concurrence en Go avec goroutines et channels. Patterns avancés, synchronisation, select, et bonnes pratiques avec exemples de code détaillés.

Concurrence Go - Goroutines et Channels en action

La concurrence représente l'un des points forts de Go. Contrairement à d'autres langages où le multithreading reste complexe, Go propose un modèle élégant basé sur les goroutines et channels qui simplifie considérablement le développement d'applications concurrentes.

Philosophie Go

"Ne communiquez pas en partageant la mémoire, partagez la mémoire en communiquant." Ce principe fondamental guide toute la conception de la concurrence en Go.

Comprendre les goroutines

Les goroutines sont des threads légers gérés par le runtime Go. Elles consomment environ 2 Ko de stack (contre plusieurs Mo pour un thread OS) et permettent d'exécuter des milliers de tâches concurrentes sans surcharge système.

Le lancement d'une goroutine s'effectue simplement avec le mot-clé go devant un appel de fonction. Le runtime se charge de la planification et de la distribution sur les threads disponibles.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simule une requête réseau
func fetchData(id int) {
    // Simule un délai réseau
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Données %d récupérées\n", id)
}

func main() {
    // Lancement séquentiel - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Séquentiel: %v\n", time.Since(start))

    // Lancement concurrent - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Exécution en goroutine
    }
    time.Sleep(150 * time.Millisecond) // Attend la fin
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

L'exécution concurrente réduit le temps total de 500ms à environ 100ms. Cependant, utiliser time.Sleep pour synchroniser les goroutines n'est pas une bonne pratique. Les channels offrent une solution élégante.

Channels : communication entre goroutines

Un channel est un conduit typé permettant l'envoi et la réception de valeurs entre goroutines. Les channels assurent la synchronisation : une goroutine qui envoie attend qu'une autre reçoive, et inversement.

La création d'un channel utilise la fonction make. L'opérateur <- permet d'envoyer et recevoir des données selon son placement par rapport au channel.

channels_basic.gogo
package main

import "fmt"

// worker effectue un calcul et renvoie le résultat via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Reçoit les jobs jusqu'à fermeture du channel
    for job := range jobs {
        result := job * 2 // Traitement
        results <- result // Envoi du résultat
    }
}

func main() {
    // Création des channels
    jobs := make(chan int, 10)    // Channel bufferisé
    results := make(chan int, 10)

    // Démarrage de 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Envoi de 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signale la fin des jobs

    // Collecte des résultats
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Résultat: %d\n", result)
    }
}

Les channels directionnels (<-chan pour réception, chan<- pour envoi) renforcent la sécurité du code en limitant les opérations possibles.

Channels bufferisés vs non-bufferisés

La distinction entre ces deux types de channels influence directement le comportement de synchronisation des goroutines.

Les channels non-bufferisés bloquent l'expéditeur jusqu'à ce qu'un récepteur soit prêt. Les channels bufferisés permettent d'envoyer jusqu'à N valeurs sans blocage, où N représente la capacité du buffer.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Channel non-bufferisé - synchronisation stricte
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Bloque jusqu'à réception
    }()

    msg := <-unbuffered // Débloque l'envoi
    fmt.Println(msg)

    // Channel bufferisé - capacité de 2
    buffered := make(chan int, 2)

    // Ces envois ne bloquent pas
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Bloquerait car buffer plein

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Vérification de la capacité
    fmt.Printf("Longueur: %d, Capacité: %d\n",
        len(buffered), cap(buffered))
}

Les channels bufferisés s'utilisent pour découpler producteurs et consommateurs, tandis que les non-bufferisés garantissent une synchronisation point à point.

Attention aux deadlocks

Un deadlock survient quand toutes les goroutines sont bloquées en attente. Le runtime Go le détecte et termine le programme avec un message d'erreur explicite.

Select : multiplexage de channels

L'instruction select permet d'attendre sur plusieurs opérations de channel simultanément. Elle s'apparente à un switch pour les communications concurrentes.

Cette construction est essentielle pour gérer des timeouts, annulations et communications multiples sans bloquer indéfiniment sur un seul channel.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simule un appel API avec délai variable
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: données reçues", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Lancement de deux appels API en parallèle
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Timeout global de 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collecte des résultats avec timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - opération annulée")
            return
        }
    }
}

Le select choisit le premier channel prêt. Si plusieurs sont prêts, le choix est pseudo-aléatoire pour éviter la famine.

Prêt à réussir tes entretiens Go ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Pattern Worker Pool

Le pattern worker pool distribue des tâches entre plusieurs workers, limitant la concurrence et optimisant l'utilisation des ressources. Ce pattern s'avère indispensable pour traiter de grandes quantités de données.

L'implémentation repose sur un channel de jobs partagé entre workers et un channel de résultats pour la collecte.

worker_pool.gogo
package main

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

// Task représente une unité de travail
type Task struct {
    ID   int
    Data string
}

// Result contient le résultat du traitement
type Result struct {
    TaskID int
    Output string
}

// worker traite les tâches reçues
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulation du traitement
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d a traité: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Démarrage des workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Envoi des tâches
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("tâche-%d", i)}
    }
    close(tasks)

    // Fermeture du channel results après fin des workers
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collecte des résultats
    for result := range results {
        fmt.Printf("Tâche %d: %s\n", result.TaskID, result.Output)
    }
}

Le sync.WaitGroup coordonne l'attente de la fin de tous les workers avant de fermer le channel de résultats.

Pattern Fan-Out/Fan-In

Ce pattern distribue le travail entre plusieurs goroutines (fan-out) puis agrège les résultats (fan-in). Il maximise le parallélisme tout en simplifiant la collecte des résultats.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produit des nombres sur un channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square calcule le carré des nombres reçus
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combine plusieurs channels en un seul (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Fonction de sortie pour chaque channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Lancement d'une goroutine par channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Fermeture après fin de toutes les goroutines
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Génération des données
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribution sur 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: agrégation des résultats
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

Ce pattern brille pour les opérations CPU-bound distribuables et les pipelines de traitement de données.

Context pour l'annulation et les deadlines

Le package context standardise la gestion des annulations, deadlines et valeurs à travers les goroutines. Toute goroutine longue durée devrait accepter un context comme premier paramètre.

context_example.gogo
package main

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

// fetchWithContext simule une requête annulable
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simule une opération longue
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Données de %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled ou context.DeadlineExceeded
    }
}

func main() {
    // Context avec timeout de 500ms
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Libère les ressources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Erreur: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context avec annulation manuelle
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Annulation explicite
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Requête annulée: %v\n", err)
    }
}
Bonne pratique

Toujours appeler defer cancel() immédiatement après création du context pour éviter les fuites de ressources.

Synchronisation avec sync.Mutex

Bien que les channels soient privilégiés pour la communication, le package sync reste nécessaire pour protéger l'accès concurrent aux structures de données partagées.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter est un compteur thread-safe
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment incrémente la valeur pour une clé donnée
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Verrouillage exclusif
    defer c.mu.Unlock() // Déverrouillage garanti
    c.value[key]++
}

// Value retourne la valeur actuelle
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

    // 1000 incrémentations concurrentes
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment("visits")
        }()
    }

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

Le sync.RWMutex optimise les lectures concurrentes avec RLock()/RUnlock() pour les opérations de lecture uniquement.

Erreurs courantes et solutions

La concurrence en Go présente des pièges classiques. Voici les erreurs les plus fréquentes et comment les éviter.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERREUR: Capture de variable de boucle
    // Toutes les goroutines afficheraient la même valeur
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture par référence - BUG
        }()
    }

    // SOLUTION: Passer la valeur en paramètre
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Copie locale - CORRECT
        }(i)
    }
    wg.Wait()

    // ERREUR: Envoi sur channel nil
    var ch chan int
    // ch <- 1 // Bloque indéfiniment

    // SOLUTION: Toujours initialiser avec make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERREUR: Envoi sur channel fermé
    done := make(chan bool)
    close(done)
    // done <- true // Panique!

    // SOLUTION: Vérifier avant envoi ou utiliser sync.Once
    select {
    case done <- true:
        fmt.Println("Envoyé")
    default:
        fmt.Println("Channel fermé ou plein")
    }
}

La détection des data races s'effectue avec le flag -race lors de la compilation ou des tests : go test -race ./....

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Conclusion

La maîtrise de la concurrence en Go repose sur quelques concepts clés qui, bien compris, permettent de construire des applications hautement performantes.

Points essentiels à retenir :

✅ Les goroutines sont légères et bon marché - en créer des milliers reste acceptable

✅ Les channels synchronisent et transfèrent les données entre goroutines

✅ Le select gère les communications multiples et les timeouts

✅ Le pattern worker pool limite la concurrence et optimise les ressources

✅ Le package context standardise annulation et deadlines

✅ Les mutex protègent les données partagées quand les channels ne suffisent pas

✅ Le flag -race détecte les data races lors des tests

La philosophie "Share memory by communicating" guide vers des designs plus sûrs et maintenables que le multithreading traditionnel avec locks.

Tags

#go
#golang
#concurrency
#goroutines
#channels

Partager

Articles similaires