Comment exécuter plusieurs fonctions simultanément dans Go

De Get Docs
Aller à :navigation, rechercher

L'auteur a sélectionné le Diversity in Tech Fund pour recevoir un don dans le cadre du programme Write for DOnations.

Introduction

L'une des fonctionnalités populaires du langage Go est sa prise en charge de premier ordre de la concurrence, ou la capacité d'un programme à faire plusieurs choses à la fois. Être capable d'exécuter du code simultanément devient une partie plus importante de la programmation à mesure que les ordinateurs passent de l'exécution d'un seul flux de code plus rapidement à l'exécution simultanée de plusieurs flux de code. Pour exécuter des programmes plus rapidement, un programmeur doit concevoir ses programmes pour qu'ils s'exécutent simultanément, de sorte que chaque partie simultanée du programme puisse être exécutée indépendamment des autres. Deux fonctionnalités de Go, goroutines et channels, facilitent la simultanéité lorsqu'elles sont utilisées ensemble. Les goroutines résolvent la difficulté de configurer et d'exécuter du code simultané dans un programme, et les canaux résolvent la difficulté de communiquer en toute sécurité entre le code exécuté simultanément.

Dans ce didacticiel, vous explorerez à la fois les goroutines et les canaux. Tout d'abord, vous allez créer un programme qui utilise des goroutines pour exécuter plusieurs fonctions à la fois. Ensuite, vous ajouterez des canaux à ce programme pour communiquer entre les goroutines en cours d'exécution. Enfin, vous ajouterez plus de goroutines au programme pour simuler un programme exécuté avec plusieurs goroutines de travail.

Conditions préalables

Pour suivre ce tutoriel, vous aurez besoin de :

Exécuter des fonctions en même temps avec Goroutines

Dans un ordinateur moderne, le processeur, ou CPU, est conçu pour exécuter autant de flux de code que possible en même temps. Ces processeurs ont un ou plusieurs "cœurs", chacun capable d'exécuter un flux de code en même temps. Ainsi, plus un programme peut utiliser simultanément de cœurs, plus le programme s'exécutera rapidement. Cependant, pour que les programmes puissent tirer parti de l'augmentation de vitesse fournie par plusieurs cœurs, les programmes doivent pouvoir être divisés en plusieurs flux de code. Diviser un programme en parties peut être l'une des choses les plus difficiles à faire en programmation, mais Go a été conçu pour rendre cela plus facile.

Pour ce faire, Go utilise notamment une fonctionnalité appelée goroutines. Une goroutine est un type spécial de fonction qui peut s'exécuter pendant que d'autres goroutines sont également en cours d'exécution. Lorsqu'un programme est conçu pour exécuter plusieurs flux de code à la fois, le programme est conçu pour exécuter simultanément. En règle générale, lorsqu'une fonction est appelée, elle finit de s'exécuter complètement avant le code après avoir continué à s'exécuter. C'est ce qu'on appelle l'exécution au "premier plan" car cela empêche votre programme de faire quoi que ce soit d'autre avant qu'il ne se termine. Avec une goroutine, l'appel de fonction continuera à exécuter le code suivant tout de suite pendant que la goroutine s'exécute en "arrière-plan". Le code est considéré comme s'exécutant en arrière-plan lorsqu'il n'empêche pas un autre code de s'exécuter avant qu'il ne se termine.

La puissance fournie par les goroutines est que chaque goroutine peut fonctionner sur un cœur de processeur en même temps. Si votre ordinateur a quatre cœurs de processeur et que votre programme a quatre goroutines, les quatre goroutines peuvent s'exécuter simultanément. Lorsque plusieurs flux de code s'exécutent en même temps sur différents cœurs comme celui-ci, cela s'appelle s'exécuter en parallèle.

Pour visualiser la différence entre la concurrence et le parallélisme, considérez le diagramme suivant. Lorsqu'un processeur exécute une fonction, il ne l'exécute pas toujours du début à la fin en une seule fois. Parfois, le système d'exploitation entrelace d'autres fonctions, goroutines ou autres programmes sur un cœur de processeur lorsqu'une fonction attend que quelque chose d'autre se produise, comme la lecture d'un fichier. Le diagramme montre comment un programme conçu pour la simultanéité peut s'exécuter sur un seul cœur ainsi que sur plusieurs cœurs. Il montre également comment plus de segments d'une goroutine peuvent tenir dans le même laps de temps (9 segments verticaux, comme le montre le diagramme) lors de l'exécution en parallèle que lors de l'exécution sur un seul cœur.

La colonne de gauche du diagramme, intitulée "Concurrency", montre comment un programme conçu autour de la simultanéité peut s'exécuter sur un seul cœur de processeur en exécutant une partie de goroutine1, puis une autre fonction, goroutine ou programme, puis goroutine2, puis goroutine1 à nouveau, et ainsi de suite. Pour un utilisateur, cela donnerait l'impression que le programme exécute toutes les fonctions ou goroutines en même temps, même si elles sont en fait exécutées par petites parties les unes après les autres.

La colonne à droite du diagramme, intitulée "Parallélisme", montre comment ce même programme pourrait s'exécuter en parallèle sur un processeur à deux cœurs de processeur. Le premier cœur du processeur montre goroutine1 s'exécutant entrecoupé d'autres fonctions, goroutines ou programmes, tandis que le deuxième cœur du processeur montre goroutine2 s'exécutant avec d'autres fonctions ou goroutines sur ce cœur. Parfois, goroutine1 et goroutine2 s'exécutent en même temps, uniquement sur des cœurs de processeur différents.

Ce diagramme montre également une autre caractéristique puissante de Go, l'évolutivité. Un programme est évolutif lorsqu'il peut s'exécuter sur n'importe quoi, d'un petit ordinateur avec quelques cœurs de processeur à un grand serveur avec des dizaines de cœurs, et tirer parti de ces ressources supplémentaires. Le diagramme montre qu'en utilisant des goroutines, votre programme concurrent est capable de s'exécuter sur un seul cœur de processeur, mais que plus de cœurs de processeur sont ajoutés, plus de goroutines peuvent être exécutées en parallèle pour accélérer le programme.

Pour démarrer avec votre nouveau programme simultané, créez un répertoire multifunc à l'emplacement de votre choix. Vous avez peut-être déjà un répertoire pour vos projets, mais dans ce didacticiel, vous allez créer un répertoire appelé projects. Vous pouvez créer le répertoire projects via un IDE ou via la ligne de commande.

Si vous utilisez la ligne de commande, commencez par créer le répertoire projects et accédez-y :

mkdir projects
cd projects

Depuis le répertoire projects, utilisez la commande mkdir pour créer le répertoire du programme (multifunc) puis naviguez dedans :

mkdir multifunc
cd multifunc

Une fois que vous êtes dans le répertoire multifunc, ouvrez un fichier nommé main.go en utilisant nano, ou votre éditeur préféré :

nano main.go

Collez ou saisissez le code suivant dans le fichier main.go pour commencer.

projets/multifunc/main.go

package main

import (
    "fmt"
)

func generateNumbers(total int) {
    for idx := 1; idx <= total; idx++ {
        fmt.Printf("Generating number %d\n", idx)
    }
}

func printNumbers() {
    for idx := 1; idx <= 3; idx++ {
        fmt.Printf("Printing number %d\n", idx)
    }
}

func main() {
    printNumbers()
    generateNumbers(3)
}

Ce programme initial définit deux fonctions, generateNumbers et printNumbers, puis exécute ces fonctions dans la fonction main. La fonction generateNumbers prend le nombre de nombres à « générer » comme paramètre, dans ce cas un à trois, puis imprime chacun de ces nombres à l'écran. La fonction printNumbers ne prend pas encore de paramètres, mais elle imprimera également les nombres un à trois.

Une fois que vous avez enregistré le fichier main.go, exécutez-le en utilisant go run pour voir la sortie :

go run main.go

La sortie ressemblera à ceci :

OutputPrinting number 1
Printing number 2
Printing number 3
Generating number 1
Generating number 2
Generating number 3

Vous verrez les fonctions s'exécuter les unes après les autres, avec printNumbers en premier et generateNumbers en second.

Maintenant, imaginez que printNumbers et generateNumbers prennent chacun trois secondes pour s'exécuter. Lors de l'exécution synchroniquement, ou l'un après l'autre comme dans le dernier exemple, votre programme prendrait six secondes pour s'exécuter. Tout d'abord, printNumbers fonctionnerait pendant trois secondes, puis generateNumbers fonctionnerait pendant trois secondes. Dans votre programme, cependant, ces deux fonctions sont indépendantes l'une de l'autre car elles ne reposent pas sur les données de l'autre pour s'exécuter. Vous pouvez en profiter pour accélérer ce programme hypothétique en exécutant les fonctions simultanément à l'aide de goroutines. Lorsque les deux fonctions s'exécutent simultanément, le programme pourrait, en théorie, s'exécuter en deux fois moins de temps. Si les fonctions printNumbers et generateNumbers prennent trois secondes pour s'exécuter et qu'elles démarrent exactement au même moment, le programme peut se terminer en trois secondes. (La vitesse réelle peut cependant varier en raison de facteurs extérieurs, tels que le nombre de cœurs de l'ordinateur ou le nombre d'autres programmes exécutés sur l'ordinateur en même temps.)

L'exécution simultanée d'une fonction en tant que goroutine est similaire à l'exécution d'une fonction de manière synchrone. Pour exécuter une fonction en tant que goroutine (par opposition à une fonction synchrone standard), il suffit d'ajouter le mot-clé go avant l'appel de la fonction.

Cependant, pour que le programme exécute les goroutines simultanément, vous devrez effectuer une modification supplémentaire. Vous devrez ajouter un moyen pour que votre programme attende que les deux goroutines aient fini de s'exécuter. Si vous n'attendez pas que vos goroutines se terminent et que votre fonction main se termine, les goroutines pourraient ne jamais s'exécuter, ou seule une partie d'entre elles pourrait s'exécuter et ne pas terminer son exécution.

Pour attendre la fin des fonctions, vous utiliserez un WaitGroup du package sync de Go. Le package sync contient des "primitives de synchronisation", telles que WaitGroup, qui sont conçues pour synchroniser différentes parties d'un programme. Dans votre cas, la synchronisation garde une trace de la fin de l'exécution des deux fonctions afin que vous puissiez quitter le programme.

La primitive WaitGroup fonctionne en comptant le nombre de choses qu'elle doit attendre pour utiliser les fonctions Add, Done et Wait. La fonction Add augmente le décompte du nombre fourni à la fonction, et Done diminue le décompte de un. La fonction Wait peut alors être utilisée pour attendre que le compte atteigne zéro, ce qui signifie que Done a été appelé suffisamment de fois pour compenser les appels à Add. Une fois que le compte atteint zéro, la fonction Wait revient et le programme continue à s'exécuter.

Ensuite, mettez à jour le code dans votre fichier main.go pour exécuter vos deux fonctions en tant que goroutines en utilisant le mot-clé go, et ajoutez un sync.WaitGroup au programme :

projets/multifunc/main.go

package main

import (
    "fmt"
    "sync"
)

func generateNumbers(total int, wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= total; idx++ {
        fmt.Printf("Generating number %d\n", idx)
    }
}

func printNumbers(wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= 3; idx++ {
        fmt.Printf("Printing number %d\n", idx)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go printNumbers(&wg)
    go generateNumbers(3, &wg)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

Après avoir déclaré le WaitGroup, il devra savoir combien de choses attendre. Inclure un wg.Add(2) dans la fonction main avant de démarrer les goroutines indiquera à wg d'attendre deux appels Done avant de considérer que le groupe est terminé. Si cela n'est pas fait avant le démarrage des goroutines, il est possible que les choses se passent dans le désordre ou que le code panique car le wg ne sait pas qu'il devrait attendre les appels Done. .

Chaque fonction utilisera alors defer pour appeler Done afin de diminuer le nombre de un après la fin de l'exécution de la fonction. La fonction main est également mise à jour pour inclure un appel à Wait sur le WaitGroup, de sorte que la fonction main attendra que les deux fonctions appellent [ X154X] pour poursuivre l'exécution et quitter le programme.

Après avoir enregistré votre fichier main.go, exécutez-le en utilisant go run comme vous l'avez fait auparavant :

go run main.go

La sortie ressemblera à ceci :

OutputPrinting number 1
Waiting for goroutines to finish...
Generating number 1
Generating number 2
Generating number 3
Printing number 2
Printing number 3
Done!

Votre sortie peut différer de ce qui est imprimé ici, et il est même susceptible de changer à chaque fois que vous exécutez le programme. Lorsque les deux fonctions s'exécutent simultanément, le résultat dépend du temps que Go et votre système d'exploitation accordent à l'exécution de chaque fonction. Parfois, il y a suffisamment de temps pour exécuter chaque fonction complètement et vous verrez les deux fonctions imprimer leurs séquences entières sans interruption. D'autres fois, vous verrez le texte intercalé comme la sortie ci-dessus.

Une expérience que vous pouvez essayer consiste à supprimer l'appel wg.Wait() dans la fonction main et à exécuter à nouveau le programme plusieurs fois avec go run. Selon votre ordinateur, vous pouvez voir une sortie des fonctions generateNumbers et printNumbers, mais il est également probable que vous ne verrez aucune sortie de leur part. Lorsque vous supprimez l'appel à Wait, le programme n'attendra plus la fin de l'exécution des deux fonctions avant de continuer. Étant donné que la fonction main se termine peu après la fonction Wait, il y a de fortes chances que votre programme atteigne la fin de la fonction main et se termine avant que les goroutines aient fini de s'exécuter. Lorsque cela se produit, vous verrez quelques chiffres imprimés, mais vous ne verrez pas les trois de chaque fonction.

Dans cette section, vous avez créé un programme qui utilise le mot-clé go pour exécuter deux goroutines simultanément et imprimer une séquence de nombres. Vous avez également utilisé un sync.WaitGroup pour que votre programme attende que ces goroutines se terminent avant de quitter le programme.

Vous avez peut-être remarqué que les fonctions generateNumbers et printNumbers n'ont pas de valeurs de retour. En Go, les goroutines ne sont pas capables de renvoyer des valeurs comme le ferait une fonction standard. Vous pouvez toujours utiliser le mot-clé go pour appeler une fonction qui renvoie des valeurs, mais ces valeurs de retour seront rejetées et vous ne pourrez pas y accéder. Alors, que faites-vous lorsque vous avez besoin de données d'une goroutine dans une autre goroutine si vous ne pouvez pas renvoyer de valeurs ? La solution est d'utiliser une fonctionnalité Go appelée "channels", qui permet d'envoyer des données d'une goroutine à une autre.

Communiquer en toute sécurité entre les goroutines avec les canaux

L'une des parties les plus difficiles de la programmation concurrente est la communication sécurisée entre les différentes parties du programme qui s'exécutent simultanément. Si vous ne faites pas attention, vous pourriez rencontrer des problèmes qui ne sont possibles qu'avec des programmes concurrents. Par exemple, une course de données peut se produire lorsque deux parties d'un programme s'exécutent simultanément et qu'une partie essaie de mettre à jour une variable tandis que l'autre essaie de la lire en même temps. Lorsque cela se produit, la lecture ou l'écriture peut se produire dans le désordre, ce qui conduit à ce qu'une ou les deux parties du programme utilisent la mauvaise valeur. Le nom "course aux données" vient des deux parties du programme qui "se font la course" pour accéder aux données.

Bien qu'il soit toujours possible de rencontrer des problèmes de concurrence tels que les courses de données dans Go, le langage est conçu pour faciliter leur évitement. En plus des goroutines, les canaux sont une autre fonctionnalité qui rend la simultanéité plus sûre et plus facile à utiliser. Un canal peut être considéré comme un tuyau entre deux ou plusieurs goroutines différentes par lesquelles les données peuvent être envoyées. Une goroutine met les données à une extrémité du tuyau et une autre goroutine extrait ces mêmes données. La partie difficile de s'assurer que les données passent de l'un à l'autre en toute sécurité est gérée pour vous.

La création d'un canal dans Go est similaire à la création d'un slice, à l'aide de la fonction intégrée make(). La déclaration de type pour un canal utilise le mot-clé chan suivi du type de données que vous souhaitez envoyer sur le canal. Par exemple, pour créer un canal pour envoyer des valeurs int, vous utiliserez le type chan int. Si vous vouliez un canal pour envoyer des vaules []byte, ce serait chan []byte, comme ceci :

bytesChan := make(chan []byte)

Une fois qu'un canal est créé, vous pouvez envoyer ou recevoir des données sur le canal en utilisant l'opérateur en forme de flèche <-. La position de l'opérateur <- par rapport à la variable de canal détermine si vous lisez ou écrivez dans le canal.

Pour écrire dans une voie, commencez par la variable de voie, suivie de l'opérateur <-, puis de la valeur que vous souhaitez écrire dans la voie :

intChan := make(chan int)
intChan <- 10

Pour lire une valeur à partir d'un canal, commencez par la variable dans laquelle vous souhaitez insérer la valeur, soit = ou := pour attribuer une valeur à la variable, suivi de [X173X ], puis le canal à partir duquel vous souhaitez lire :

intChan := make(chan int)
intVar := <- intChan

Pour garder ces deux opérations droites, il peut être utile de se rappeler que la flèche <- pointe toujours vers la gauche (par opposition à ->) et que la flèche pointe vers la direction de la valeur. Dans le cas d'une écriture sur une voie, la flèche pointe la valeur vers la voie. Lors de la lecture d'un canal, la flèche pointe le canal vers la variable.

Comme une tranche, un canal peut également être lu en utilisant le mot-clé range dans une boucle for. Lorsqu'un canal est lu à l'aide du mot-clé range, chaque itération de la boucle lira la valeur suivante du canal et la placera dans la variable de boucle. Il continuera ensuite à lire à partir du canal jusqu'à ce que le canal soit fermé ou que la boucle for soit sortie d'une autre manière, comme un break :

intChan := make(chan int)
for num := range intChan {
    // Use the value of num received from the channel
    if num < 1 {
        break
    }
}

Dans certains cas, vous souhaiterez peut-être autoriser uniquement une fonction à lire ou à écrire dans un canal, mais pas les deux. Pour ce faire, vous devez ajouter l'opérateur <- sur la déclaration de type chan. Semblable à la lecture et à l'écriture à partir d'un canal, le type de canal utilise la flèche <- pour permettre aux variables de contraindre un canal à la lecture uniquement, à l'écriture uniquement ou à la fois à la lecture et à l'écriture. Par exemple, pour définir un canal en lecture seule de valeurs int, la déclaration de type serait <-chan int :

func readChannel(ch <-chan int) {
    // ch is read-only
}

Si vous vouliez que le canal soit en écriture seule, vous le déclareriez comme chan<- int :

func writeChannel(ch chan<- int) {
    // ch is write-only
}

Notez que la flèche pointe hors du canal pour la lecture et pointe vers le canal pour l'écriture. Si la déclaration n'a pas de flèche, comme dans le cas de chan int, le canal peut être utilisé à la fois en lecture et en écriture.

Enfin, une fois qu'un canal n'est plus utilisé, il peut être fermé à l'aide de la fonction intégrée close(). Cette étape est essentielle car lorsque des canaux sont créés puis laissés inutilisés plusieurs fois dans un programme, cela peut entraîner ce que l'on appelle une fuite de mémoire. Une fuite de mémoire se produit lorsqu'un programme crée quelque chose qui utilise de la mémoire sur un ordinateur, mais ne restitue pas cette mémoire à l'ordinateur une fois qu'il a fini de l'utiliser. Cela conduit le programme à utiliser lentement (ou parfois pas si lentement) plus de mémoire au fil du temps, comme une fuite d'eau. Lorsqu'un canal est créé avec make(), une partie de la mémoire de l'ordinateur est utilisée pour le canal, puis lorsque close() est appelé sur le canal, cette mémoire est restituée à l'ordinateur pour être utilisée pour autre chose.

Maintenant, mettez à jour le fichier main.go dans votre programme pour utiliser un canal chan int pour communiquer entre vos goroutines. La fonction generateNumbers générera des nombres et les écrira sur le canal tandis que la fonction printNumbers lira ces nombres du canal et les imprimera à l'écran. Dans la fonction main, vous allez créer un nouveau canal à passer en paramètre à chacune des autres fonctions, puis utiliser close() sur le canal pour le fermer car il ne sera plus utilisé . La fonction generateNumbers ne devrait plus non plus être une goroutine car une fois cette fonction exécutée, le programme aura fini de générer tous les nombres dont il a besoin. Ainsi, la fonction close() n'est appelée sur le canal qu'avant la fin de l'exécution des deux fonctions.

projets/multifunc/main.go

package main

import (
    "fmt"
    "sync"
)

func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= total; idx++ {
        fmt.Printf("sending %d to channel\n", idx)
        ch <- idx
    }
}

func printNumbers(ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for num := range ch {
        fmt.Printf("read %d from channel\n", num)
    }
}

func main() {
    var wg sync.WaitGroup
    numberChan := make(chan int)

    wg.Add(2)
    go printNumbers(numberChan, &wg)

    generateNumbers(3, numberChan, &wg)

    close(numberChan)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

Dans les paramètres de generateNumbers et printNumbers, vous verrez que les types chan utilisent les types en lecture seule et en écriture seule. Étant donné que generateNumbers n'a besoin que de pouvoir écrire des nombres sur le canal, il s'agit d'un type en écriture seule avec la flèche <- pointant vers le canal. printNumbers n'a besoin que de pouvoir lire les nombres du canal, il s'agit donc d'un type en lecture seule avec la flèche <- pointant vers l'extérieur du canal.

Même si ces types pourraient être un chan int, ce qui permettrait à la fois la lecture et l'écriture, il peut être utile de les contraindre uniquement à ce dont la fonction a besoin pour éviter que votre programme ne s'exécute accidentellement à partir de quelque chose connu sous le nom de [ X243X]impasse. Un blocage peut se produire lorsqu'une partie d'un programme attend qu'une autre partie du programme fasse quelque chose, mais que cette autre partie du programme attend également que la première partie du programme se termine. Étant donné que les deux parties du programme s'attendent l'une à l'autre, le programme ne continuera jamais à fonctionner, presque comme lorsque deux engrenages se saisissent.

Le blocage peut se produire en raison du fonctionnement de la communication par canal dans Go. Lorsqu'une partie d'un programme écrit sur un canal, il attendra qu'une autre partie du programme lise à partir de ce canal avant de continuer. De même, si un programme lit à partir d'un canal, il attendra qu'une autre partie du programme écrive sur ce canal avant de continuer. Une partie d'un programme qui attend que quelque chose d'autre se produise est dite bloquante parce qu'elle est empêchée de continuer jusqu'à ce que quelque chose d'autre se produise. Les canaux se bloquent lorsqu'ils sont en cours d'écriture ou de lecture. Donc, si vous avez une fonction dans laquelle vous vous attendez à écrire sur un canal mais que vous lisez accidentellement à partir du canal à la place, votre programme peut entrer dans une impasse car le canal ne sera jamais écrit. S'assurer que cela ne se produise jamais est une des raisons d'utiliser un chan<- int ou un <-chan int au lieu d'un simple chan int.

Un autre aspect important du code mis à jour consiste à utiliser close() pour fermer le canal une fois qu'il a été écrit par generateNumbers. Dans ce programme, close() provoque la sortie de la boucle for ... range dans printNumbers. Étant donné que l'utilisation de range pour lire à partir d'un canal se poursuit jusqu'à ce que le canal à partir duquel il lit soit fermé, si close n'est pas appelé sur numberChan, alors printNumbers ne le sera jamais. terminer. Si printNumbers ne se termine jamais, la méthode Done de WaitGroup ne sera jamais appelée par defer lorsque printNumbers se termine. Si la méthode Done n'est jamais appelée depuis printNumbers, le programme lui-même ne se fermera jamais car la méthode Wait dans le main ] la fonction ne continuera jamais. Ceci est un autre exemple de blocage car la fonction main attend quelque chose qui n'arrivera jamais.

Maintenant, exécutez à nouveau votre code mis à jour en utilisant la commande go run sur main.go.

go run main.go

Votre résultat peut varier légèrement de ce qui est affiché ci-dessous, mais dans l'ensemble, il devrait être similaire :

Outputsending 1 to channel
sending 2 to channel
read 1 from channel
read 2 from channel
sending 3 to channel
Waiting for functions to finish...
read 3 from channel
Done!

La sortie du programme montre que la fonction generateNumbers génère les nombres un à trois tout en les écrivant sur le canal partagé avec printNumbers. Une fois que printNumbers reçoit le numéro, il l'imprime ensuite à l'écran. Une fois que generateNumbers a généré les trois nombres, il se fermera, permettant à la fonction main de fermer le canal et d'attendre que printNumbers soit terminé. Une fois que printNumbers a fini d'imprimer le dernier numéro, il appelle Done sur le WaitGroup et le programme se termine. Semblable aux sorties précédentes, la sortie exacte que vous voyez dépendra de divers facteurs extérieurs, tels que le moment où le système d'exploitation ou le runtime Go choisissent d'exécuter des goroutines spécifiques, mais cela devrait être relativement proche.

L'avantage de concevoir vos programmes à l'aide de goroutines et de canaux est qu'une fois que vous avez conçu votre programme pour qu'il soit divisé, vous pouvez l'étendre à plus de goroutines. Étant donné que generateNumbers écrit simplement sur un canal, peu importe le nombre d'autres choses qui lisent à partir de ce canal. Il enverra simplement des numéros à tout ce qui lit le canal. Vous pouvez en tirer parti en exécutant plus d'une goroutine printNumbers, de sorte que chacune d'elles lira à partir du même canal et traitera les données simultanément.

Maintenant que votre programme utilise des canaux pour communiquer, ouvrez à nouveau le fichier main.go et mettez à jour votre programme afin qu'il démarre plusieurs goroutines printNumbers. Vous devrez modifier l'appel à wg.Add afin qu'il en ajoute un pour chaque goroutine que vous démarrez. Vous n'avez plus besoin de vous soucier d'en ajouter un au WaitGroup pour l'appel à generateNumbers car le programme ne continuera pas sans terminer toute la fonction, contrairement à quand vous l'exécutiez en tant que une goroutine. Pour vous assurer qu'il ne réduit pas le nombre de WaitGroup lorsqu'il se termine, vous devez supprimer la ligne defer wg.Done() de la fonction. Ensuite, ajouter le numéro de la goroutine à printNumbers permet de voir plus facilement comment le canal est lu par chacun d'eux. Augmenter le nombre de nombres générés est également une bonne idée pour qu'il soit plus facile de voir les nombres se répartir :

projets/multifunc/main.go

...

func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
    for idx := 1; idx <= total; idx++ {
        fmt.Printf("sending %d to channel\n", idx)
        ch <- idx
    }
}

func printNumbers(idx int, ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for num := range ch {
        fmt.Printf("%d: read %d from channel\n", idx, num)
    }
}

func main() {
    var wg sync.WaitGroup
    numberChan := make(chan int)

    for idx := 1; idx <= 3; idx++ {
        wg.Add(1)
        go printNumbers(idx, numberChan, &wg)
    }

    generateNumbers(5, numberChan, &wg)

    close(numberChan)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

Une fois votre main.go mis à jour, vous pouvez relancer votre programme en utilisant go run avec main.go. Votre programme doit démarrer trois goroutines printNumbers avant de continuer à générer des nombres. Votre programme devrait également maintenant générer cinq nombres au lieu de trois pour faciliter la visualisation des nombres répartis entre chacune des trois goroutines printNumbers :

go run main.go

La sortie peut ressembler à ceci (bien que votre sortie puisse varier un peu):

Outputsending 1 to channel
sending 2 to channel
sending 3 to channel
3: read 2 from channel
1: read 1 from channel
sending 4 to channel
sending 5 to channel
3: read 4 from channel
1: read 5 from channel
Waiting for goroutines to finish...
2: read 3 from channel
Done!

Lorsque vous regardez la sortie de votre programme cette fois-ci, il y a de fortes chances qu'elle diffère considérablement de la sortie que vous voyez ci-dessus. Puisqu'il y a trois goroutines printNumbers en cours d'exécution, il y a un élément de chance pour déterminer laquelle reçoit un nombre spécifique. Lorsqu'une goroutine printNumbers reçoit un numéro, elle passe un peu de temps à imprimer ce numéro à l'écran, tandis qu'une autre goroutine lit le numéro suivant du canal et fait la même chose. Lorsqu'une goroutine a terminé son travail d'impression du numéro et est prête à lire un autre numéro, elle va revenir en arrière et relire le canal pour imprimer le suivant. S'il n'y a plus de numéros à lire sur le canal, il commencera à se bloquer jusqu'à ce que le numéro suivant puisse être lu. Une fois que generateNumbers a terminé et que close() est appelé sur le canal, les trois goroutines printNumbers termineront leurs boucles range et sortiront. Lorsque les trois goroutines sont sorties et ont appelé Done sur le WaitGroup, le compte du WaitGroup atteindra zéro et le programme se terminera. Vous pouvez également expérimenter en augmentant ou en diminuant la quantité de goroutines ou de nombres générés pour voir comment cela affecte la sortie.

Lorsque vous utilisez des goroutines, évitez d'en démarrer trop. En théorie, un programme pourrait avoir des centaines voire des milliers de goroutines. Cependant, selon l'ordinateur sur lequel le programme s'exécute, il peut être plus lent d'avoir un plus grand nombre de goroutines. Avec un nombre élevé de goroutines, il y a une chance qu'il se heurte à une faim de ressources. Chaque fois que Go exécute une partie d'une goroutine, il lui faut un peu plus de temps pour recommencer à s'exécuter, en plus du temps nécessaire pour exécuter le code dans la fonction suivante. En raison du temps supplémentaire que cela prend, il est possible que l'ordinateur prenne plus de temps pour basculer entre l'exécution de chaque goroutine que pour exécuter réellement la goroutine elle-même. Lorsque cela se produit, cela s'appelle une pénurie de ressources car le programme et ses goroutines n'obtiennent pas les ressources dont ils ont besoin pour fonctionner, ou en obtiennent très peu. Dans ces cas, il peut être plus rapide de réduire le nombre de parties du programme exécutées simultanément, car cela réduira le temps nécessaire pour basculer entre elles et donnera plus de temps à l'exécution du programme lui-même. Se souvenir du nombre de cœurs sur lesquels le programme s'exécute peut être un bon point de départ pour décider du nombre de goroutines que vous souhaitez utiliser.

L'utilisation d'une combinaison de goroutines et de canaux permet de créer des programmes très puissants capables d'évoluer depuis l'exécution sur de petits ordinateurs de bureau jusqu'à des serveurs massifs. Comme vous l'avez vu dans cette section, les canaux peuvent être utilisés pour communiquer entre quelques goroutines et potentiellement des milliers de goroutines avec des changements minimes. Si vous en tenez compte lors de l'écriture de vos programmes, vous pourrez tirer parti de la simultanéité disponible dans Go pour offrir à vos utilisateurs une meilleure expérience globale.

Conclusion

Dans ce didacticiel, vous avez créé un programme à l'aide du mot-clé go pour démarrer des goroutines s'exécutant simultanément qui impriment des nombres au fur et à mesure de leur exécution. Une fois ce programme en cours d'exécution, vous avez créé un nouveau canal de valeurs int en utilisant make(chan int), puis utilisé le canal pour générer des nombres dans une goroutine et les envoyer à une autre goroutine pour les imprimer à l'écran. Enfin, vous avez démarré plusieurs goroutines "d'impression" en même temps comme exemple de la façon dont les canaux et les goroutines peuvent être utilisés pour accélérer vos programmes sur des ordinateurs multicœurs.

Si vous souhaitez en savoir plus sur la simultanéité dans Go, le document Effective Go créé par l'équipe Go donne beaucoup plus de détails. Le billet de blog Concurrency is not parallelism Go est également un suivi intéressant sur la relation entre la concurrence et le parallélisme, deux termes qui sont parfois confondus à tort pour signifier la même chose.

Ce tutoriel fait également partie de la série DigitalOcean Comment coder dans Go. La série couvre un certain nombre de sujets Go, de l'installation de Go pour la première fois à l'utilisation du langage lui-même.