Introduction au traitement parallèle + nouvelle unité d'exécution parallèle Ractor dans Ruby

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.

À propos du traitement parallèle (parallèle) et du traitement parallèle (simultané)

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) image.png

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.

Multi-processus et multi-thread

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.

(Supplément) Comment réaliser le traitement des threads

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.

Exemple de code multi-thread, multi-processus

À 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

Difficulté de la programmation multithread

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 **.

À propos de Global Interpreter Lock

** 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:

  1. Lors de la programmation multi-thread, il n'est plus nécessaire de décrire le traitement exclusif pour chaque structure de données individuelle.
  1. Les implémentations de plug-ins natives ne sont souvent pas thread-safe, mais pour les exécuter en toute sécurité sans modifier leurs implémentations
  2. L'implémentation de la VM elle-même n'est pas thread-safe

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.

Exemple d'implémentation

É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:

image.png image.png Source

C'est un résultat convaincant même en considérant le mécanisme ci-dessus.

À propos de Ractor

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.

L'idée de Ractor

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

  1. Rendre toutes les variables en lecture seule (immuable)
  2. Les variables partagées entre les threads sont spécifiées par type et détectées au moment de la compilation pour les processus qui ne sont pas thread-safe.
  3. Rendre la mémoire indépendante pour chaque unité d'exécution parallèle

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.

image.png 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.

Comment partager des données entre Ractors

É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.

Le cadeau du racteur

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.

Les références

Ceci est une phrase résumée pour étude. Je vous serais reconnaissant si vous pouviez signaler des erreurs!

Recommended Posts

Introduction au traitement parallèle + nouvelle unité d'exécution parallèle Ractor dans Ruby
Introduction à Ruby 2
Exécution parallèle en Java
Introduction à Micronaut 2 ~ Test unitaire ~
Traitement parallèle mesuré avec Java
Comment itérer indéfiniment en Ruby
Essayez d'implémenter Yuma dans Ruby
Introduction aux algorithmes avec somme cumulée Java
Comment installer Bootstrap dans Ruby
[Super Introduction] À propos des symboles dans Ruby
Passer de SQLite3 à PostgreSQL dans un nouveau projet Ruby on Rails
Comment déboguer le traitement dans le modèle Ruby on Rails avec juste la console
Comment insérer un traitement avec n'importe quel nombre d'éléments dans le traitement itératif dans Ruby
Procédure de création d'un environnement d'exécution Ruby localement
Comment implémenter le traitement asynchrone dans Outsystems
Comment démarrer un indice à partir d'un nombre arbitraire dans le traitement itératif Ruby