
Synchronisation avec les channels
Maîtrisez la synchronisation de goroutines avec les channels en Go : rendezvous, signaux, barrières, contrôle de flux et patterns avancés pour des applications concurrentes robustes.
Introduction à la synchronisation avec les channels : Coordonner l'activité concurrente
Si la communication de données est un aspect fondamental des channels en Go, la synchronisation est un autre pilier essentiel de leur utilisation. Les channels ne servent pas seulement à échanger des informations entre les goroutines, mais aussi à coordonner leur activité, à contrôler le flux d'exécution concurrent, et à s'assurer que certaines opérations sont exécutées dans un ordre précis ou au bon moment.
La synchronisation avec les channels repose sur le comportement bloquant des opérations d'envoi et de réception sur les channels unbuffered (non bufferisés). Ce blocage inhérent aux channels permet de créer des points de synchronisation entre les goroutines, de s'assurer qu'une goroutine attend qu'une autre ait terminé une certaine tâche avant de poursuivre, ou de mettre en place des mécanismes de rendezvous et de contrôle de flux.
Ce chapitre explore en profondeur les techniques de synchronisation avec les channels en Go. Nous allons examiner comment les channels unbuffered peuvent être utilisés pour réaliser la synchronisation de base (rendezvous) entre deux goroutines, comment implémenter des signaux et des événements concurrents avec les channels, comment créer des barrières de synchronisation pour coordonner un groupe de goroutines, et comment utiliser les channels pour contrôler le flux d'exécution et orchestrer des patterns de concurrence plus complexes. L'objectif est de vous fournir une boîte à outils complète pour maîtriser la synchronisation avec les channels et construire des applications Go concurrentes robustes, cohérentes et bien coordonnées.
Synchronisation de base : Rendezvous avec les channels unbuffered
La forme la plus fondamentale de synchronisation avec les channels est le rendezvous, qui permet de synchroniser deux goroutines à un point précis de leur exécution. Les channels unbuffered (non bufferisés) sont parfaitement adaptés pour réaliser le rendezvous, grâce à leur comportement bloquant inhérent.
Principe du Rendezvous avec les channels unbuffered :
Le rendezvous avec les channels unbuffered repose sur le fait que l'envoi et la réception sur un channel unbuffered sont des opérations synchrones et bloquantes. Pour réaliser un rendezvous entre deux goroutines, vous créez un channel unbuffered et vous utilisez ce channel pour orchestrer la synchronisation :
- Goroutine 1 (émettrice) : Exécute sa tâche principale, puis envoie un signal (une valeur quelconque, souvent une valeur nulle comme
struct{}{}oubool) sur le channel unbuffered pour indiquer qu'elle a atteint le point de rendezvous. L'opération d'envoi bloque la goroutine 1 jusqu'à ce que la goroutine 2 reçoive le signal du channel. - Goroutine 2 (réceptrice) : Exécute sa tâche principale, puis attend de recevoir un signal depuis le channel unbuffered (
<-canal). L'opération de réception bloque la goroutine 2 jusqu'à ce que la goroutine 1 envoie un signal sur le channel.
Lorsque l'envoi et la réception se produisent simultanément, les deux goroutines sont débloquées et peuvent continuer leur exécution après le point de rendezvous. Le channel unbuffered agit comme un point de synchronisation, garantissant que les deux goroutines atteignent ce point avant de poursuivre.
Exemple de Rendezvous avec channels unbuffered :
package main
import (
"fmt"
"time"
)
func goroutine1(canalSynchro chan struct{}) {
fmt.Println("Goroutine 1 : Début du travail...")
time.Sleep(2 * time.Second) // Simuler un travail
fmt.Println("Goroutine 1 : Travail terminé, envoi du signal de rendezvous...")
canalSynchro <- struct{}{} // Envoi d'un signal vide sur le channel (rendezvous)
fmt.Println("Goroutine 1 : Après le rendezvous.") // Cette ligne s'affiche APRES que goroutine2 ait atteint le rendezvous
}
func goroutine2(canalSynchro chan struct{}) {
fmt.Println("Goroutine 2 : Début du travail...")
time.Sleep(1 * time.Second) // Simuler un travail plus court
fmt.Println("Goroutine 2 : Travail presque terminé, attente du rendezvous...")
<-canalSynchro // Attente du signal de rendezvous depuis le channel (bloquant)
fmt.Println("Goroutine 2 : Rendezvous atteint, reprise du travail...") // Cette ligne s'affiche APRES que goroutine1 ait atteint le rendezvous
time.Sleep(1 * time.Second) // Simuler la suite du travail
fmt.Println("Goroutine 2 : Travail terminé.")
}
func main() {
canalSynchro := make(chan struct{}) // Création d'un channel unbuffered pour la synchronisation
go goroutine1(canalSynchro)
go goroutine2(canalSynchro)
fmt.Println("Programme principal : Lancement des goroutines et attente...")
time.Sleep(5 * time.Second) // Attendre suffisamment longtemps pour observer l'exécution des goroutines
fmt.Println("Programme principal : Fin du programme.")
}
Dans cet exemple :
- Un channel unbuffered
canalSynchrode typestruct{}(channel de signal) est créé. goroutine1etgoroutine2partagent ce channelcanalSynchro.goroutine1, après avoir terminé une partie de son travail, envoie un signal videstruct{}{}surcanalSynchro(canalSynchro <- struct{}{}) et se bloque jusqu'à ce quegoroutine2reçoive ce signal.goroutine2, après avoir terminé une partie de son travail, attend de recevoir un signal depuiscanalSynchro(<-canalSynchro) et se bloque jusqu'à ce quegoroutine1envoie le signal.- Le channel
canalSynchroréalise un rendezvous entregoroutine1etgoroutine2: les deux goroutines se synchronisent à ce point, et ne poursuivent leur exécution qu'après que les deux aient atteint le point de rendezvous. Vous pouvez observer dans la sortie que "Goroutine 1 : Après le rendezvous." et "Goroutine 2 : Rendezvous atteint, reprise du travail..." s'affichent presque simultanément, après que les deux goroutines aient atteint le point de rendezvous.
Le rendezvous avec les channels unbuffered est un pattern de synchronisation de base et essentiel en Go, permettant de coordonner précisément l'exécution de deux goroutines.
Channels comme signaux : Déclenchement d'événements et notifications
Les channels peuvent également être utilisés comme de simples signaux entre les goroutines, pour déclencher des événements ou envoyer des notifications. Dans ce cas d'utilisation, la valeur transportée par le channel n'est pas importante en soi, c'est la présence du signal (la réception d'une valeur sur le channel) qui compte.
Utilisation de channels comme signaux :
Pour utiliser un channel comme signal, vous pouvez créer un channel de type chan struct{} (channel de signal, qui ne transporte aucune donnée utile). Le type struct{} (struct vide) est utilisé car il ne consomme pas de mémoire et est efficace pour les signaux.
Patterns de signalisation avec channels :
- Signal de démarrage : Une goroutine peut attendre un signal sur un channel avant de démarrer son travail. Une autre goroutine (généralement la goroutine principale) envoie un signal sur le channel pour déclencher le démarrage de la goroutine worker.
- Signal de fin de tâche : Une goroutine worker peut envoyer un signal sur un channel lorsqu'elle a terminé son travail, pour notifier la goroutine appelante de sa terminaison. C'est une alternative à
sync.WaitGrouppour la synchronisation de la terminaison de goroutines dans certains cas. - Signal d'événement : Un channel peut être utilisé pour diffuser un signal d'événement à plusieurs goroutines. Une goroutine émettrice envoie un signal sur le channel, et plusieurs goroutines réceptrices peuvent recevoir ce signal et réagir à l'événement.
- Signal d'annulation (avec
context.Context) : Le packagecontextutilise un channelDone()pour propager les signaux d'annulation aux goroutines. Le channelctx.Done()est fermé pour signaler l'annulation du contexte.
Exemple de channel comme signal de démarrage :
package main
import (
"fmt"
"time"
)
func workerAttenteDemarrage(id int, demarrage chan struct{}) {
fmt.Printf("Worker %d : En attente du signal de démarrage...\n", id)
<-demarrage // Attente bloquante du signal de démarrage sur le channel 'demarrage'
fmt.Printf("Worker %d : Signal de démarrage reçu, début du travail...\n", id)
time.Sleep(2 * time.Second) // Simuler un travail
fmt.Printf("Worker %d : Travail terminé.\n", id)
}
func main() {
demarrageChan := make(chan struct{}) // Channel de signal de démarrage
// Lancement de 3 goroutines worker en attente du signal de démarrage
for i := 1; i <= 3; i++ {
go workerAttenteDemarrage(i, demarrageChan)
}
fmt.Println("Programme principal : Workers en attente de démarrage.")
time.Sleep(3 * time.Second) // Laisser les workers en attente pendant 3 secondes
fmt.Println("Programme principal : Envoi du signal de démarrage à tous les workers...")
close(demarrageChan) // Fermeture du channel 'demarrageChan' pour envoyer le signal de démarrage à tous les workers
time.Sleep(1 * time.Second) // Laisser le temps aux workers de démarrer et de terminer
fmt.Println("Programme principal : Fin du programme.")
}
Dans cet exemple :
- Un channel
demarrageChande typechan struct{}est créé pour servir de signal de démarrage. - Chaque goroutine
workerAttenteDemarrageattend bloquante la réception d'un signal surdemarrageChan(<-demarrage). - La fonction
main, après un délai de 3 secondes, ferme le channeldemarrageChan(close(demarrageChan)). La fermeture du channeldemarrageChansert de signal de démarrage pour toutes les goroutines worker en attente. - Lorsque
demarrageChanest fermé, toutes les goroutinesworkerAttenteDemarragesont débloquées de leur opération de réception surdemarrageChan, et commencent leur travail.
L'utilisation de channels comme signaux est un pattern courant et flexible en Go pour déclencher des actions, coordonner des événements et contrôler le flux d'exécution concurrent.
Channels comme barrières : Synchronisation de groupes de goroutines
Les channels peuvent être utilisés pour implémenter des barrières de synchronisation (barriers), permettant de synchroniser un groupe de goroutines à un point précis de leur exécution, de manière similaire à sync.WaitGroup, mais avec plus de flexibilité et de contrôle.
Principe des barrières de synchronisation avec channels :
Pour implémenter une barrière de synchronisation avec des channels, vous pouvez utiliser un channel pour compter le nombre de goroutines qui ont atteint le point de synchronisation. Le channel sert de "point de rassemblement" :
- Création d'un channel compteur : Créez un channel buffered dont la capacité est égale au nombre de goroutines à synchroniser. Ce channel servira de compteur pour la barrière.
- Signalement de l'arrivée à la barrière : Chaque goroutine, lorsqu'elle atteint le point de synchronisation, envoie une valeur (signal) sur le channel compteur. L'opération d'envoi sur un channel buffered ne bloque pas immédiatement (tant que le buffer n'est pas plein).
- Attente que toutes les goroutines atteignent la barrière : Une goroutine coordinatrice (généralement la goroutine principale) reçoit
Nvaleurs (oùNest le nombre de goroutines à synchroniser) depuis le channel compteur. L'opération de réception bloque jusqu'à ce queNvaleurs aient été reçues, ce qui signifie que toutes les goroutines ont atteint la barrière et ont envoyé leur signal.
Exemple de barrière de synchronisation avec channels :
package main
import (
"fmt"
"sync"
"time"
)
func workerBarriere(id int, barriere chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d : Début du travail...\n", id)
time.Sleep(time.Duration(id) * time.Second) // Simuler un travail
fmt.Printf("Worker %d : Travail terminé, signalement à la barrière...\n", id)
barriere <- struct{}{} // Envoi d'un signal à la barrière (non-bloquant car channel buffered)
fmt.Printf("Worker %d : Après le signal à la barrière.\n", id) // Cette ligne s'affiche AVANT que le programme principal ne passe la barrière
}
func main() {
nombreWorkers := 3
barriereChan := make(chan struct{}, nombreWorkers) // Channel buffered de capacité 'nombreWorkers' (barrière)
var wg sync.WaitGroup
wg.Add(nombreWorkers)
// Lancement des goroutines workerBarriere
for i := 1; i <= nombreWorkers; i++ {
go workerBarriere(i, barriereChan, &wg)
}
fmt.Println("Programme principal : Attente que tous les workers atteignent la barrière...")
// Attente de réception de 'nombreWorkers' signaux depuis le channel 'barriereChan'
for i := 0; i < nombreWorkers; i++ {
<-barriereChan // Réception bloquante : attend qu'un worker envoie un signal à la barrière
}
fmt.Println("Programme principal : Barrière franchie, tous les workers sont arrivés.") // Atteint après que tous les workers aient signalé leur arrivée
wg.Wait() // Attendre la terminaison effective de toutes les goroutines (sécurité)
fmt.Println("Programme principal : Fin du programme.")
}
Dans cet exemple :
- Un channel buffered
barriereChande capaciténombreWorkers(3) est créé pour servir de barrière de synchronisation. - Chaque goroutine
workerBarriere, lorsqu'elle atteint le point de synchronisation, envoie un signal videstruct{}{}surbarriereChan(barriereChan <- struct{}{}). L'envoi sur un channel buffered est non-bloquant, donc les workers continuent leur exécution après avoir envoyé le signal. - La fonction
mainattend de recevoirnombreWorkerssignaux depuisbarriereChan(bouclefor i := 0; i < nombreWorkers; i++ { <-barriereChan }). L'opération de réception est bloquante, donc le programme principal attend que les 3 workers aient envoyé leur signal à la barrière. - Une fois que le programme principal a reçu les 3 signaux, il considère que la barrière est franchie et continue son exécution après la boucle
for.
Les channels buffered permettent d'implémenter des barrières de synchronisation pour coordonner l'activité de groupes de goroutines, offrant une alternative flexible à sync.WaitGroup pour certains scénarios de synchronisation plus complexes.