Placez go devant un appel de fonction et cette fonction s’exécute en concurrence avec le reste du programme, gérée par le runtime de Go plutôt que par le système d’exploitation. La lancer est la partie facile. La partie difficile, c’est faire passer un résultat entre deux goroutines sans qu’elles se marchent dessus sur un état partagé, et c’est le travail des channels.

Qu’est-ce qu’une goroutine ?

Une goroutine est une fonction qui s’exécute indépendamment de celle qui l’a lancée, gérée par le runtime de Go et non par le système d’exploitation. Un thread du système d’exploitation réserve un mégaoctet ou plus de stack et coûte au noyau un vrai temps de planification. Une goroutine démarre avec un stack d’environ 2 Ko qui grandit selon les besoins, et le runtime multiplexe de nombreuses goroutines sur une poignée de threads du système d’exploitation. En lancer des milliers à la fois est normal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
	"fmt"
	"time"
)

func sayHi(name string) {
	fmt.Println("hi,", name)
}

func main() {
	go sayHi("alice")
	go sayHi("bob")
	time.Sleep(10 * time.Millisecond)
	fmt.Println("done")
}

Exécutez ce code plusieurs fois et l’ordre de “hi, alice” et “hi, bob” change. C’est le scheduler qui décide. time.Sleep sert uniquement à garder main en vie assez longtemps pour que les goroutines affichent leur message : supprimez-le et le programme peut se terminer avant que l’une ou l’autre ne s’exécute, parce que le retour de main met fin au processus quoi qu’il reste en cours. Le sleep est un bricolage, pas une synchronisation. C’est un channel qui le remplace.

Comment les channels relient-ils les goroutines ?

Un channel est un tuyau typé : une goroutine y envoie une valeur, une autre la récupère, et le runtime gère le passage de relais. Imaginez un tapis roulant à une seule voie entre deux ouvriers. Celui qui envoie pose un objet sur le tapis et ne peut pas en poser un second avant que le premier ne soit retiré. C’est un channel non bufferisé, et ce passage de relais bloquant est tout le mécanisme de synchronisation.

1
2
3
4
5
6
7
8
ch := make(chan string)

go func() {
	ch <- "payload ready"
}()

msg := <-ch
fmt.Println(msg)

make(chan string) crée un channel de strings non bufferisé. L’envoi (ch <- "payload ready") bloque jusqu’à ce que quelque chose le reçoive, et la réception (<-ch) bloque jusqu’à ce que quelque chose l’envoie. Aucun des deux côtés n’avance tant que les deux ne sont pas prêts, ce qui est exactement le passage de relais que le time.Sleep précédent se contentait de simuler.

Donnez une capacité au channel et le tapis supporte plus d’un objet avant de se bloquer :

1
2
3
4
5
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// a 4th send here would block until something receives

L’envoi d’un channel bufferisé ne bloque que lorsque le buffer est plein ; la réception ne bloque que lorsqu’il est vide. Cela détend le couplage entre l’émetteur et le récepteur et aide à absorber les pics d’activité. Cela ne supprime pas le besoin de coordination, seulement le point où le blocage commence.

Comment fermer un channel et faire un range sur celui-ci ?

Celui qui envoie ferme un channel pour dire « plus rien n’arrivera » :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func producer(ch chan<- int) {
	for i := 1; i <= 3; i++ {
		ch <- i
	}
	close(ch)
}

ch := make(chan int)
go producer(ch)
for v := range ch {
	fmt.Println(v) // 1, 2, 3, then the loop exits
}

for v := range ch continue à recevoir jusqu’à ce que le channel soit fermé et vidé, puis se termine de lui-même. Pas de valeur sentinelle, pas de comptage manuel. Pour distinguer un channel fermé et vide d’un channel qui a livré une valeur zéro, utilisez la forme à deux valeurs : v, ok := <-ch met ok à false uniquement quand le channel est fermé et qu’il ne reste plus rien à recevoir.

close a trois façons de paniquer, et le compilateur n’en détecte aucune :

  • Envoyer sur un channel fermé panique.
  • Fermer un channel déjà fermé panique.
  • Fermer un channel nil panique.

C’est donc toujours l’émetteur qui ferme, jamais le récepteur. Le récepteur n’a aucun moyen de savoir si une autre goroutine a encore l’intention d’envoyer.

Comment attendre que plusieurs goroutines se terminent ?

range fonctionne quand un channel signale la fin du travail. Quand vous avez lancé plusieurs goroutines et que vous avez seulement besoin de savoir qu’elles ont toutes terminé, utilisez sync.WaitGroup :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
	fmt.Println("all workers finished")
}

Add(1) incrémente le compteur avant que chaque goroutine démarre, Done() le décrémente (avec defer, donc il s’exécute même si la fonction panique), et Wait() bloque jusqu’à ce que le compteur atteigne zéro. Déplacez Add à l’intérieur de la goroutine et vous avez écrit une race : main peut atteindre Wait(), voir un compteur à zéro, et retourner avant qu’aucun worker ne l’ait incrémenté.

Comment select fonctionne-t-il avec plusieurs channels ?

select attend sur plusieurs opérations de channel à la fois et exécute celle qui est prête en premier. Les timeouts et l’annulation en Go reposent là-dessus.

1
2
3
4
5
6
7
8
select {
case res := <-resultCh:
	fmt.Println("got result:", res)
case err := <-errCh:
	fmt.Println("failed:", err)
case <-time.After(2 * time.Second):
	fmt.Println("timed out waiting for a result")
}

Si plus d’un case est prêt, select en choisit un au hasard, pas dans l’ordre d’écriture, donc l’ordre des case ne donne aucune priorité. Sans default et sans case prêt, select bloque ; un select{} vide bloque pour toujours. Ajoutez un case default et toute l’instruction devient non bloquante : si rien n’est prêt à cet instant, c’est default qui s’exécute.

Un worker pool combine presque tout cela. Un nombre fixe de goroutines récupère des jobs sur un channel et pousse les résultats sur un autre, si bien que le travail concurrent reste plafonné quel que soit le nombre de jobs qui arrivent.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		results <- j * j
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs)

	for i := 0; i < 9; i++ {
		fmt.Println(<-results)
	}
}

Trois workers, neuf jobs. close(jobs) après le dernier envoi permet à la boucle range de chaque worker de se terminer une fois la file vidée. Les signatures utilisent jobs <-chan int (réception seule) et results chan<- int (envoi seul). La flèche indique la direction et le compilateur la fait respecter, si bien qu’un worker qui envoie par erreur sur jobs échoue à la compilation plutôt que de mal se comporter à l’exécution.

Pourquoi les goroutines font-elles des leaks, ou se bloquent-elles ?

Leaks de goroutines. Une goroutine bloquée pour toujours sur un envoi ou une réception qui ne se produira jamais ne plante pas. Elle reste là, retenant son stack et tout ce qu’elle a capturé par closure, pendant toute la durée de vie du programme. La cause habituelle est une goroutine qui envoie un résultat sur un channel que plus personne ne lit, parce que le récepteur a déjà abandonné après un timeout. Donnez à la goroutine une sortie, généralement un context.Context sur lequel elle fait aussi un select, pour qu’elle puisse abandonner l’envoi.

Deadlock. fatal error: all goroutines are asleep - deadlock! signifie que toutes les goroutines, main compris, sont bloquées sur un channel ou un lock sans plus rien pour les débloquer. Le plus petit exemple est un envoi non bufferisé dans main sans aucune autre goroutine en cours d’exécution pour le recevoir.

Les races que les channels n’empêchent pas. Un channel synchronise le passage de relais d’une valeur. Il ne protège pas une variable que vous continuez à manipuler hors du channel. Deux goroutines qui incrémentent le même compteur capturé continuent de se faire concurrence, channel ou pas, exactement comme dans le cas des threads expliqué dans qu’est-ce qu’une race condition et comment la corriger. Go embarque un détecteur pour ça : compilez ou testez avec -race et le runtime signale les accès concurrents à la même mémoire où au moins un est une écriture.

1
2
go test -race ./...
go build -race -o worker-pool .

Il ne détecte que les races qui se produisent réellement pendant l’exécution, donc il en trouve davantage sous une charge concurrente réaliste que dans un test unitaire tranquille. Cette charge est aussi là où les tests chargés de goroutines deviennent instables, le même genre d’instabilité couvert dans tests unitaires vs tests d’intégration vs tests end-to-end. Attendez-vous à plusieurs fois plus de mémoire et de CPU avec -race activé. Exécutez-le en CI, pas dans le binaire que vous livrez.

Channel ou mutex : lequel choisir ?

Les deux rendent l’accès concurrent sûr, mais à partir d’extrémités opposées. Un sync.Mutex protège un état partagé sur place : verrouiller, lire ou écrire, déverrouiller, et la donnée ne bouge jamais. Un channel déplace la propriété d’une valeur d’une goroutine à une autre, si bien qu’une seule goroutine la touche à la fois, par construction. La recommandation de Go lui-même : utilisez les channels pour coordonner ou passer du travail entre goroutines, et un mutex pour protéger une seule donnée partagée que plusieurs goroutines mettent à jour sur place, comme un cache ou un compteur. Si une fonction a besoin des deux, elle fait probablement deux travaux qui se liraient mieux séparés.

Si vous venez de JavaScript, le modèle est différent. Un seul thread exécute votre code et await ne rend la main qu’aux points visibles dans le code source ; async/await et l’event loop de JavaScript expliqués montre où se trouvent ces points de suspension. Go vous donne un vrai parallélisme, donc le choix de la primitive vous revient.

Pour votre prochain batch job (redimensionner un dossier d’images, ou appeler une API une fois par ligne d’un CSV), partez du worker pool ci-dessus. Choisissez le nombre de workers, fermez le channel des jobs une fois l’envoi terminé, et ajoutez un context.Context au select la première fois que vous avez besoin d’annuler.

Articles associés