TL; TR: Veuillez simplement aller à la dernière partie et me dire comment vous pourriez résoudre ce problème.
J'ai commencé à utiliser Golang ce matin en provenance de Python. Je veux appeler plusieurs fois un exécutable de source fermée à partir de Go, avec un bit de concurrence, avec différents arguments de ligne de commande. Mon code résultant fonctionne très bien mais j'aimerais avoir votre avis afin de l'améliorer. Comme je suis à un stade précoce d'apprentissage, je vais également expliquer mon flux de travail.
Par souci de simplicité, supposons ici que ce "programme externe de source fermée" est zenity
, un outil de ligne de commande Linux qui peut afficher des boîtes de message graphiques à partir de la ligne de commande.
Donc, dans Go, j'irais comme ceci:
package main
import "os/exec"
func main() {
cmd := exec.Command("zenity", "--info", "--text='Hello World'")
cmd.Run()
}
Cela devrait fonctionner parfaitement. Notez que .Run()
est un équivalent fonctionnel à .Start()
suivi de .Wait()
. C'est génial, mais si je voulais exécuter ce programme une seule fois, tout le programme ne valait pas la peine. Faisons donc cela plusieurs fois.
Maintenant que cela fonctionnait, je voudrais appeler mon programme plusieurs fois, avec des arguments de ligne de commande personnalisés (ici juste i
pour des raisons de simplicité).
package main
import (
"os/exec"
"strconv"
)
func main() {
NumEl := 8 // Number of times the external program is called
for i:=0; i<NumEl; i++ {
cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
cmd.Run()
}
}
D'accord, nous l'avons fait! Mais je ne vois toujours pas l'avantage de Go over Python… Ce morceau de code est en fait exécuté de façon sérielle. J'ai un processeur multicœur et j'aimerais en profiter. Ajoutons donc un peu de simultanéité avec les goroutines.
Réécrivons notre code pour rendre les choses plus faciles à appeler et à réutiliser et ajoutons le fameux mot clé go
:
package main
import (
"os/exec"
"strconv"
)
func main() {
NumEl := 8
for i:=0; i<NumEl; i++ {
go callProg(i) // <--- There!
}
}
func callProg(i int) {
cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
cmd.Run()
}
Rien! Quel est le problème? Tous les goroutines sont exécutés en même temps. Je ne sais pas vraiment pourquoi zenity n'est pas exécuté mais AFAIK, le programme Go s'est arrêté avant que le programme externe zenity puisse même être initialisé. Cela a été confirmé par l'utilisation de time.Sleep
: Attendre quelques secondes était suffisant pour permettre à l'instance 8 de zenity de se lancer. Je ne sais pas si cela peut être considéré comme un bug.
Pour aggraver les choses, le vrai programme que j'aimerais appeler prend du temps à s'exécuter. Si j'exécute 8 instances de ce programme en parallèle sur mon processeur à 4 cœurs, ça va perdre du temps à faire beaucoup de changement de contexte… Je ne sais pas comment les goroutines Go se comportent, mais exec.Command
va lancer zenity 8 fois dans 8 threads différents. Pour aggraver encore les choses, je veux exécuter ce programme plus de 100 000 fois. Faire tout cela à la fois dans des goroutins ne sera pas du tout efficace. Je voudrais quand même tirer parti de mon processeur à 4 cœurs!
Les ressources en ligne ont tendance à recommander l'utilisation de sync.WaitGroup
Pour ce type de travail. Le problème avec cette approche est que vous travaillez essentiellement avec des lots de goroutines: si je crée WaitGroup de 4 membres, le programme Go attendra tous les 4 programmes externes pour terminer avant d'appeler un nouveau lot de 4 programmes. Ce n'est pas efficace: le CPU est gaspillé, encore une fois.
Certaines autres ressources ont recommandé l'utilisation d'un canal en mémoire tampon pour effectuer le travail:
package main
import (
"os/exec"
"strconv"
)
func main() {
NumEl := 8 // Number of times the external program is called
NumCore := 4 // Number of available cores
c := make(chan bool, NumCore - 1)
for i:=0; i<NumEl; i++ {
go callProg(i, c)
c <- true // At the NumCoreth iteration, c is blocking
}
}
func callProg(i int, c chan bool) {
defer func () {<- c}()
cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
cmd.Run()
}
Cela semble moche. Les chaînes n'étaient pas prévues à cet effet: j'exploite un effet secondaire. J'adore le concept de defer
mais je déteste devoir déclarer une fonction (même un lambda) pour faire sortir une valeur du canal factice que j'ai créé. Oh, et bien sûr, utiliser un canal factice est, en soi, moche.
Maintenant, nous avons presque terminé. Je dois juste prendre en compte un autre effet secondaire: le programme Go se ferme avant que toutes les fenêtres pop-up zenity ne soient fermées. En effet, lorsque la boucle est finie (à la 8ème itération), rien n'empêche le programme de se terminer. Cette fois, sync.WaitGroup
Sera utile.
package main
import (
"os/exec"
"strconv"
"sync"
)
func main() {
NumEl := 8 // Number of times the external program is called
NumCore := 4 // Number of available cores
c := make(chan bool, NumCore - 1)
wg := new(sync.WaitGroup)
wg.Add(NumEl) // Set the number of goroutines to (0 + NumEl)
for i:=0; i<NumEl; i++ {
go callProg(i, c, wg)
c <- true // At the NumCoreth iteration, c is blocking
}
wg.Wait() // Wait for all the children to die
close(c)
}
func callProg(i int, c chan bool, wg *sync.WaitGroup) {
defer func () {
<- c
wg.Done() // Decrease the number of alive goroutines
}()
cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
cmd.Run()
}
Terminé.
Je ne parle pas de fils; comment Go gère les goroutines en interne n'est pas pertinent. Je veux vraiment dire limiter le nombre de goroutines lancées en même temps: exec.Command
Crée un nouveau thread à chaque appel, donc je devrais contrôler le nombre de fois qu'il est appelé.
Je ne peux pas me convaincre que ces canaux factices sont la voie à suivre.
Je ferais apparaître 4 goroutines ouvrières qui lisent les tâches à partir d'un canal commun. Les goroutines qui sont plus rapides que les autres (car elles sont programmées différemment ou obtiennent des tâches simples) recevront plus de tâches de ce canal que les autres. En plus de cela, j'utiliserais un sync.WaitGroup pour attendre que tous les travailleurs aient fini. La partie restante n'est que la création des tâches. Vous pouvez voir un exemple d'implémentation de cette approche ici:
package main
import (
"os/exec"
"strconv"
"sync"
)
func main() {
tasks := make(chan *exec.Cmd, 64)
// spawn four worker goroutines
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
for cmd := range tasks {
cmd.Run()
}
wg.Done()
}()
}
// generate some tasks
for i := 0; i < 10; i++ {
tasks <- exec.Command("zenity", "--info", "--text='Hello from iteration n."+strconv.Itoa(i)+"'")
}
close(tasks)
// wait for the workers to finish
wg.Wait()
}
Il existe probablement d'autres approches possibles, mais je pense que c'est une solution très propre et facile à comprendre.
Une approche simple de la limitation (exécutez f()
N fois mais maximum maxConcurrency
simultanément), juste un schéma:
package main
import (
"sync"
)
const maxConcurrency = 4 // for example
var throttle = make(chan int, maxConcurrency)
func main() {
const N = 100 // for example
var wg sync.WaitGroup
for i := 0; i < N; i++ {
throttle <- 1 // whatever number
wg.Add(1)
go f(i, &wg, throttle)
}
wg.Wait()
}
func f(i int, wg *sync.WaitGroup, throttle chan int) {
defer wg.Done()
// whatever processing
println(i)
<-throttle
}
Je n'appellerais probablement pas le canal throttle
"factice". À mon humble avis, c'est une manière élégante (ce n'est pas mon invention bien sûr), comment limiter la concurrence.
BTW: Veuillez noter que vous ignorez l'erreur renvoyée par cmd.Run()
.
essayez ceci: https://github.com/korovkin/limiter
limiter := NewConcurrencyLimiter(10)
limiter.Execute(func() {
zenity(...)
})
limiter.Wait()