Comme vous le savez probablement, Ruby dispose de plusieurs implémentations, telles que MRI, JRuby, Rubinius, Opal, RubyMotion, etc., et chacune d'entre elles peut utiliser un modèle d'exécution de code différent. Cet article se concentre sur les trois premières et compare MRI
Comme vous le savez probablement, Ruby a plusieurs implémentations, telles que MRI, JRuby, Rubinius, Opal, RubyMotion, etc. et chacune d'entre elles peut utiliser un modèle différent de code l'exécution. Cet article se concentre sur les trois premiers et compare MRI (l'implémentation la plus populaire) avec JRuby et Rubinius en exécutant quelques exemples de scripts censés évaluer l'adéquation du forking et du threading dans diverses situations, telles que le traitement d'algorithmes gourmands en ressources processeur, la copie de fichiers, etc.
Fourchette
est un nouveau processus enfant (une copie du processus parent)
communique avec d'autres via des canaux de communication interprocessus (IPC) tels que des files d'attente de messages, des fichiers, des sockets, etc.
existe même lorsque le processus parent prend fin
est un appel POSIX - fonctionne principalement sur les plates-formes Unix
Fil
est "seulement" un contexte d'exécution, travaillant dans le cadre d'un processus
partage toute la mémoire avec les autres (par défaut, il utilise moins de mémoire qu'un fork)
communique avec les autres par le biais d'objets à mémoire partagée
meurt avec un processus
introduit des problèmes typiques de multithreading tels que la famine, les blocages, etc.
Il existe de nombreux outils utilisant des forks et des threads, qui sont utilisés quotidiennement, par exemple Unicorn (forks) et Puma (threads) au niveau des serveurs d'application, Resque (forks) et Sidekiq (threads) au niveau des tâches d'arrière-plan, etc.
Le tableau suivant présente la prise en charge du forking et du threading dans les principales implémentations de Ruby.
Mise en œuvre de Ruby
Fourche
Filetage
IRM
Oui
Oui (limité par le GIL**)
JRuby
–
Oui
Rubinius
Oui
Oui
Deux autres mots magiques reviennent comme un boomerang dans ce sujet - parallélisme et concurrence - nous devons les expliquer un peu. Tout d'abord, ces termes ne peuvent pas être utilisés de manière interchangeable. En résumé, nous pouvons parler de parallélisme lorsque deux tâches ou plus sont traitées exactement en même temps. La concurrence a lieu lorsque deux tâches ou plus sont traitées dans des périodes de temps qui se chevauchent (pas nécessairement en même temps). Certes, il s'agit d'une explication générale, mais elle est suffisante pour vous aider à faire la différence et à comprendre le reste de cet article.
Le tableau suivant présente la prise en charge du parallélisme et de la concurrence.
Mise en œuvre de Ruby
Parallélisme (via les fourches)
Parallélisme (via les threads)
Concurrence
IRM
Oui
Non
Oui
JRuby
–
Oui
Oui
Rubinius
Oui
Oui (depuis la version 2.X)
Oui
Finie la théorie, place à la pratique !
Le fait de disposer d'une mémoire séparée n'entraîne pas nécessairement une consommation identique à celle du processus parent. Il existe plusieurs techniques d'optimisation de la mémoire. L'une d'entre elles est le Copy on Write (CoW), qui permet au processus parent de partager la mémoire allouée avec le processus enfant sans la copier. Avec CoW, de la mémoire supplémentaire n'est nécessaire qu'en cas de modification de la mémoire partagée par un processus enfant. Dans le contexte de Ruby, toutes les implémentations ne sont pas compatibles avec la technique CoW. Par exemple, MRI la supporte entièrement depuis la version 2.X. Avant cette version, chaque fork consommait autant de mémoire qu'un processus parent.
L'un des principaux avantages/inconvénients de l'IRM (rayez la mention inutile) est l'utilisation du GIL (Global Interpreter Lock). En résumé, ce mécanisme est responsable de la synchronisation de l'exécution des threads, ce qui signifie qu'un seul thread peut être exécuté à la fois. Mais attendez... Cela signifie-t-il qu'il n'y a aucun intérêt à utiliser des threads dans l'IRM ? La réponse se trouve dans la compréhension des mécanismes internes de la GIL... ou au moins dans l'examen des exemples de code de cet article.
Cas de test
Afin de présenter le fonctionnement du forking et du threading dans les implémentations de Ruby, j'ai créé une classe simple appelée Test et quelques autres qui en héritent. Chaque classe a une tâche différente à traiter. Par défaut, chaque tâche s'exécute quatre fois en boucle. De plus, chaque tâche s'exécute contre trois types d'exécution de code : séquentielle, avec des fourches et avec des threads. En outre, Benchmark.bmbm exécute le bloc de code deux fois - la première fois pour faire fonctionner l'environnement d'exécution, la seconde pour effectuer des mesures. Tous les résultats présentés dans cet article ont été obtenus lors de la deuxième exécution. Bien entendu, même les bmbm ne garantit pas une isolation parfaite, mais les différences entre plusieurs exécutions du code sont insignifiantes.
exiger "benchmark"
classe Test
AMOUNT = 4
def run
Benchmark.bmbm do |b|
b.report("sequential") { sequential }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
private
def sequential
AMOUNT.times { perform }
end
def forking
AMOUNT.times do
fork do
exécuter
fin
fin
Process.waitall
rescue NotImplementedError => e
# la méthode fork n'est pas disponible dans JRuby
met e
fin
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
exécuter
end
fin
threads.map(&:join)
fin
def perform
raise "non implémenté"
end
end
Test de charge
Exécute des calculs en boucle pour générer une charge importante de l'unité centrale.
classe LoadTest < Test
def perform
1000.times { 1000.times { 2**3**4 } }
end
end
Exécutons-le...
LoadTest.new.run
...et vérifier les résultats
IRM
JRuby
Rubinius
séquentiel
1.862928
2.089000
1.918873
bifurcation
0.945018
–
1.178322
filetage
1.913982
1.107000
1.213315
Comme vous pouvez le constater, les résultats des exécutions séquentielles sont similaires. Bien sûr, il y a une petite différence entre les solutions, mais elle est due à l'implémentation sous-jacente des méthodes choisies dans les différents interprètes.
La bifurcation, dans cet exemple, permet un gain de performance significatif (le code s'exécute presque deux fois plus vite).
Le threading donne les mêmes résultats que le forking, mais seulement pour JRuby et Rubinius. L'exécution de l'échantillon avec des threads sur l'IRM prend un peu plus de temps que la méthode séquentielle. Il y a au moins deux raisons à cela. Premièrement, GIL force l'exécution séquentielle des threads, donc dans un monde parfait le temps d'exécution devrait être le même que pour l'exécution séquentielle, mais il y a aussi une perte de temps pour les opérations GIL (passage d'un thread à l'autre, etc.). Deuxièmement, la création de threads nécessite également un certain temps de latence.
Cet exemple ne permet pas de répondre à la question du sens des fils d'utilisation dans l'IRM. Voyons-en un autre.
Test du sommeil
Exécute une méthode de sommeil.
classe SnoozeTest < Test
def perform
sleep 1
end
end
Voici les résultats
IRM
JRuby
Rubinius
séquentiel
4.004620
4.006000
4.003186
bifurcation
1.022066
–
1.028381
filetage
1.001548
1.004000
1.003642
Comme vous pouvez le voir, chaque implémentation donne des résultats similaires non seulement dans les exécutions séquentielles et de forking, mais aussi dans les exécutions de threading. Alors, pourquoi MRI a le même gain de performance que JRuby et Rubinius ? La réponse se trouve dans l'implémentation de dormir.
IRM dormir est mise en œuvre avec la méthode rb_thread_wait_for C, qui en utilise une autre appelée native_sleep. Jetons un coup d'oeil rapide à son implémentation (le code a été simplifié, l'implémentation originale peut être trouvée ici):
La raison pour laquelle cette fonction est importante est qu'en plus d'utiliser le contexte strict de Ruby, elle bascule également dans celui du système afin d'y effectuer certaines opérations. Dans une telle situation, le processus Ruby n'a rien à faire... Bel exemple de perte de temps ? Pas vraiment, car il y a une GIL qui dit : "Rien à faire dans ce thread ? Passons à une autre et revenons ici après un certain temps". Cela peut être fait en déverrouillant et en verrouillant la GIL avec GVL_UNLOCK_BEGIN() et GVL_UNLOCK_END() fonctions.
La situation devient claire, mais dormir est rarement utile. Nous avons besoin de plus d'exemples concrets.
Test de téléchargement de fichiers
Exécute un processus qui télécharge et enregistre un fichier.
nécessite "net/http"
classe DownloadFileTest < Test
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
fin
fin
Les résultats suivants n'ont pas besoin d'être commentés. Ils sont assez similaires à ceux de l'exemple précédent.
1.003642
JRuby
Rubinius
séquentiel
0.327980
0.334000
0.329353
bifurcation
0.104766
–
0.121054
filetage
0.085789
0.094000
0.088490
Un autre bon exemple pourrait être le processus de copie de fichiers ou toute autre opération d'entrée/sortie.
Conclusions
Rubinius supporte pleinement le forking et le threading (depuis la version 2.X, lorsque GIL a été supprimé). Votre code peut être concurrent et s'exécuter en parallèle.
JRuby fait un bon travail avec les threads, mais ne supporte pas du tout le forking. Le parallélisme et la concurrence peuvent être réalisés avec des threads.
IRM supporte le forking, mais le threading est limité par la présence de GIL. La simultanéité peut être obtenue avec des threads, mais uniquement lorsque le code en cours d'exécution sort du contexte de l'interpréteur Ruby (par exemple les opérations d'E/S, les fonctions du noyau). Il n'y a aucun moyen d'obtenir du parallélisme.