Focus sur les channels

Christophe Lecroq | 21 Nov 2022

Cet article est le second de la série consacrée à la concurrence en Go. Il reprend en détail l’utilisation des channels.

Pré-requis

Pour bien suivre ce tutoriel, vous aurez besoin :

  • d’une installation fonctionnelle de Golang (je travaille sur la version 1.19 mais les concepts expliqués ici existent dans les versions antérieures
  • d’une connaissance de base du langage Go. Si vous n’êtes pas encore familier avec la concurence en Go, je vous encourage à aller lire le premier article de la série.

Vous pouvez retrouver tout le code source des exemples (002_*) ici.

Les buffered channels

Bloquant vs non bloquant

En Go, un channel est bloquant par défaut. C’est à dire qu’il est nécessaire d’attribuer une valeur au channel pour pouvoir la lire et ainsi continuer l’exécution du programme. Si vous lancez ce code :

1.	c, c2 := make(chan bool), make(chan bool)
2.
3.	go func() {
4.		c <- true
5.		c2 <- false
6.	}()
7.
8.	fmt.Println(<-c2,<-c)

Vous recevrez un message du type : fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan receive]:. Lorsque le programme va s’exécuter, il écrit dans le channel c (ligne 4) puis cherche à lire cette valeur. Ici, c’est c2 qui est appelé en premier sauf que lui n’a aucune valeur. Le programme détecte alors un deadlock puisqu’il ne peut plus continuer son exécution.

Le même code en réordonnant la lecture des channels ne générera plus d’erreur.

	c, c2 := make(chan bool), make(chan bool)

	go func() {
		c <- true
		c2 <- false
	}()

	fmt.Println(<-c, <-c2)

L’exécution du code suivant retournera : true false

Il existe plusieurs manières de rendre non bloquant un channel, je ne parlerais ici que de celle concernant les channels mais sachez qu’il est possible aussi de contourner le mécanisme avec l’instruction select.

	c, c2 := make(chan bool, 1), make(chan bool, 1)

	go func() {
		c <- true
		c2 <- false
	}()

	fmt.Println(<-c2, <-c)

Dans l’extrait ci-dessus, on déclare un channel avec un buffer de 1. Les channels ayant une taille de buffer dans l’instruction make sont non bloquants, c’est à dire que le traitement va continuer même si le channel n’est pas lu directement.

Notez que j’ai replacé les paramètres de l’instruction Println dans l’ordre qui avait généré le deadlock tout à l’heure. Si vous exécuter ce code, vous aurez comme sortie false true

Pour conclure sur cette notion de blocage, il s’agit d’un mécanisme pratique pour ne pas avoir à gérer explicitement le blocage des channels. Cependant, vous aurez sans doute des cas de figure dans lesquels vous souhaitez gérer vous-même ce comportement pour couvrir votre cas d’usage.

Itérer sur un channel

Pour parcourir un buffered channel, Golang permet à l’aide de la boucle for et du mot clé range d’itérer sur les valeurs de votre channel. Vous pouvez donc organiser vos traitements et leurs résultats associés.

package main

import "fmt"

func main() {
	c := make(chan int, 2)
	go func() {
		c <- 2
		c <- 1
		close(c)
	}()

	for v := range c {
		fmt.Println(v)
	}
}

La sortie sera donc :

2
1

Selon ce que vous cherchez à réaliser, l’utilisation d’un channel avec buffer peut impliquer l’utilisation de la fonction close sur votre channel. En effet, si vous n’indiquez pas à votre programme que vous ne souhaitez plus continuer à utiliser ce channel, le programme considérera que vous devez encore écrire dedans et paniquera avec une erreur deadlock.

Cas pratique

Reprenons l’exemple de l’article précédent, Legolas et Gandalf versus le Balrog. Pour démontrer l’intéret des buffered channels, j’ai ajouté quelques contraintes :

  • Legolas a un nombre limité de flèches. Dans mon exemple, je limite la taille du buffer à 3.
  • Laisser Legolas agir en premier et Gandalf terminer en boucle jusqu’à la fin du “traitement”
  • Utiliser un channel dead pour gérer l’information de fin de traitement plutôt que de passer les channels à nul. Il s’agit d’une façon plus idomatique de faire.
package main

import (
	"fmt"
)

const BalrogHP = 20

func LegolasShootArrows(damage chan int) {
	damage <- 1
	damage <- 2
	damage <- 3
	close(damage)
}

func GandalfCastsSpell(dead chan bool, damage chan int) {
	for !<-dead {
		damage <- 5
	}
}

func DisplayBalrogHP(dead chan bool, LegolasDamage, GandalfDamage chan int) {
	var balrogHP = BalrogHP
	fmt.Print("Balrog is coming ! ")
	for balrogHP > 0 {
		if balrogHP > 0 {
			dead <- false
			fmt.Printf("Balrog HP : %d\n", balrogHP)
		} else {
			dead <- true
		}

		var incomingDamage int
		for damage := range LegolasDamage {
			fmt.Printf("Legolas shoots an arrow and deals %d damages ! \n", damage)
			incomingDamage += damage
		}

		var open bool
		if incomingDamage, open = <-GandalfDamage; open {
			fmt.Printf("Gandalf casts a spell and deals %d damages ! \n", incomingDamage)
		}

		balrogHP -= incomingDamage
	}

	fmt.Printf("Balrog is dead !")
}

func main() {
	dead, LegolasDamage, GandalfDamage := make(chan bool), make(chan int, 3), make(chan int)

	go LegolasShootArrows(LegolasDamage)
	go GandalfCastsSpell(dead, GandalfDamage)

	go DisplayBalrogHP(dead, LegolasDamage, GandalfDamage)

	var a string
	fmt.Scanln(&a)
}

La sortie sera la suivante :

Balrog is coming ! Balrog HP : 20
Legolas shoots an arrow and deals 1 damages !
Legolas shoots an arrow and deals 2 damages !
Legolas shoots an arrow and deals 3 damages !
Gandalf casts a spell and deals 5 damages ! 
Balrog HP : 15
Gandalf casts a spell and deals 5 damages ! 
Balrog HP : 10
Gandalf casts a spell and deals 5 damages ! 
Balrog HP : 5
Gandalf casts a spell and deals 5 damages ! 
Balrog is dead !

Vous noterez que le code a été agencé un peu différemment que dans la première version. Tout d’abord, pour ordonner les actions, j’ai supprimé l’instruction select pour gérer la boucle for puis la structure conditionnelle if.

Enfin, vous aurez peut-être noté la présence d’un booléen open utilisé lors de l’assignation de la valeur stockée dans le channel de Gandalf, ligne 40. Étant donné que je ne gère pas un buffered channel, ce booléen sera toujours à true. Je n’ai cependant pas le choix que de le gérer si je veux vous montrer comment récupérer une valeur d’un channel dans une condition.

Conclusion

Les channels sont les outils de base pour gérer les échanges d’informations entre vos différentes goroutines. N’hésitez pas à vous entrainez pour bien appréhender leurs principes de fonctionnement. Le prochain article de la série continuera de traiter de la concurrence mais au travers du package sync.