Cet article fournit une introduction au traitement parallèle. Il mentionne également la nouvelle unité d'exécution parallèle Ractor en cours de développement en Ruby.
Tout d'abord, je résumerai les termes qui sont souvent confus lors de l'examen de ce sujet.
Dans le ** traitement parallèle **, plusieurs processus s'exécutent en même temps à un moment donné. ** Le traitement parallèle ** traite plusieurs processus dans l'ordre par division de temps. Contrairement au traitement parallèle, un seul processus s'exécute en même temps à un moment donné.
Si le moment auquel une pluralité de processus sont exécutés est montré dans l'ordre chronologique, l'image montrée ci-dessous est obtenue. (Le traitement est exécuté uniquement dans la pièce avec la ligne bleue)
Cet article traite du comportement du traitement parallèle, mais sachez que même si vous écrivez du code pour le traitement parallèle, il peut finir par se comporter comme un traitement parallèle. (Par exemple, un processeur à 1 cœur ne peut pas exécuter deux processus ou plus en parallèle.) Le système d'exploitation et la machine virtuelle planifient cette zone d'une bonne manière.
En général, il existe deux méthodes principales pour réaliser un traitement parallèle: ** multi-processus ** et ** multi-thread **. Le multi-processus est une méthode permettant de créer plusieurs processus et d'exécuter chaque processus un processus à la fois. Le multi-threading est une méthode permettant de créer plusieurs threads dans un processus et d'exécuter chaque thread un processus à la fois.
Dans le cas du multi-processus, l'espace mémoire est séparé dans chaque processus. Par conséquent, il est fondamentalement impossible de passer des variables entre les processus. Il est également hautement sécurisé car il empêche les interactions involontaires basées sur la mémoire entre les processus. L'inconvénient est que chaque processus dispose d'un espace mémoire, de sorte que l'utilisation totale de la mémoire a tendance à augmenter. (Cependant, sous Linux, la mémoire entre les processus est partagée autant que possible par le mécanisme appelé Copie en écriture.)
Dans le cas du multithreading, un processus a plusieurs threads, donc l'espace mémoire est partagé entre les threads. Par conséquent, l'utilisation de la mémoire peut être supprimée et, selon l'implémentation, la création et la commutation de threads sont plus légères que la création et la commutation de processus. Cependant, comme les threads peuvent s'influencer les uns les autres via la mémoire, des bogues tels que des conflits de données ont tendance à se produire. En général, la programmation multithread a de nombreux éléments à prendre en compte et est difficile à implémenter correctement.
L'unité dans laquelle un processus est exécuté en traitement parallèle est appelée ** unité d'exécution parallèle **. Dans le cas du multi-processus, l'unité d'exécution parallèle est un processus, et dans le cas du multi-thread, c'est un thread.
Il existe deux principales méthodes d'implémentation du traitement des threads: ** thread natif ** et ** thread vert **. Le thread natif est une méthode pour réaliser un traitement multi-thread en utilisant l'implémentation du système d'exploitation telle qu'elle est. Puisqu'il appartient au système d'exploitation de planifier les threads (décider quel thread exécuter), l'implémentation du système de traitement devient simple. D'un autre côté, il y a un inconvénient à ce que le traitement de la création et de la commutation de threads (appelé changement de contexte) soit lourd. (Au fait, le thread natif est un concept qui combine le thread du noyau et le processus léger, mais les détails sont omis. Je pense que le thread natif et le thread du noyau sont souvent mélangés.)
Le thread vert est un thread implémenté à l'origine par une machine virtuelle de traitement de langage (par exemple, yarv de cruby, jvm de java, etc.), et est une méthode de réalisation d'un traitement multi-thread. La goroutine de golang est aussi une sorte de fil vert, et sa légèreté de fonctionnement est trop réputée. Dans cruby, il a été implémenté par un thread vert avant la version 1.9, mais il a maintenant été modifié pour utiliser un thread natif. Les threads verts sont également appelés threads utilisateur.
À titre d'exemple, l'implémentation du traitement parallèle dans Ruby est illustrée. Dans Ruby, vous pouvez facilement décrire le traitement parallèle en utilisant le gem Parallel.
Le code multi-processus ressemble à ceci:
multi_process.rb
require 'parallel'
Parallel.each(1..10, in_processes: 10) do |i|
sleep 10
puts i
end
Si vous exécutez ce code et regardez la liste des processus, cela ressemble à ceci: Vous pouvez voir qu'un processus principal et 10 processus enfants se produisent.
$ ps aux | grep ruby
PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND PRI STIME UTIME
79050 9.7 0.1 4355568 14056 s005 S+ 2:39PM 0:00.28 ruby mp.rb
79072 0.0 0.0 4334968 1228 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79071 0.0 0.0 4334968 1220 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79070 0.0 0.0 4334968 1244 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79069 0.0 0.0 4334968 1244 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79068 0.0 0.0 4334968 1172 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79067 0.0 0.0 4334968 1180 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79066 0.0 0.0 4334968 1208 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79065 0.0 0.0 4334968 1252 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79064 0.0 0.0 4334968 1168 s005 S+ 2:39PM 0:00.00 ruby mp.rb
79063 0.0 0.0 4334968 1168 s005 S+ 2:39PM 0:00.00 ruby mp.rb
Le code multithread ressemble à ceci:
multi_threads.rb
require 'parallel'
Parallel.each(1..10, in_threads: 10) do |i|
sleep 10
puts i
end
Regardez la liste des fils ici aussi.
Si vous ajoutez -L
à la commande ps
, le thread apparaîtra comme un processus.
Sans -L
, il n'y a qu'un seul processus, mais avec -L
, 11 lignes sont affichées.
De plus, la colonne NLWP
indique le nombre de threads dans le processus, et comme il s'agit de 11 (thread principal x1 + thread de travail x10), on peut voir qu'il s'agit d'un processus multi-thread.
$ ps aux | grep mt.rb
4419 1.0 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
$ ps aux -L | grep mt.rb
PID LWP %CPU NLWP %MEM VSZ RSS TTY STAT START TIME COMMAND
4419 4419 6.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4453 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4454 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4455 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4456 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4457 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4458 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4460 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4461 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4462 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
4419 4463 0.0 11 0.6 850176 12384 pts/1 Sl+ 15:41 0:00 ruby mt.rb
Dans le traitement multi-thread, divers problèmes peuvent survenir car le traitement est exécuté en parallèle avec une pluralité de threads partageant une mémoire. L'un des principaux problèmes est la ** course aux données **.
Des courses de données peuvent se produire avec un code comme celui ci-dessous. Ce code tente de trouver la somme des nombres entiers de 1 à 10, mais en raison de problèmes de course de données, il peut ne pas être possible de trouver la somme correctement.
require 'parallel'
sum = 0;
Parallel.each(1..10, in_threads: 10) do |i|
add = sum + i
sum = add
end
puts sum
Dans ce code, chaque thread partage la variable «sum», et chaque thread lit et écrit la somme en même temps. Par conséquent, le contenu écrit dans un thread peut être écrasé par un autre thread. Par conséquent, il y a un problème que le code ci-dessus peut ne pas être en mesure de calculer la somme normalement.
Un moyen courant de résoudre les problèmes de course de données consiste à prendre des verrous exclusifs entre les threads.
require 'parallel'
sum = 0;
m = Mutex.new
Parallel.each(1..10, in_threads: 10) do |i|
m.lock
add = sum + i
sum = add
m.unlock
end
puts sum
En conséquence, un seul thread est exécuté à la fois pendant que le verrou est maintenu, et la course aux données est éliminée.
Le code qui prend correctement en compte ces problèmes et fonctionne bien en multithreading est appelé ** thread safe **.
** GIL ** est souvent évoqué dans le traitement multithread dans des langages légers (ruby, python, etc.). D'ailleurs, en Ruby, il s'appelle GVL (Giant VM Lock).
GIL empêche plusieurs threads d'être exécutés en même temps en effectuant un contrôle exclusif entre les threads. En d'autres termes, un seul thread peut être exécuté à la fois dans un interpréteur et une machine virtuelle. Les raisons et les avantages de ce besoin comprennent:
Grâce à GIL, le code Ruby de programmation multithread que je viens d'illustrer fonctionne bien sans Mutex. Ce comportement n'est pas différent de l'idée fondamentale de Ruby de faciliter la programmation.
Cependant, le fait qu'un seul thread puisse être exécuté à la fois signifie que le traitement parallèle d'origine est impossible. C'est pourquoi il est souvent mentionné que Ruby et Python ne sont pas adaptés au calcul parallèle.
Exceptionnellement, lors de l'attente d'E / S, le thread annule le GIL, de sorte que plusieurs threads peuvent exécuter le traitement en même temps. Pour cette raison, dans les traitements qui nécessitent beaucoup d'attente d'E / S (serveur Web, etc.), le multithreading est pratiquement utilisé même dans un système de traitement avec GIL.
Étant donné que les serveurs HTTP doivent généralement traiter chaque demande en même temps, un traitement parallèle est souvent mis en œuvre. Les serveurs HTTP typiques de Ruby sont ** unicorn ** et ** puma **, le premier est une implémentation multi-processus et le second est une implémentation multi-thread.
Les performances de la licorne et du puma sont comparées dans ce blog.
La conclusion de ce blog est la suivante:
C'est un résultat convaincant même en considérant le mécanisme ci-dessus.
Jusqu'à présent, nous avons expliqué comment réaliser un traitement parallèle et montré l'implémentation dans Ruby et ses performances. Le traitement multithread dans Ruby a le problème qu'il ne peut pas atteindre ses performances d'origine en raison de GVL. Ractor (anciennement Guild) est un nouveau mécanisme de traitement parallèle Ruby qui a été créé pour résoudre ce problème.
Ractor peut atteindre de véritables performances multithread tout en conservant l'avantage de rendre la programmation multithread GVL traditionnelle plus facile à gérer.
Je vais vous expliquer le mécanisme.
La course de données se produit car plusieurs threads peuvent lire et écrire dans une variable car les threads partagent la mémoire. La solution à cela est
Dans Ractor, trois méthodes ont été adoptées. Cette nouvelle unité d'exécution parallèle s'appelle Ractor. Un processus Ruby a un ou plusieurs Ractors, et un Ractor a un ou plusieurs threads. Puisque chaque Ractor fonctionne dans un espace mémoire séparé, il n'y a aucun problème avec le partage de mémoire comme dans les threads conventionnels.
Source: https://www.slideshare.net/KoichiSasada/guild-prototype
De plus, le code Ruby avant l'introduction de Ractor peut maintenir la compatibilité descendante en l'exécutant dans un seul Ractor.
Étant donné que les Ractors ne partagent pas la mémoire, vous pouvez trouver difficile de transmettre des informations.
Pour résoudre ce problème, il existe également une fonction appelée «canal» qui réalise la communication entre les Ractors.
Les objets que vous souhaitez partager ne peuvent être transmis que via channel
.
Les objets sont classés en ** objets partageables ** et ** objets non partageables **.
Un objet partageable est un objet, comme une constante en lecture seule, qui ne peut pas provoquer de courses de données même s'il est partagé entre Factors. Les objets partageables peuvent être librement partagés via le canal.
Les objets non partageables font référence aux objets mutables généraux. Le passage de cet objet à travers le canal entraîne une copie profonde ou un déplacement de la sémantique. Dans le cas de la copie profonde, le coût du traitement de la copie et l'utilisation de la mémoire augmentent, mais il est aussi sûr et facile à comprendre que le multi-processus. Dans le cas de la sémantique de déplacement, la propriété de l'objet est transférée à un autre facteur. Par conséquent, le Ractor d'origine ne peut pas faire référence à l'objet, mais contrairement à la copie profonde, le coût de traitement et l'utilisation de la mémoire n'augmentent pas autant que la copie.
Résumé:
Ce faisant, Ractor réalise une programmation multi-thread facile tout en maintenant la sécurité des threads.
Ractor est une unité d'exécution parallèle située entre les processus et les threads. En sélectionnant correctement les informations que le développeur souhaite partager entre Ractors, le traitement parallèle peut être réalisé sans augmenter l'utilisation de la RAM autant que le multi-processus et sans la dégradation des performances due à GIL contrairement au multi-thread.
Ractor reçoit beaucoup d'attention en tant que nouvelle fonctionnalité de Ruby 3. Il semble que Ractor lui-même soit encore en développement, et il faudra un peu de temps avant qu'il n'atteigne la portée des utilisateurs généraux de Ruby. Dans le futur, on s'attend à ce que la bibliothèque multithread de Ruby soit réimplémentée dans Ractor. Il est peut-être proche du moment où les serveurs HTTP qui remplacent Puma deviendront courants.
Ceci est une phrase résumée pour étude. Je vous serais reconnaissant si vous pouviez signaler des erreurs!
Recommended Posts