Primitives de Canal en Golang

GolangBeginner
Pratiquer maintenant

Introduction

L'une des fonctionnalités les plus attrayantes du langage Go est son support natif pour la programmation concurrente. Il offre un support solide pour le parallélisme, la programmation concurrente et la communication réseau, permettant une utilisation plus efficace des processeurs multi-cœurs.

Contrairement à d'autres langages qui atteignent la concurrence via la mémoire partagée, Go réalise la concurrence en utilisant des canaux (channels) pour la communication entre différentes goroutines.

Les goroutines sont les unités de concurrence dans Go et peuvent être considérées comme des threads légers. Elles consomment moins de ressources lors des changements entre elles par rapport aux threads traditionnels.

Les canaux et les goroutines constituent ensemble les primitives de concurrence dans Go. Dans cette section, nous allons découvrir les canaux, cette nouvelle structure de données.

Points de connaissance :

  • Types de canaux
  • Création de canaux
  • Opérations sur les canaux
  • Blocage des canaux
  • Canaux unidirectionnels

Aperçu des Canaux

Les canaux (channels) sont une structure de données spéciale principalement utilisée pour la communication entre les goroutines.

Contrairement à d'autres modèles de concurrence qui utilisent des mutex et des fonctions atomiques pour un accès sécurisé aux ressources, les canaux permettent la synchronisation en envoyant et en recevant des données entre plusieurs goroutines.

Dans Go, un canal est comme un tapis roulant ou une file d'attente qui suit la règle Premier Entré, Premier Sorti (FIFO - First-In-First-Out).

Types et Déclaration de Canaux

Un canal est un type de référence, et son format de déclaration est le suivant :

var channelName chan elementType

Chaque canal possède un type spécifique et ne peut transporter que des données du même type. Voyons quelques exemples :

Créez un fichier nommé channel.go dans le répertoire ~/project :

cd ~/project
touch channel.go

Par exemple, déclarons un canal de type int :

var ch chan int

Le code ci-dessus déclare un canal de type int nommé ch.

En plus de cela, il existe de nombreux autres types de canaux couramment utilisés.

Dans channel.go, écrivez le code suivant :

package main

import "fmt"

func main() {
    var ch1 chan int   // Déclare un canal d'entiers
    var ch2 chan bool  // Déclare un canal booléen
    var ch3 chan []int // Déclare un canal qui transporte des slices d'entiers
    fmt.Println(ch1, ch2, ch3)
}

Le code ci-dessus déclare simplement trois types de canaux différents.

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est la suivante :

<nil> <nil> <nil>

Initialisation du Canal

Semblables aux maps, les canaux doivent être initialisés après leur déclaration avant de pouvoir être utilisés.

La syntaxe pour initialiser un canal est la suivante :

make(chan elementType)

Dans channel.go, écrivez le code suivant :

package main

import "fmt"

func main() {
    // Canal qui stocke des données de type entier (int)
    ch1 := make(chan int)

    // Canal qui stocke des données booléennes (bool)
    ch2 := make(chan bool)

    // Canal qui stocke des données de type slice d'entiers ([]int)
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est affichée ci-dessous :

0xc0000b4000 0xc0000b4040 0xc0000b4080

Dans le code ci-dessus, trois types de canaux différents sont définis et initialisés.

En examinant la sortie, nous pouvons voir que, contrairement aux maps, l'impression directe du canal lui-même ne révèle pas son contenu.

L'impression directe d'un canal ne donne que l'adresse mémoire du canal lui-même. C'est parce que, comme un pointeur, un canal n'est qu'une référence à une adresse mémoire.

Alors, comment pouvons-nous accéder aux valeurs contenues dans un canal et les manipuler ?

Opérations sur les Canaux

Les opérations sur les canaux sont centrées sur l'envoi et la réception de données, ce qui se fait à l'aide de l'opérateur <-. Pour voir les canaux en action, nous devons les utiliser avec des goroutines, les threads légers de Go pour la concurrence.

Un canal connecte une goroutine émettrice et une goroutine réceptrice. Si l'une n'est pas prête, l'autre sera bloquée. Voyons un exemple complet.

Dans channel.go, écrivez le code suivant :

package main

import (
    "fmt"
    "time"
)

func main() {
    // Crée un canal non tamponné (unbuffered channel)
    ch := make(chan int)

    // Démarre une nouvelle goroutine en utilisant une fonction anonyme
    go func() {
        fmt.Println("Goroutine commence l'envoi...")
        // Envoie la valeur 10 au canal
        ch <- 10
        fmt.Println("Goroutine a terminé l'envoi.")
    }()

    fmt.Println("Main attend les données...")
    // Reçoit la valeur du canal
    value := <-ch
    fmt.Printf("Main a reçu : %d\n", value)

    // Donne du temps à la goroutine pour afficher son message final
    time.Sleep(time.Second)
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme sera similaire à ceci (l'ordre peut légèrement varier) :

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

Dans cet exemple :

  1. ch := make(chan int) crée un canal non tamponné. Les canaux non tamponnés exigent que l'émetteur et le récepteur soient prêts simultanément pour que la communication ait lieu. C'est ce qu'on appelle la synchronisation.
  2. go func() { ... }() démarre une nouvelle goroutine. Le mot-clé go exécute la fonction concurremment avec la fonction main.
  3. À l'intérieur de la goroutine, ch <- 10 envoie la valeur 10 au canal. Cette opération sera bloquée jusqu'à ce que la goroutine main soit prête à recevoir.
  4. Dans la fonction main, value := <-ch reçoit la valeur. Cette opération est bloquée jusqu'à ce que la goroutine envoie une valeur.
  5. Une fois que les deux sont prêts, la valeur est transférée, et les deux goroutines se débloquent et continuent leur exécution. Nous avons ajouté time.Sleep pour nous assurer que le programme ne se termine pas avant que la goroutine puisse afficher son message final.

Fermeture du Canal

Lorsqu'aucune autre valeur ne sera envoyée sur un canal, l'émetteur peut le fermer en utilisant la fonction close(). Une manière courante de lire toutes les valeurs d'un canal jusqu'à sa fermeture est d'utiliser une boucle for range.

Modifions channel.go :

package main

import "fmt"

func main() {
    // Un canal tamponné (buffered channel) pour contenir plusieurs valeurs
    ch := make(chan int, 3)

    // Démarre une goroutine pour envoyer des données
    go func() {
        fmt.Println("Goroutine envoie 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Ferme le canal après l'envoi de toutes les valeurs
        close(ch)
        fmt.Println("Goroutine a fermé le canal.")
    }()

    fmt.Println("Main reçoit les données...")
    // Utilise une boucle for-range pour recevoir les valeurs jusqu'à ce que le canal soit fermé
    for value := range ch {
        fmt.Printf("Main a reçu : %d\n", value)
    }
    fmt.Println("Main a terminé la réception.")
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est la suivante (l'ordre peut légèrement varier) :

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

Ceci démontre un modèle courant : la boucle for range gère automatiquement la vérification si le canal est fermé et s'arrête lorsqu'il l'est.

Lorsque vous travaillez avec des canaux fermés, rappelez-vous ces règles clés :

  • Envoyer sur un canal fermé provoquera une erreur d'exécution (un panic).
  • Recevoir d'un canal fermé et vide retournera immédiatement la valeur zéro du type du canal.
  • Fermer un canal déjà fermé provoquera un panic.
  • En général, seul l'émetteur doit fermer un canal, jamais le récepteur.

Blocage de Canal

Comme nous l'avons vu à l'étape précédente, les opérations sur les canaux peuvent bloquer. C'est une caractéristique clé qui permet aux goroutines de se synchroniser. Examinons cela de plus près.

Canaux Non Tamponnés (Unbuffered Channels)

Les canaux créés avec make(chan T) sont non tamponnés.

ch1 := make(chan int) // Canal non tamponné

Un canal non tamponné n'a aucune capacité. Une opération d'envoi sur un canal non tamponné bloque la goroutine émettrice jusqu'à ce qu'une autre goroutine soit prête à recevoir. Inversement, une opération de réception bloque la goroutine réceptrice jusqu'à ce qu'une autre goroutine soit prête à envoyer. Cela crée un point de synchronisation fort, comme nous l'avons vu dans notre premier exemple à l'étape précédente.

Canaux Tamponnés (Buffered Channels)

Les canaux peuvent également être tamponnés, ce qui signifie qu'ils ont une capacité pour contenir un certain nombre de valeurs avant de bloquer.

ch2 := make(chan int, 5) // Canal tamponné avec une capacité de 5

Avec un canal tamponné :

  • Une opération d'envoi ne bloque que lorsque le tampon est plein.
  • Une opération de réception ne bloque que lorsque le tampon est vide.

Si le tampon n'est pas plein, un émetteur peut envoyer une valeur sans qu'un récepteur soit immédiatement prêt. La valeur sera stockée dans la file d'attente du canal.

Exemple de Blocage Mutuel (Deadlock)

Que se passe-t-il si nous dépassons la capacité d'un canal ? Le programme aboutira à un blocage mutuel (deadlock). Un deadlock se produit lorsque toutes les goroutines d'un programme sont bloquées, attendant quelque chose qui n'arrivera jamais.

Créez un fichier nommé channel1.go :

cd ~/project
touch channel1.go

Écrivez le code suivant, qui tente d'envoyer deux valeurs à un canal d'une capacité de un, le tout au sein de la goroutine principale :

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // Ceci réussit
    fmt.Println("Sent 10 to channel")
    ch <- 20 // Ceci bloque indéfiniment car le canal est plein
    fmt.Println("succeed")
}

Exécutez le programme en utilisant la commande suivante :

go run channel1.go

La sortie du programme est la suivante :

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

Le programme panique avec une erreur de deadlock. La goroutine main envoie 10, ce qui remplit le tampon. Elle tente ensuite d'envoyer 20 mais se bloque car le tampon est plein. Puisqu'aucune autre goroutine ne recevra jamais du canal, la goroutine main restera bloquée pour toujours, ce qui amène le runtime Go à détecter un deadlock.

Pour corriger cela, l'opération doit être coordonnée avec une autre goroutine capable de recevoir la valeur et de libérer de l'espace dans le tampon.

Canaux Unidirectionnels

Par défaut, un canal est bidirectionnel et peut être lu ou écrit. Parfois, nous devons restreindre l'utilisation d'un canal, par exemple, nous voulons nous assurer qu'un canal ne peut qu'être écrit ou seulement lu dans une fonction. Comment pouvons-nous y parvenir ?

Nous pouvons déclarer explicitement un canal avec une utilisation restreinte avant de l'initialiser.

Canal en Écriture Seule (Write-only Channel)

Un canal en écriture seule signifie que, dans une fonction donnée, le canal ne peut être utilisé que pour l'écriture et ne peut pas être lu. Déclarons et utilisons un canal en écriture seule de type int :

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est la suivante :

Data written to channel
Data received: 10

Dans cet exemple, la fonction writeOnly restreint le canal à la seule écriture.

Canal en Lecture Seule (Read-only Channel)

Un canal en lecture seule signifie que, dans une fonction donnée, le canal ne peut être utilisé que pour la lecture et ne peut pas être écrit. Déclarons et utilisons un canal en lecture seule de type int :

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est la suivante :

Data received from channel: 20

Dans cet exemple, la fonction readOnly restreint le canal à la seule lecture.

Combinaison de Canaux en Écriture Seule et en Lecture Seule

Vous pouvez combiner des canaux en écriture seule et en lecture seule pour démontrer comment les données circulent à travers des canaux restreints :

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

Exécutez le programme en utilisant la commande suivante :

go run channel.go

La sortie du programme est la suivante :

Data written to channel
Data received from channel: 30

Cet exemple illustre comment un canal en écriture seule peut transmettre des données à un canal en lecture seule.

Résumé

Dans ce laboratoire, nous avons appris les fondamentaux des canaux (channels), qui comprennent :

  • Les types de canaux et comment les déclarer
  • Les méthodes pour initialiser les canaux
  • Les opérations sur les canaux
  • Le concept de blocage des canaux (channel blocking)
  • La déclaration des canaux unidirectionnels