La concurrence est-elle toujours le meilleur choix ? Découvrons-le
Cet article vise à démontrer quand l’utilisation de la simultanéité peut être plus avantageuse en fonction du type de charge de travail de votre programme.
Par conséquent, je ne couvrirai pas les termes de simultanéité populaires tels que les goroutines, les groupes d’attente, les canaux et les courses de données, entre autres. J’ai en tête de créer une série d’articles expliquant en détail des exemples utiles des blocs de construction et des bizarreries de la concurrence les plus utilisés.
L’une des erreurs les plus courantes à mes débuts avec la concurrence était d’essayer d’implémenter les problèmes en utilisant la concurrence dès le début. Non seulement cela rendait plus difficile la recherche de la solution optimale, mais il y avait un risque que l’utilisation de la concurrence soit inappropriée pour ce problème.
Sur la base de mon expérience, je vous suggère de commencer par obtenir une solution séquentielle fonctionnelle, puis d’itérer, si nécessaire, pour vérifier s’il est logique d’implémenter une solution concurrente.
Essayez de résoudre le problème avec une solution séquentielle avant de penser à la concurrence.
Je n’expliquerai pas en détail ce que sont la concurrence et le parallélisme car il existe de nombreux articles bien expliqués sur Internet. Néanmoins, je laisserai quelques définitions de l’un des créateurs de Go :
« La simultanéité consiste à gérer plusieurs choses à la fois. Le parallélisme consiste à faire beaucoup de choses à la fois. —Rob Pike
« La concurrence concerne la structure et le parallélisme concerne l’exécution. En d’autres termes, la concurrence est un moyen de structurer une chose afin que vous puissiez (peut-être) utiliser le parallélisme pour faire un meilleur travail. —Rob Pike
Un moyen utile de savoir si la simultanéité peut convenir est de comprendre la charge de travail de votre programme. Il existe principalement deux types de charges de travail à prendre en compte lors de l’analyse de la simultanéité :
Charges de travail liées au processeur
Ils décrivent une situation où l’exécution du programme dépend fortement de la CPU ; elle est directement liée à la vitesse de l’unité centrale de traitement.
Ce sont les charges de travail parfaites pour tirer parti de la simultanéité en utilisant le parallélisme lorsque nous avons plus d’un cœur disponible (nous verrons dans la section sur l’analyse comparative que ce n’est pas toujours vrai). Nous ne verrons aucune amélioration des performances si nous l’exécutons dans un seul cœur avec plusieurs goroutines, car l’exécution en cours fera perdre un temps précieux à la planification des goroutines entrantes et sortantes, également appelée commutation de contexte.
Charges de travail liées aux E/S
Ils décrivent une situation où l’exécution du programme est fortement dépendante du système d’entrée-sortie mais pas des ressources CPU.
Une fois qu’un bloc de tâches passe par des E/S, le planificateur Go reprendra toute autre tâche sur ces charges de travail. Par conséquent, nous pouvons tirer parti de plusieurs goroutines dans un seul cœur pour améliorer les performances. L’utilisation du parallélisme ne se terminera pas par une augmentation des performances.
Nous devons faire attention à ne pas générer plus de goroutines que de cœurs disponibles pour éviter de perdre en performances.
Je vais écrire quelques fonctions naïves avec lesquelles nous pouvons exécuter des benchmarks pour vérifier laquelle est la plus performante, en essayant de tirer parti de la concurrence et du parallélisme.
Voici les fonctions Golang utilisées :
Voici les fonctions de benchmarking utilisées :
Les résultats de l’analyse comparative peuvent varier en fonction de la machine sur laquelle ils sont exécutés. Vous devriez comparer plus d’une fois tout en essayant de rendre votre machine aussi inactive que possible.
Utilisez le -count
indicateur lors de l’exécution d’un benchmark pour l’exécuter un certain nombre de fois ; Je l’ai sauté pour le bien de l’exemple.
J’exécute ces benchmarks sur mon ordinateur portable avec un total de dix cœurs, donc des versions simultanées des fonctions qui utilisent Golang runtime.NumCPU()
utilisera un total de dix goroutines.
Voici à quoi ressemble un benchmark de charge de travail sans parallélisme lors de l’exécution d’un thread :
go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially 1869 3147006 ns/op
BenchmarkComputeHighNumberConcurrently 632 9490364 ns/op
L’exécution de la solution séquentielle est environ 3 fois plus rapide que la solution simultanée. On peut s’y attendre en raison de la surcharge causée par le changement de contexte des goroutines.
Voici un exemple de benchmark de charge de travail exploitant le parallélisme et exécutant dix threads :
go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeHighNumberSequentially-10 1794 3145169 ns/op
BenchmarkComputeHighNumberConcurrently-10 3162 1699410 ns/op
En exécutant les dix goroutines séparément sur chaque thread, nous pouvons observer que maintenant la solution concurrente est ~ 2 fois plus rapide que la solution séquentielle car dix goroutines font le travail simultanément en parallèle.
Malgré ces résultats, toutes les charges de travail CPU ne correspondent pas bien à la simultanéité. Ensuite, nous allons comparer l’algorithme de tri par fusion pour le démontrer :
go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially 3908991 1530 ns/op
BenchmarkMergeSortConcurrently 452580 12307 ns/op
go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkMergeSortSequentially-10 4068475 1496 ns/op
BenchmarkMergeSortConcurrently-10 533010 12462 ns/op
En quoi la solution séquentielle est-elle peut-être plus efficace que l’utilisation de la concurrence avec le parallélisme ? Analysons ce que fait l’algorithme de tri par fusion pour comprendre les résultats précédents.
Nous générons quelques goroutines à chaque itération pour calculer les premier et deuxième morceaux de la liste. Comme il s’agit d’un algorithme récursif, nous nous retrouverons dans une situation où nous ne générerons des goroutines que pour calculer un seul élément. C’est très inefficace. Nous sommes dans une situation où il est plus coûteux de créer et de planifier une goroutine que de fusionner un seul élément dans la même goroutine.
Ainsi, si la charge de travail que nous voulons paralléliser est si petite que la création et la planification d’une goroutine sont plus coûteuses, les avantages de la simultanéité et du parallélisme sont perdus.
Lorsqu’il est très coûteux de diviser le travail ou de combiner les résultats, la simultanéité n’est peut-être pas un bon choix.
Voici une charge de travail sans parallélisme, exécutant un thread :
go test -cpu=1 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially 2 2814863021 ns/op
BenchmarkComputeURLConcurrently 7 838693786 ns/op
L’exécution de la solution simultanée est environ 4 fois plus rapide. Ceci est attendu puisque les goroutines des charges de travail d’E/S entrent et sortent des états d’attente, tirant parti de nouvelles goroutines pour utiliser efficacement le même thread.
Exemple de charge de travail exploitant le parallélisme, exécutant dix threads :
go test -cpu=10 -run=XXX -bench=. -benchtime=5s
goos: darwin
goarch: arm64
pkg: concurrency-workloads
BenchmarkComputeURLSequentially-10 2 2548004562 ns/op
BenchmarkComputeURLConcurrently-10 7 1001367625 ns/op
En exécutant la charge de travail avec dix goroutines, chacune sur un thread séparé, il n’y a aucune amélioration des performances. Comme mentionné précédemment, cela est dû au fait que nous pouvons gérer efficacement le changement de contexte dans un seul thread pour les charges de travail d’E/S, de sorte que l’utilisation du parallélisme ne se termine pas par une augmentation des performances.
D’une manière générale, nous pourrions conclure que les charges de travail liées au processeur conviennent parfaitement à la concurrence en tirant parti du parallélisme. Alors que d’un autre côté, les charges de travail liées aux E/S n’étaient pas adaptées au parallélisme.
Cependant, chaque charge de travail doit être analysée avec soin, car nous avons observé que l’algorithme de tri par fusion lié au processeur n’était pas adapté à la concurrence et au parallélisme en raison de la nature de sa mise en œuvre.
Enfin, je tiens à mentionner que l’ajout de la simultanéité s’accompagne toujours d’une complexité supplémentaire, alors utilisez-la judicieusement et uniquement lorsqu’elle améliore les performances !