En savoir plus sur les canaux mis en mémoire tampon
Comme vous l’avez appris, les canaux ne sont pas mis en mémoire tampon par défaut. Cela signifie qu’ils acceptent d’envoyer l’opération uniquement s’il existe une opération de réception. Dans le cas contraire, le programme sera bloqué en attente indéfiniment.
Il peut arriver que vous ayez besoin de ce type de synchronisation entre les goroutines. Toutefois, il peut arriver que vous ayez simplement besoin d’implémenter l’accès simultané et que vous n’ayez pas besoin de restreindre la manière dont les goroutines communiquent entre elles.
Les canaux mis en mémoire tampon envoient et reçoivent des données sans bloquer le programme, car un canal mis en mémoire tampon se comporte comme une file d’attente. Vous pouvez limiter la taille de cette file d’attente lorsque vous créez le canal, comme suit :
ch := make(chan string, 10)
Chaque fois que vous envoyez un élément au canal, l’élément est ajouté à la file d’attente. Ensuite, une opération de réception supprime l’élément de la file d’attente. Lorsque le canal est plein, toute opération d’envoi attend simplement qu’il y ait de l’espace pour stocker les données. À l’inverse, si le canal est vide et qu’il y a une opération de lecture, il est bloqué jusqu’à ce qu’il y ait un texte à lire.
Voici un exemple simple pour comprendre les canaux mis en mémoire tampon :
package main
import (
"fmt"
)
func send(ch chan string, message string) {
ch <- message
}
func main() {
size := 4
ch := make(chan string, size)
send(ch, "one")
send(ch, "two")
send(ch, "three")
send(ch, "four")
fmt.Println("All data sent to the channel ...")
for i := 0; i < size; i++ {
fmt.Println(<-ch)
}
fmt.Println("Done!")
}
Quand vous exécutez le programme, vous voyez la sortie suivante :
All data sent to the channel ...
one
two
three
four
Done!
Vous pourriez dire que nous n'avons rien fait de différent ici, et vous auriez raison. Mais voyons quand cela se produit lorsque vous remplacez la variable size
par un nombre inférieur (vous pouvez même essayer avec un nombre plus élevé), comme suit :
size := 2
Lorsque vous réexécutez le programme, vous obtenez l'erreur suivante :
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.send(...)
/Users/developer/go/src/concurrency/main.go:8
main.main()
/Users/developer/go/src/concurrency/main.go:16 +0xf3
exit status 2
La raison en est que les appels à la fonction send
sont séquentiels. Vous n’êtes pas en train de créer une nouvelle goroutine. Par conséquent, il n’y a rien à mettre dans la file d'attente.
Les canaux sont profondément connectés aux goroutines. Si une autre goroutine reçoit des données du canal, le programme entier peut entrer dans un bloc de façon permanente. Comme vous l’avez vu, cela peut se produire.
Maintenant, faisons quelque chose d'intéressant ! Nous allons créer une goroutine pour les deux derniers appels (les deux premiers s’adaptent correctement dans la mémoire tampon) et créer une boucle for à exécuter quatre fois. Voici le code :
func main() {
size := 2
ch := make(chan string, size)
send(ch, "one")
send(ch, "two")
go send(ch, "three")
go send(ch, "four")
fmt.Println("All data sent to the channel ...")
for i := 0; i < 4; i++ {
fmt.Println(<-ch)
}
fmt.Println("Done!")
}
Quand vous exécutez le programme, celui-ci doit fonctionner normalement. Lorsque vous utilisez des canaux, nous vous recommandons d’utiliser toujours des goroutines.
Nous allons tester le cas où vous créez un canal mis en mémoire tampon avec plus d’éléments que ce dont vous avez besoin. Nous allons utiliser l’exemple que nous avons utilisé précédemment pour vérifier les API et créer un canal mis en mémoire tampon d’une taille de 10 :
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
start := time.Now()
apis := []string{
"https://management.azure.com",
"https://dev.azure.com",
"https://api.github.com",
"https://outlook.office.com/",
"https://api.somewhereintheinternet.com/",
"https://graph.microsoft.com",
}
ch := make(chan string, 10)
for _, api := range apis {
go checkAPI(api, ch)
}
for i := 0; i < len(apis); i++ {
fmt.Print(<-ch)
}
elapsed := time.Since(start)
fmt.Printf("Done! It took %v seconds!\n", elapsed.Seconds())
}
func checkAPI(api string, ch chan string) {
_, err := http.Get(api)
if err != nil {
ch <- fmt.Sprintf("ERROR: %s is down!\n", api)
return
}
ch <- fmt.Sprintf("SUCCESS: %s is up and running!\n", api)
}
Quand vous exécutez le programme, vous obtenez la même sortie que précédemment. Vous pouvez tester en modifiant la taille du canal avec des nombres inférieurs ou supérieurs, et le programme continuera de fonctionner.
Canaux non mis en mémoire tampon et canaux mis en mémoire tampon
À ce stade, vous vous demandez peut-être quand utiliser un type ou un autre. Tout dépend de la façon dont vous souhaitez que la communication se fasse entre les goroutines. Les canaux non mis en mémoire tampon communiquent de façon synchrone. Ils garantissent que chaque fois que vous envoyez des données, le programme est bloqué jusqu’à ce que quelqu’un lise le contenu du canal.
À l’inverse, les canaux mis en mémoire tampon découplent les opérations d’envoi et de réception. Ils ne bloquent pas un programme, mais vous devez être prudent, car vous risquez de provoquer un blocage (comme vous l’avez vu précédemment). Lorsque vous utilisez des canaux non mis en mémoire tampon, vous pouvez contrôler le nombre de goroutines pouvant être exécutées simultanément. Par exemple, vous pouvez effectuer des appels à une API et contrôler le nombre d’appels que vous effectuez chaque seconde. Dans le cas contraire, vous risquez d’être bloqué.
Directions du canal
Les canaux dans Go ont une autre fonctionnalité intéressante. Quand vous utilisez des canaux comme paramètres d’une fonction, vous pouvez spécifier si un canal est destiné à envoyer ou à recevoir des données. À mesure que votre programme se développe, vous pouvez avoir trop de fonctions et il est judicieux de documenter l’intention de chaque canal afin de les utiliser correctement. Ou peut-être vous écrivez une bibliothèque et exposez un canal en lecture seule pour maintenir la cohérence des données.
Pour définir la direction du canal, vous pouvez le faire de la même façon que lorsque vous lisez ou recevez des données. Toutefois, vous le faites lorsque vous déclarez le canal dans un paramètre de fonction. La syntaxe permettant de définir le type de canal comme paramètre dans une fonction est :
chan<- int // it's a channel to only send data
<-chan int // it's a channel to only receive data
Lorsque vous envoyez des données via un canal en réception seule, vous obtenez une erreur lors de la compilation du programme.
Nous allons utiliser le programme suivant comme exemple à deux fonctions : une qui lit les données et une autre qui les envoie :
package main
import "fmt"
func send(ch chan<- string, message string) {
fmt.Printf("Sending: %#v\n", message)
ch <- message
}
func read(ch <-chan string) {
fmt.Printf("Receiving: %#v\n", <-ch)
}
func main() {
ch := make(chan string, 1)
send(ch, "Hello World!")
read(ch)
}
Quand vous exécutez le programme, vous voyez la sortie suivante :
Sending: "Hello World!"
Receiving: "Hello World!"
Le programme clarifie l’intention de chaque canal dans chaque fonction. Si vous essayez d’utiliser un canal pour envoyer des données dans un canal dont l’objectif est uniquement de recevoir des données, vous obtiendrez une erreur de compilation. Par exemple, essayez d’effectuer une opération semblable à la suivante :
func read(ch <-chan string) {
fmt.Printf("Receiving: %#v\n", <-ch)
ch <- "Bye!"
}
Quand vous exécutez le programme, vous obtenez l’erreur suivante :
# command-line-arguments
./main.go:12:5: invalid operation: ch <- "Bye!" (send to receive-only type <-chan string)
Il est préférable d’avoir une erreur de compilation qu’un canal mal intentionné.
Multiplexage
Enfin, nous allons voir comment interagir avec plusieurs canaux simultanément en utilisant le mot clé select
. Dans certains cas, vous souhaiterez attendre qu’un événement se produise lorsque vous utilisez plusieurs canaux. Par exemple, vous pouvez inclure une logique pour annuler une opération en cas d’anomalie dans les données traitées par votre programme.
Une instruction select
fonctionne comme une instruction switch
, mais pour les canaux. Elle bloque l’exécution du programme jusqu’à ce qu’il reçoive un événement à traiter. Si elle reçoit plusieurs événements, elle en choisit un au hasard.
L’un des aspects essentiels de l’instruction select
est qu’elle termine son exécution après le traitement d’un événement. Si vous souhaitez attendre que d’autres événements se produisent, vous devrez peut-être utiliser une boucle.
Nous allons utiliser le programme suivant pour voir select
en action :
package main
import (
"fmt"
"time"
)
func process(ch chan string) {
time.Sleep(3 * time.Second)
ch <- "Done processing!"
}
func replicate(ch chan string) {
time.Sleep(1 * time.Second)
ch <- "Done replicating!"
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go process(ch1)
go replicate(ch2)
for i := 0; i < 2; i++ {
select {
case process := <-ch1:
fmt.Println(process)
case replicate := <-ch2:
fmt.Println(replicate)
}
}
}
Quand vous exécutez le programme, vous voyez la sortie suivante :
Done replicating!
Done processing!
Notez que la fonction replicate
s’est terminée en premier : c’est pourquoi vous voyez sa sortie en premier dans le terminal. La fonction principale a une boucle, car l’instruction select
se termine dès qu’elle reçoit un événement, mais nous attendons toujours la fin de la fonction process
.