Article original : How to Handle Concurrency with Goroutines and Channels in Go
La concurrence est la capacité d'un programme à effectuer plusieurs tâches simultanément. C'est un aspect crucial pour construire des systèmes évolutifs et réactifs.
Le modèle de concurrence de Go est basé sur le concept de goroutines, des threads légers qui peuvent exécuter plusieurs fonctions de manière concurrente, et des channels, un mécanisme de communication intégré pour un échange de données sûr et efficace entre les goroutines.
Les fonctionnalités de concurrence de Go permettent aux développeurs d'écrire des programmes qui peuvent :
- Gérer plusieurs requêtes simultanément, améliorant la réactivité et le débit.
- Utiliser efficacement les processeurs multi-cœurs, maximisant les ressources du système.
- Écrire du code concurrent qui est sûr, efficace et facile à maintenir.
Le modèle de concurrence de Go est conçu pour minimiser les frais généraux, réduire la latence et prévenir les erreurs de concurrence courantes comme les conditions de course et les interblocages.
Avec Go, les développeurs peuvent construire des systèmes haute performance, évolutifs et concurrents avec facilité, ce qui en fait un choix idéal pour construire des systèmes distribués modernes, des réseaux et des infrastructures cloud.
Table des matières
- Étude de cas : Un guichet de banque
- Traitement séquentiel
- Concurrence
- Qu'est-ce que les Goroutines et les Channels ?
- Qu'est-ce qu'une Goroutine ?
- Comment implémenter une Goroutine
- Comment fonctionne une Goroutine ?
- Qu'est-ce que les WaitGroups ?
- Qu'est-ce que les Channels ?
- Comment écrire des données dans un Channel
- Comment lire des données depuis un Channel
- Comment implémenter des Channels avec Goroutine
- Qu'est-ce que les tampons de Channel ?
- Qu'est-ce qu'un Channel non tamponné ?
- Comment créer un Channel tamponné
- Qu'est-ce que les directions de Channel ?
- Comment gérer plusieurs opérations de communication avec Channel Select
- Comment mettre en timeout des processus longs dans un Channel
- Comment fermer un Channel
- Comment itérer sur les messages d'un Channel
- Conclusion
Considérons un scénario pour illustrer la concurrence :
Étude de cas : Un guichet de banque
Imaginez une banque occupée avec deux guichetiers, Maria et David. Les clients arrivent à la banque pour effectuer diverses transactions comme des dépôts, des retraits et des transferts. L'objectif est de servir les clients rapidement et efficacement.
Traitement séquentiel (aucune concurrence)
Maria et David travaillent de manière séquentielle, un à la fois. Lorsqu'un client arrive, Maria aide le client, et David attend que Maria ait terminé avant d'aider le client suivant. Cela entraîne un long temps d'attente pour les clients.
Concurrence
Maria et David travaillent de manière concurrente, servant les clients simultanément. Lorsqu'un client arrive, Maria aide le client avec une transaction, et David aide simultanément un autre client avec une transaction différente. Ils travaillent ensemble, partageant des ressources comme la base de données de la banque et les réserves de liquidités, pour servir plusieurs clients en même temps.
Dans ce scénario, la concurrence permet à Maria et David de travailler ensemble efficacement, servant plusieurs clients simultanément, et améliorant l'expérience globale des clients. Ce même concept s'applique à la programmation informatique, où la concurrence permet à plusieurs tâches de s'exécuter simultanément, améliorant la réactivité, l'efficacité et les performances.
Qu'est-ce que les Goroutines et les Channels ?
Une goroutine est un thread léger géré par le runtime Go. C'est une fonction qui s'exécute sur le runtime Go. Elle aide à répondre aux exigences de concurrence et de flux asynchrone.
Les goroutines vous permettent de démarrer et d'exécuter d'autres threads d'exécution de manière concurrente dans votre programme.
Les channels sont utilisés pour communiquer entre les goroutines. C'est un conduit typé à travers lequel vous pouvez envoyer et recevoir des valeurs avec l'opérateur de channel : <-.
Comment implémenter une Goroutine
Pour utiliser et implémenter une goroutine, le mot-clé go est utilisé pour précéder une fonction.
package main
import (
"fmt"
"math/rand"
"time"
)
func pause() {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
func sendMsg(msg string) {
pause()
fmt.Println(msg)
}
func main() {
sendMsg("hello") // sync
go sendMsg("test1") // async
go sendMsg("test2") // async
go sendMsg("test3") // async
sendMsg("main") // sync
time.Sleep(2 * time.Second)
}
Dans l'exemple ci-dessus,
- La fonction
sendMsgest appelée de manière synchrone et asynchrone. - La fonction
sendMsgest appelée de manière synchrone lorsque la fonctionsendMsgest appelée sans le mot-clégo. - La fonction
sendMsgest appelée de manière asynchrone lorsque la fonctionsendMsgest appelée avec le mot-clégo.
Comment fonctionne une Goroutine ?
Lorsque la fonction sendMsg est appelée avec le mot-clé go, la fonction main n'attendra pas que la fonction sendMsg termine son exécution avant de continuer à la ligne de code suivante et retournera immédiatement après que la fonction sendMsg soit appelée.
Sinon, la fonction est appelée de manière synchrone, et la fonction main attendra que la fonction sendMsg termine son exécution avant de continuer à la ligne de code suivante.
L'ordre de la sortie lorsque vous exécutez l'exemple ci-dessus différera de l'ordre du code car les trois goroutine s'exécutent toutes de manière concurrente et puisque les fonctions font une pause pendant une période de temps, l'ordre dans lequel elles se réveillent différera et sera sorti.
Le time.Sleep(2 * time.Second) est une méthode rapide et simple utilisée pour garder la fonction principale en cours d'exécution pendant 2 secondes pour permettre à la goroutine de terminer son exécution avant que la fonction principale ne se termine. Sinon, la fonction principale se terminera immédiatement après que la goroutine soit appelée et la goroutine n'aura pas assez de temps pour terminer son exécution, ce qui entraînera des erreurs.
Qu'est-ce que les WaitGroups ?
Contrairement au time.Sleep(2 * time.Second) utilisé dans l'exemple ci-dessus, les WaitGroups sont plus standard pour attendre qu'une collection de goroutines termine son exécution. C'est un moyen simple de synchroniser plusieurs goroutines.
Une goroutine peut également être déclarée avec des fonctions anonymes
package main
import (
"fmt"
"sync"
"time"
"math/rand"
)
func pause() {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
func sendMsg(msg string, wg *sync.WaitGroup) {
defer wg.Done()
pause()
fmt.Println(msg)
}
func main() {
var wg sync.WaitGroup
wg.Add(3)
go func(msg string) {
defer wg.Done()
pause()
fmt.Println(msg)
}("test1")
go sendMsg("test2", &wg)
go sendMsg("test3", &wg)
wg.Wait()
}
Dans l'exemple ci-dessus, le sync.WaitGroup est utilisé pour attendre que les trois goroutine terminent leur exécution avant que la fonction principale ne se termine. Il synchronise les trois goroutine et la fonction principale.
- Le
sync.WaitGroup (wg)gère les goroutines et garde une trace du nombre de goroutines qui s'exécutent. - La méthode
sync.WaitGroup.Add (wg.Add)est utilisée pour ajouter le nombre de goroutines en tant qu'arguments qui s'exécutent. - La méthode
sync.WaitGroup.Done (wg.Done)est utilisée pour décrémenter le nombre de goroutines qui s'exécutent. - La méthode
**sync.WaitGroup.Wait (wg.Wait)**est utilisée pour attendre que toutes les goroutines terminent leur exécution avant que la fonction principale ne se termine.
Qu'est-ce que les Channels ?
Les channels sont utilisés pour communiquer entre les goroutines. C'est un conduit typé à travers lequel vous pouvez envoyer et recevoir des messages avec l'opérateur de channel, **<-**.
Dans leur forme la plus simple, une goroutine écrit des messages dans le channel et une autre goroutine lit les mêmes messages depuis le channel.
Les channels sont créés en utilisant la méthode make et le mot-clé chan ainsi que son type. Les channels sont utilisés pour transférer des messages du type avec lequel ils ont été déclarés.
Exemple :
package main
func main(){
msgChan := make(chan string)
}
L'exemple ci-dessus crée un channel msgChan de type string.
Comment écrire des données dans un Channel
Pour écrire des données dans un channel, spécifiez d'abord le nom (msgChan) du channel, suivi de l'opérateur <- et du message. Cela est considéré comme l'Émetteur.
msgChan <- "hello world"
Comment lire des données depuis un Channel
Pour lire des données depuis un channel, déplacez simplement l'opérateur (<-) devant le nom du channel (msgChan) et vous pouvez l'assigner à une variable. Cela est considéré comme le Récepteur.
msg := <- msgChan
Comment implémenter des Channels avec Goroutine
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
msgChan := make(chan string)
go func() {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
msgChan <- "hello" // Écrire des données dans le channel
msgChan <- "world" // Écrire des données dans le channel
}()
msg1 := <- msgChan
msg2 := <- msgChan
fmt.Println(msg1, msg2)
}
L'exemple ci-dessus montre comment écrire et lire des données depuis un channel. Le channel msgChan est créé et le mot-clé go est utilisé pour créer une goroutine qui écrit des données dans le channel. Les variables msg1 et msg2 sont utilisées pour lire des données depuis le channel.
Les channels se comportent comme une file premier entré, premier sorti. Ainsi, lorsqu'une goroutine écrit des données dans le channel, l'autre goroutine lit les données depuis le channel dans le même ordre dans lequel elles ont été écrites.
Qu'est-ce que les tampons de Channel ?
Les channels peuvent être tamponnés ou non tamponnés. Les exemples précédents incluent l'utilisation de channels non tamponnés.
Qu'est-ce qu'un Channel non tamponné ?
Un channel non tamponné fait en sorte que l'émetteur se bloque immédiatement après l'envoi d'un message dans le channel jusqu'à ce que le récepteur reçoive le message.
Qu'est-ce qu'un Channel tamponné ?
Un channel tamponné permet à l'émetteur d'envoyer des messages dans le channel sans se bloquer jusqu'à ce que le tampon soit plein. Ainsi, l'émetteur se bloque uniquement une fois que le tampon est rempli et attend qu'une autre goroutine lise depuis le channel, s'assurant que l'espace devient disponible avant de se débloquer.
Comment créer un Channel tamponné
Lors de la création d'un channel tamponné, utilisez la fonction make et spécifiez un deuxième paramètre pour indiquer la taille du tampon.
msgBufChan := make(chan string, 2)
L'exemple ci-dessus crée un channel tamponné msgBufChan de type string avec une taille de tampon de 2. Cela signifie que le channel peut contenir jusqu'à deux messages avant de se bloquer.
package main
import (
"time"
)
func main() {
size := 3
msgBufChan := make(chan int, size)
// lecteur (récepteur)
go func() {
for {
_ = <- msgBufChan
time.Sleep(time.Second)
}
}()
// écrivain (émetteur)
writer := func() {
for i := 0; i <= 10; i++ {
msgBufChan <- i
println(i)
}
}
writer()
}
L'exemple ci-dessus crée un channel tamponné msgBufChan de type int avec une taille de tampon de 3.
- La fonction
writerécrit des données dans le channel et la fonctionreaderlit des données depuis le channel. - Lorsque le programme s'exécute, vous verrez que les nombres
0 à 3sont imprimés immédiatement et les nombres restants5 à 10sont imprimés lentement, environ un par seconde (time.Sleep(time.Second)). - Cela montre l'effet du channel tamponné qui spécifie la taille qu'il peut contenir avant de se bloquer.
Qu'est-ce que les directions de Channel ?
Lors de l'utilisation de channels comme paramètres de fonction, par défaut, vous pouvez envoyer et recevoir des messages dans la fonction. Pour fournir une sécurité supplémentaire au moment de la compilation, les paramètres de fonction de channel peuvent être définis avec une direction. C'est-à-dire qu'ils peuvent être définis pour être lecture seule ou écriture seule.
Exemple :
package main
import (
"fmt"
"time"
)
func writer(channel chan<- string, msg string) {
channel <- msg
}
func reader(channel <-chan string) {
msg := <- channel
fmt.Println(msg)
}
func main() {
msgChan := make(chan string, 1)
go reader(msgChan)
for i := 0; i < 10; i++ {
writer(msgChan, fmt.Sprintf("msg %d", i))
}
time.Sleep(time.Second * 5)
}
L'exemple ci-dessus montre comment définir un channel avec une direction.
- La fonction
writerest définie avec un channel en écriture seule et - La fonction
readerest définie avec un channel en lecture seule.
Le channel msgChan est créé avec une taille de tampon de 1. La fonction writer écrit des données dans le channel et la fonction reader lit des données depuis le channel.
Comment gérer plusieurs opérations de communication avec Channel Select
L'instruction select permet à une goroutine d'attendre sur plusieurs opérations de communication. Un select bloque jusqu'à ce que l'un de ses cas puisse s'exécuter, puis il exécute ce cas. Il en choisit un au hasard si plusieurs sont prêts.
Les instructions select et case sont utilisées pour simplifier la gestion et la lisibilité de wait sur plusieurs channels.
Exemple :
package main
import (
"fmt"
"time"
"math/rand"
)
func pause() {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
func test1(c chan<- string) {
for {
pause()
c <- "hello"
}
}
func test2(c chan<- string) {
for {
pause()
c <- "world"
}
}
func main() {
rand.Seed(time.Now().Unix())
c1 := make(chan string)
c2 := make(chan string)
go test1(c1)
go test2(c2)
for {
select {
case msg1 := <- c1:
fmt.Println(msg1)
case msg2 := <- c2:
fmt.Println(msg2)
}
}
}
L'exemple ci-dessus montre comment utiliser l'instruction select pour attendre sur plusieurs channels. Les fonctions test1 et test2 écrivent des données dans les channels c1 et c2 respectivement. La fonction main lit des données depuis les channels c1 et c2 en utilisant l'instruction select.
L'instruction select se bloquera jusqu'à ce que l'un des channels soit prêt à envoyer ou recevoir des données. Si les deux channels sont prêts, l'instruction select en choisira un au hasard.
Comment mettre en timeout des processus longs dans un Channel
La fonction time.After est utilisée pour créer un channel qui envoie un message après une durée spécifiée. Cela peut être utilisé pour implémenter un timeout pour un channel.
Il peut être spécifié dans une instruction select pour aider à gérer les situations où il faut trop de temps pour recevoir un message depuis l'un des channels surveillés.
Pensez également à utiliser timeout lorsque vous travaillez avec des ressources externes, car vous ne pouvez jamais garantir le temps de réponse et, par conséquent, vous devrez peut-être prendre des mesures proactives après qu'un temps prédéterminé se soit écoulé.
L'implémentation d'un timeout avec une instruction select est très simple.
Exemple :
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
go func(channel chan string) {
time.Sleep(1 * time.Second)
channel <- "hello world"
}(c1)
select {
case msg2 := <-c1:
fmt.Println(msg2)
case <-time.After(2 * time.Second): // Timeout après 2 secondes
fmt.Println("timeout")
}
}
- L'exemple ci-dessus montre comment utiliser la fonction
time.Afterpour créer un channel qui envoie un message après une durée spécifiée. - La fonction
mainlit des données depuis le channelc1en utilisant l'instructionselect. - L'instruction
selectse bloquera jusqu'à ce que l'un des channels soit prêt à envoyer ou recevoir des données. - Si le channel
c1est prêt, la fonctionmainimprimera le message. - Si le channel
c1n'est pas prêt après 2 secondes, la fonctionmainimprimera un message de timeout.
Comment fermer un Channel
Fermer un channel est utilisé pour indiquer qu'aucune autre valeur ne sera envoyée sur le channel. Il est utilisé pour signaler au récepteur que le channel a été fermé et qu'aucune autre valeur ne sera envoyée.
Les channels Go peuvent être explicitement fermés pour aider avec les problèmes de synchronisation. L'implémentation par défaut fermera le channel lorsque toutes les valeurs auront été envoyées.
Fermer un channel se fait en invoquant la fonction intégrée close.
close(channel)
Exemple :
package main
import (
"fmt"
"bytes"
)
func process(work <-chan string, fin chan<- string) {
var b bytes.Buffer
for {
if msg, notClosed := <-work; notClosed {
fmt.Printf("%s received...\n", msg)
} else {
fmt.Println("Channel closed")
fin <- b.String()
return
}
}
}
func main() {
work := make(chan string, 3)
fin := make(chan string)
go process(work, fin)
word := "hello world"
for i := 0; i < len(word); i++ {
letter := string(word[i])
work <- letter
fmt.Printf("%s sent ...\n", letter)
}
close(work)
fmt.Printf("result: %s\n", <-fin)
}
L'exemple ci-dessus montre comment fermer un channel. Le channel work est créé avec une taille de tampon de 3. La fonction process lit des données depuis le channel work et écrit des données dans le channel fin. La fonction main écrit des données dans le channel work et ferme le channel work. La fonction process imprimera le message si le channel work n'est pas fermé. Si le channel work est fermé, la fonction process imprimera un message et écrira les données dans le channel fin.
Comment itérer sur les messages d'un Channel
Les channels peuvent être itérés en utilisant le mot-clé range, similaire aux arrays, slice, et/ou maps. Cela vous permet d'itérer rapidement et facilement sur les messages dans un channel.
Exemple :
package main
import (
"fmt"
)
func main() {
c := make(chan string, 3)
go func() {
c <- "hello"
c <- "world"
c <- "goroutine"
close(c) // Fermer le channel est très important avant de procéder à l'itération, sinon erreur de deadlock
}()
for msg := range c {
fmt.Println(msg)
}
}
L'exemple ci-dessus montre comment itérer sur un channel en utilisant le mot-clé range. Le channel c est créé avec une taille de tampon de 3. Le mot-clé go est utilisé pour créer une goroutine qui écrit des données dans le channel c. La fonction main itère sur le channel c en utilisant le mot-clé range et imprime le message.
Conclusion
Dans cet article, nous avons appris comment gérer la concurrence avec les goroutines et les channels en Go. Nous avons appris comment créer des goroutines, et comment utiliser les WaitGroups et les channels pour communiquer entre les goroutines.
Nous avons également appris comment utiliser les tampons de channel, les directions de channel, le select de channel, le timeout de channel, la fermeture de channel et la portée de channel.
Les goroutines et les channels sont des fonctionnalités puissantes de Go qui aident à répondre aux exigences de concurrence et de flux asynchrone.
Comme toujours, j'espère que vous avez apprécié l'article et appris quelque chose de nouveau. Si vous le souhaitez, vous pouvez également me suivre sur LinkedIn ou Twitter.