Aller au contenu principal

Cours 5 — Channels

Fonctions anonymes et goroutines​

Une fonction anonyme est une fonction définie par sa déclaration immédiate, sans nom, à l'endroit même où elle est utilisée — utile pour répondre rapidement à un besoin ponctuel sans avoir à la nommer et la déclarer ailleurs :

func main() {
func(l int, b int) {
fmt.Println(l * b)
}(20, 30)
}

Utilisée seule comme ci-dessus, une fonction anonyme n'est pas très utile — on pourrait tout aussi bien écrire une fonction nommée. Son vrai intérêt apparaît combinée à go, pour lancer des fonctions anonymes en parallèle :

for i := 0; i < count; i++ {
go func(x int) {
fmt.Printf("%d ", x)
}(i)
}

Exemple

Piège classique : capture de la variable de boucle par une closure lancée en goroutine. Avant Go 1.22, la variable d'une boucle for était partagée entre toutes les itérations — une goroutine qui la référence directement (au lieu de la recevoir en paramètre) risque de voir sa valeur changée avant même d'avoir démarré :

conns := []net.Conn{conn1, conn2, conn3}
for i, c := range conns {
go func() {
fmt.Println(i, c) // BUG (avant Go 1.22) : i et c sont partagées entre toutes les
c.Write([]byte("bonjour")) // itérations — plusieurs goroutines peuvent voir la
}() // valeur de la DERNIÈRE itération plutôt que la leur
}

Le correctif classique — encore une bonne pratique de lisibilité aujourd'hui — est exactement le patron montré plus haut : passer la variable en paramètre de la fonction anonyme, ce qui en crée une copie propre à chaque appel :

for i, c := range conns {
go func(i int, c net.Conn) {
fmt.Println(i, c)
c.Write([]byte("bonjour"))
}(i, c)
}

Depuis Go 1.22 (2024), chaque itération d'une boucle for obtient sa propre copie des variables de boucle, ce qui élimine ce piège par défaut — mais le réflexe de passer les valeurs en paramètre reste recommandé pour un code lisible et portable vers d'anciennes versions de Go.

Les channels sans tampons (unbuffered channels)​

Les channels offrent une façon de communiquer entre les goroutines afin de se synchroniser ensemble en échangeant des données.

Cependant, il y a des règles précises. Premièrement, chaque channel doit être défini sur un type de donnée précis, donc il faut typer les channels. Cela peut être un entier, une chaîne, une struct, ...

Deuxièmement, vous allez avoir besoin d'une méthode pour recevoir ce qui a été transmis dans le channel. Il faut impérativement pouvoir recevoir avant d'émettre car un channel est bloquant, tant qu'un channel attend, le code ne peut pas continuer.

Voici un exemple de synchronisation entre deux méthodes go:

package main
import (
"fmt"
"time"
)

func pinger(c chan string) {
for i := 0; ; i++ {
c <- "ping"
}
}

func printer(c chan string) {
for {
msg := <- c
fmt.Println(msg)
time.Sleep(time.Second * 5)
}
}

func main() {
var c chan string = make(chan string)
go pinger(c)
go printer(c)
var input string
fmt.Scanln(&input)
}

Comme vous le constatez, la façon de créer un channel est d'utiliser la commande make et de passer le type d'information partagée par le channel: var c chan string = make(chan string).

Lors de la création d'un channel, il est possible d'établir la taille du channel en passant un second argument à make (type, taille). Pour une taille de 0 ou non-spécifiée comme dans l'exemple, la communication est unbuffered.

Synchronisation: lorsque l'émetteur (pinger) envoie un message sur le channel, il va attendre jusqu'à ce que l'afficheur (printer) soit prêt à le recevoir. Tout comme le récepteur attend de recevoir un message dans le channel avant de continuer. Les deux opérations sont bloquantes autant pour l'émetteur que le récepteur, c'est pour cette raison qu'on appelle ce type de channels synchrones.

Le programme précédent va pinger sans arrêt (vous pouvez appuyer sur enter pour l'arrêter). Un channel est représenté par le mot clé chan et dans ce cas-ci on lui passe une chaîne. La flèche gauche (<-) est utilisée pour envoyer et recevoir des messages dans le channel:

  • c <- "ping": signifie envoyer "ping" dans le channel c.
  • msg := <- c: signifie recevoir le message provenant de c et l'enregistrer dans msg.

Nous aurions pu remplacer la portion par fmt.Println(<-c) et ainsi éviter l'assignation de la variable msg.

Ajoutons cette fonction:

func ponger(c chan string) {
for i:= 0; ; i++ {
c <- "pong"
}
}

func main() {
var c chan string = make(chan string)
go pinger(c)
go ponger(c)
go printer(c)
var input string
fmt.Scanln(&input)
}

Le programme va maintenant y aller à tour de rôle pour afficher ping, pong.

Quand l'utiliser (channels non tamponnés)​

Pour synchroniser deux étapes qui doivent se passer le relais sans accumuler de retard — typiquement une chaîne de traitement réseau : une goroutine lit les octets bruts sur la connexion, une autre les découpe en messages, une troisième les traite, chacune transmettant son résultat à la suivante via un channel (un pipeline de channels).

Channel tampon avec taille (buffered channel)​

Lors de la création d'un channel avec make, vous pouvez leur assigner une taille:

ch = make (chan string, 3)

Une opération d'envoi sur le channel va ajouter à la fin du channel et une opération de réception va aller lire au début. Si le channel est plein, les opérations d'envoi sont bloquées tant et aussi longtemps qu'une opération de lecture n'aura pas lieu. Logiquement aussi, une opération de lecture sur un channel vide va bloquer tant qu'une information ne sera pas disponible.

Le choix entre les deux est purement le vôtre, il est à noter que si vous avez besoin de synchronisation, un unbuffered channel est mieux. Ceci est utile lorsque vous voulez déterminer le nombre de goroutines à lancer ou limiter le travail entre deux goroutines.

package main

import (
"fmt"
"time"
)

func main() {
maxGoroutines := 10
guard := make(chan struct{}, maxGoroutines)
done := make(chan struct{})

for i := 0; i < 30; i++ {
guard <- struct{}{} // bloque lorsqu'on arrive à maxGoroutines
go func(n int) {
worker(n)
<-guard
done <- struct{}{}
}(i)
}

for i := 0; i < 30; i++ {
<-done
}
}

func worker(i int) {
fmt.Println("travaille sur la tâche ", i)
time.Sleep(5 * time.Second)
}

Quand l'utiliser (channel tamponné)​

Comme boîte aux lettres bornée pour les messages entrants d'une connexion, quand le rythme de lecture réseau et le rythme de traitement ne sont pas exactement synchronisés — un channel tamponné absorbe les petites rafales sans bloquer la lecture à chaque message, tant que le traitement finit par suivre.

Quand l'éviter​

Si vous avez besoin d'une garantie stricte que le récepteur a bien traité un message avant que l'émetteur continue (un vrai rendez-vous synchrone) — un channel tamponné masque ce genre de dépendance ; préférez alors un channel non tamponné.

Multiplexage de channel avec select​

Soit un programme effectuant le démarrage d'une navette spatiale, le programme suit son cours régulier en effectuant le countdown de 10 à 1 (channel) pour effectuer le décollage, cependant à tout instant, un channel (annulation) pourrait être utilisé afin d'arrêter le countdown et d'annuler le décollage:

package main

import (
"fmt"
"os"
"time"
)

func main() {
fmt.Println("Compte à rebours")
abort := make(chan struct{})

go func() {
os.Stdin.Readln() // read a single byte
abort <- struct{}{}
}()

tick := time.NewTicker(1 * time.Second)

for countdown := 10; countdown > 0; countdown-- {
fmt.Printf("%v...", countdown)
select {

case <-tick.C: // Ne fait rien mais attends un tick de la part de time.
case <-abort:
fmt.Println("Décollage annulé!")
return // quitte la fonction main
}
}
launch()
}

func launch() {
fmt.Println("Décollage")
}

Il y a donc deux sources de messages, une provenant du NewTicker, qui retourne un channel nommé tick.C et qui va envoyer des messages chaque seconde tel que défini dans sa création. Il est aussi possible de recevoir un message fait par l'entremise de Enter sur le clavier.

Les deux peuvent communiquer et c'est donc pour cette raison que le select est utilisé afin d'obtenir le choix entre les deux. Un default aurait aussi pu être utilisé pour éviter des situations bloquantes:

select {
case <-abort:
fmt.Println("Décollage annulé!")
return // quitte la fonction main
default: // ne fait rien
}

Benchmarks​

Il est également possible de tester la vitesse d'exécution de notre code par l'entremise du module testing de go. Soit la fonction fibonacci récursive:

package fib

func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}

Calculons son temps d'exécution sur plusieurs itérations, dans le fichier fib_test.go:

package fib

import "testing"

func BenchmarkFib10(b *testing.B) {
for b.Loop() {
Fib(10)
}
}

Notez que le nom de la fonction doit commencer par "Benchmark". Pour exécuter le test:

go test -bench=.

b.Loop() (introduit en Go 1.24) remplace le compteur b.N : Go décide lui-même du nombre de fois qu'il doit exécuter le corps de la boucle, en fait la moyenne pour obtenir un temps réaliste, et garde automatiquement vivantes les variables assignées à l'intérieur de la boucle — ce qui empêche le compilateur d'optimiser (et de sauter) l'appel, sans avoir besoin d'une variable globale. On peut aussi tester plusieurs valeurs à l'intérieur du même test:

func benchmarkFib(i int, b *testing.B) {
for b.Loop() {
Fib(i)
}
}
func BenchmarkFib1(b *testing.B) { benchmarkFib(1, b) }
func BenchmarkFib10(b *testing.B) { benchmarkFib(10, b) }
func BenchmarkFib40(b *testing.B) { benchmarkFib(40, b) }

Vous croiserez encore souvent l'ancienne forme, dans du code ou des tutoriels écrits avant Go 1.24 (ou dans du code qui n'a pas encore été mis à jour) :

// Ancienne forme (toujours valide, mais plus la pratique recommandée) :
// nécessite un compteur explicite et une variable globale pour éviter
// que le compilateur optimise l'appel.
var resultat int
func benchmarkFib(i int, b *testing.B) {
var r int
for n := 0; n < b.N; n++ {
r = Fib(i)
}
resultat = r
}

Exemple

La même technique s'applique telle quelle à une fonction qui lance des goroutines : un simple for b.Loop(), sans rien faire de spécial pour le parallélisme interne. L'exemple suivant compare la somme séquentielle d'un grand tableau à une version qui découpe le tableau en segments traités chacun par une goroutine, les sommes partielles étant combinées par channel plutôt que par une variable commune :

Exemple

Quand l'utiliser​

Pour comparer objectivement deux implémentations candidates avant de choisir laquelle garder — par exemple deux façons de sérialiser un message avant de l'envoyer sur le réseau (encoding/json contre un format binaire fait main).

Quand l'éviter​

Pour micro-optimiser une fonction appelée seulement une poignée de fois au démarrage — le gain potentiel ne justifie pas l'effort ; réservez les benchmarks au code réellement exécuté dans un chemin chaud (ex.: à chaque message reçu par le serveur).

Aller plus loin : profiler un benchmark avec pprof​

Un benchmark dit combien de temps une fonction prend, mais pas où ce temps est passé à l'intérieur. Pour ça, Go peut générer un profil d'exécution directement depuis go test :

go test -bench=BenchmarkFib40 -cpuprofile=cpu.prof
go tool pprof cpu.prof

La commande pprof ouvre une console interactive (top pour voir les fonctions les plus coûteuses, web pour un graphe visuel si graphviz est installé). Utile dès qu'un benchmark révèle qu'une fonction est lente mais qu'on ne sait pas pourquoi — par exemple pour distinguer du temps passé dans vos boucles de temps passé dans des appels réseau ou des allocations mémoire.