Concurrence et parallélisme Ruby : un didacticiel pratique
Publié: 2022-03-11Commençons par dissiper un point de confusion trop courant parmi les développeurs Ruby ; à savoir : la concurrence et le parallélisme ne sont pas la même chose (c'est-à-dire, concurrent != parallèle).
En particulier, la simultanéité Ruby est lorsque deux tâches peuvent démarrer, s'exécuter et se terminer dans des périodes qui se chevauchent . Cela ne signifie pas nécessairement, cependant, qu'ils fonctionneront tous les deux au même instant (par exemple, plusieurs threads sur une machine monocœur). En revanche, le parallélisme se produit lorsque deux tâches s'exécutent littéralement en même temps (par exemple, plusieurs threads sur un processeur multicœur).
Le point clé ici est que les threads et/ou processus concurrents ne s'exécuteront pas nécessairement en parallèle.
Ce didacticiel fournit un traitement pratique (plutôt que théorique) des différentes techniques et approches disponibles pour la concurrence et le parallélisme dans Ruby.
Pour plus d'exemples réels de Ruby, consultez notre article sur les interpréteurs et les runtimes Ruby.
Notre cas de test
Pour un cas de test simple, je vais créer une classe Mailer
et ajouter une fonction Fibonacci (plutôt que la méthode sleep()
) pour rendre chaque requête plus gourmande en CPU, comme suit :
class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end
Nous pouvons ensuite invoquer cette classe Mailer
comme suit pour envoyer du courrier :
Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end
(Remarque : le code source de ce cas de test est disponible ici sur github.)
Pour établir une ligne de base à des fins de comparaison, commençons par faire un simple benchmark, en invoquant le mailer 100 fois :
puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }
Cela a donné les résultats suivants sur un processeur quad-core avec MRI Ruby 2.0.0p353 :
15.250000 0.020000 15.270000 ( 15.304447)
Processus multiples vs multithreading
Il n'y a pas de réponse unique lorsqu'il s'agit de décider d'utiliser plusieurs processus ou de multithreader votre application Ruby. Le tableau ci-dessous résume certains des principaux facteurs à prendre en compte.
Processus | Fils |
---|---|
Utilise plus de mémoire | Utilise moins de mémoire |
Si le parent meurt avant que les enfants ne soient sortis, les enfants peuvent devenir des processus zombies | Tous les threads meurent lorsque le processus meurt (aucune chance de zombies) |
Plus coûteux pour les processus fourchus de changer de contexte car le système d'exploitation doit tout enregistrer et recharger | Les threads ont considérablement moins de surcharge puisqu'ils partagent l'espace d'adressage et la mémoire |
Les processus forkés reçoivent un nouvel espace de mémoire virtuelle (isolation des processus) | Les threads partagent la même mémoire, il faut donc contrôler et gérer les problèmes de mémoire simultanés |
Nécessite une communication inter-processus | Peut "communiquer" via les files d'attente et la mémoire partagée |
Plus lent à créer et à détruire | Plus rapide à créer et à détruire |
Plus facile à coder et à déboguer | Peut être beaucoup plus complexe à coder et à déboguer |
Exemples de solutions Ruby utilisant plusieurs processus :
- Resque : une bibliothèque Ruby soutenue par Redis pour créer des tâches en arrière-plan, les placer sur plusieurs files d'attente et les traiter ultérieurement.
- Unicorn : un serveur HTTP pour les applications Rack conçu pour servir uniquement les clients rapides sur des connexions à faible latence et à bande passante élevée et tirer parti des fonctionnalités des noyaux de type Unix/Unix.
Exemples de solutions Ruby utilisant le multithreading :
- Sidekiq : un framework de traitement en arrière-plan complet pour Ruby. Il vise à être simple à intégrer à toute application Rails moderne et à offrir des performances bien supérieures à celles des autres solutions existantes.
- Puma : un serveur Web Ruby conçu pour la concurrence.
- Thin : Un serveur Web Ruby très rapide et simple.
Processus multiples
Avant d'examiner les options de multithreading de Ruby, explorons la voie la plus simple pour générer plusieurs processus.
En Ruby, l'appel système fork()
est utilisé pour créer une "copie" du processus en cours. Ce nouveau processus est planifié au niveau du système d'exploitation, il peut donc s'exécuter en même temps que le processus d'origine, comme tout autre processus indépendant. ( Remarque : fork()
est un appel système POSIX et n'est donc pas disponible si vous exécutez Ruby sur une plate-forme Windows.)
OK, alors exécutons notre cas de test, mais cette fois en utilisant fork()
pour employer plusieurs processus :
puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }
( Process.waitall
attend que tous les processus enfants se terminent et renvoie un tableau d'états de processus.)
Ce code donne maintenant les résultats suivants (encore une fois, sur un processeur quad-core avec MRI Ruby 2.0.0p353) :
0.000000 0.030000 27.000000 ( 3.788106)
Pas trop mal! Nous avons rendu le mailer ~5x plus rapide en modifiant simplement quelques lignes de code (c'est-à-dire en utilisant fork()
).
Ne soyez pas trop excité cependant. Bien qu'il puisse être tentant d'utiliser le fork car c'est une solution simple pour la concurrence Ruby, il présente un inconvénient majeur qui est la quantité de mémoire qu'il consommera. Le forking est un peu cher, surtout si un Copy-on-Write (CoW) n'est pas utilisé par l'interpréteur Ruby que vous utilisez. Si votre application utilise 20 Mo de mémoire, par exemple, la doubler 100 fois pourrait potentiellement consommer jusqu'à 2 Go de mémoire !
De plus, bien que le multithreading ait également ses propres complexités, il y a un certain nombre de complexités qui doivent être prises en compte lors de l'utilisation de fork()
, telles que les descripteurs de fichiers partagés et les sémaphores (entre les processus parent et enfant), la nécessité de communiquer via des canaux , etc.
Ruby Multithreading
OK, alors essayons maintenant de rendre le même programme plus rapide en utilisant les techniques de multithreading Ruby à la place.
Plusieurs threads au sein d'un même processus ont considérablement moins de surcharge qu'un nombre correspondant de processus puisqu'ils partagent l'espace d'adressage et la mémoire.
Dans cet esprit, revoyons notre cas de test, mais cette fois en utilisant la classe Thread
de Ruby :
threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Ce code donne maintenant les résultats suivants (encore une fois, sur un processeur quad-core avec MRI Ruby 2.0.0p353) :
13.710000 0.040000 13.750000 ( 13.740204)
Dommage. Ce n'est certainement pas très impressionnant! Alors que se passe-t-il? Pourquoi cela produit-il presque les mêmes résultats que lorsque nous avons exécuté le code de manière synchrone ?
La réponse, qui est le fléau de l'existence de nombreux programmeurs Ruby, est le Global Interpreter Lock (GIL) . Grâce au GIL, CRuby (l'implémentation MRI) ne prend pas vraiment en charge le threading.
Le verrouillage global de l'interpréteur est un mécanisme utilisé dans les interpréteurs de langage informatique pour synchroniser l'exécution des threads afin qu'un seul thread puisse s'exécuter à la fois. Un interpréteur qui utilise GIL autorisera toujours exactement un thread et un seul thread à s'exécuter à la fois , même s'il est exécuté sur un processeur multicœur. Ruby MRI et CPython sont deux des exemples les plus courants d'interprètes populaires qui ont un GIL.
Revenons donc à notre problème, comment pouvons-nous exploiter le multithreading dans Ruby pour améliorer les performances à la lumière du GIL ?
Eh bien, dans l'IRM (CRuby), la réponse malheureuse est que vous êtes fondamentalement bloqué et que le multithreading ne peut pas faire grand-chose pour vous.
La simultanéité Ruby sans parallélisme peut cependant être très utile pour les tâches lourdes en E/S (par exemple, les tâches qui doivent fréquemment attendre sur le réseau). Ainsi, les fils peuvent toujours être utiles dans l'IRM, pour les tâches lourdes en IO. Il y a une raison pour laquelle les threads ont été, après tout, inventés et utilisés avant même que les serveurs multicœurs ne soient courants.

Mais cela dit, si vous avez la possibilité d'utiliser une version autre que CRuby, vous pouvez utiliser une implémentation Ruby alternative telle que JRuby ou Rubinius, car ils n'ont pas de GIL et ils prennent en charge le vrai threading Ruby parallèle.
Pour prouver le point, voici les résultats que nous obtenons lorsque nous exécutons exactement la même version threadée du code qu'auparavant, mais cette fois, exécutez-le sur JRuby (au lieu de CRuby):
43.240000 0.140000 43.380000 ( 5.655000)
Maintenant, nous parlons !
Mais…
Les discussions ne sont pas gratuites
L'amélioration des performances avec plusieurs threads pourrait amener à croire que nous pouvons simplement continuer à ajouter plus de threads - essentiellement à l'infini - pour continuer à faire fonctionner notre code de plus en plus vite. Ce serait en effet bien si c'était vrai, mais la réalité est que les threads ne sont pas gratuits et donc, tôt ou tard, vous manquerez de ressources.
Disons, par exemple, que nous voulons exécuter notre exemple de mailer non pas 100 fois, mais 10 000 fois. Voyons ce qui se passe:
threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }
Boom! J'ai eu une erreur avec mon OS X 10.8 après avoir généré environ 2 000 threads :
can't create Thread: Resource temporarily unavailable (ThreadError)
Comme prévu, tôt ou tard, nous commençons à nous battre ou à manquer complètement de ressources. L'évolutivité de cette approche est donc clairement limitée.
Mise en commun des threads
Heureusement, il y a une meilleure façon; à savoir, la mise en commun des threads.
Un pool de threads est un groupe de threads pré-instanciés et réutilisables qui sont disponibles pour effectuer le travail nécessaire. Les pools de threads sont particulièrement utiles lorsqu'il y a un grand nombre de tâches courtes à effectuer plutôt qu'un petit nombre de tâches plus longues. Cela évite d'avoir à supporter la surcharge de création d'un thread un grand nombre de fois.
Un paramètre de configuration clé pour un pool de threads est généralement le nombre de threads dans le pool. Ces threads peuvent être instanciés tous en même temps (c'est-à-dire lorsque le pool est créé) ou paresseusement (c'est-à-dire selon les besoins jusqu'à ce que le nombre maximum de threads dans le pool ait été créé).
Lorsque le pool reçoit une tâche à exécuter, il attribue la tâche à l'un des threads actuellement inactifs. Si aucun thread n'est inactif (et que le nombre maximal de threads a déjà été créé), il attend qu'un thread termine son travail et devienne inactif, puis attribue la tâche à ce thread.
Donc, pour revenir à notre exemple, nous allons commencer par utiliser Queue
(puisqu'il s'agit d'un type de données thread-safe) et utiliser une implémentation simple du pool de threads :
nécessite "./lib/mailer" nécessite "benchmark" nécessite 'thread'
POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)
Dans le code ci-dessus, nous avons commencé par créer une file d'attente de jobs
pour les travaux à effectuer. Nous avons utilisé Queue
à cette fin car il est thread-safe (donc si plusieurs threads y accèdent en même temps, il maintiendra la cohérence) ce qui évite le besoin d'une implémentation plus compliquée nécessitant l'utilisation d'un mutex.
Nous avons ensuite poussé les identifiants des expéditeurs vers la file d'attente des tâches et créé notre pool de 10 threads de travail.
Dans chaque thread de travail, nous extrayons des éléments de la file d'attente des travaux.
Ainsi, le cycle de vie d'un thread de travail consiste à attendre en permanence que des tâches soient placées dans la file d'attente des tâches et à les exécuter.
La bonne nouvelle est donc que cela fonctionne et évolue sans aucun problème. Malheureusement, cela est assez compliqué même pour notre simple tutoriel.
Celluloïd
Grâce à l'écosystème Ruby Gem, une grande partie de la complexité du multithreading est parfaitement encapsulée dans un certain nombre de Ruby Gems prêts à l'emploi et faciles à utiliser.
Un bon exemple est Celluloid, l'un de mes joyaux de rubis préférés. Le framework Celluloid est un moyen simple et propre d'implémenter des systèmes concurrents basés sur des acteurs dans Ruby. Le celluloïd permet aux gens de construire des programmes concurrents à partir d'objets concurrents aussi facilement qu'ils construisent des programmes séquentiels à partir d'objets séquentiels.
Dans le contexte de notre discussion dans cet article, je me concentre spécifiquement sur la fonctionnalité Pools, mais rendez-vous service et vérifiez-la plus en détail. En utilisant Celluloid, vous serez capable de créer des programmes Ruby multithread sans vous soucier de problèmes désagréables comme les blocages, et vous trouverez trivial d'utiliser d'autres fonctionnalités plus sophistiquées comme Futures et Promises.
Voici à quel point une version multithread de notre programme de messagerie utilise Celluloid :
require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end
Propre, simple, évolutif et robuste. Que peux tu demander de plus?
Tâches d'arrière-plan
Bien sûr, une autre alternative potentiellement viable, en fonction de vos exigences et contraintes opérationnelles, serait d'employer des emplois d'arrière-plan. Un certain nombre de Ruby Gems existent pour prendre en charge le traitement en arrière-plan (c'est-à-dire, enregistrer les travaux dans une file d'attente et les traiter ultérieurement sans bloquer le thread actuel). Des exemples notables incluent Sidekiq, Resque, Delayed Job et Beanstalkd.
Pour cet article, j'utiliserai Sidekiq et Redis (un cache et un magasin de clé-valeur open source).
Tout d'abord, installons Redis et exécutons-le localement :
brew install redis redis-server /usr/local/etc/redis.conf
Avec notre instance Redis locale en cours d'exécution, examinons une version de notre exemple de programme de messagerie ( mail_worker.rb
) utilisant Sidekiq :
require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end
On peut déclencher Sidekiq avec le fichier mail_worker.rb
:
sidekiq -r ./mail_worker.rb
Et puis de l'IRB :
⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
Incroyablement simple. Et il peut évoluer facilement en modifiant simplement le nombre de travailleurs.
Une autre option consiste à utiliser Sucker Punch, l'une de mes bibliothèques de traitement RoR asynchrones préférées. L'implémentation à l'aide de Sucker Punch sera très similaire. Nous devrons simplement inclure SuckerPunch::Job
plutôt que Sidekiq::Worker
, et MailWorker.new.async.perform()
plutôt MailWorker.perform_async()
.
Conclusion
Une simultanéité élevée est non seulement réalisable dans Ruby, mais aussi plus simple que vous ne le pensez.
Une approche viable consiste simplement à bifurquer un processus en cours d'exécution pour multiplier sa puissance de traitement. Une autre technique consiste à tirer parti du multithreading. Bien que les threads soient plus légers que les processus, nécessitant moins de surcharge, vous pouvez toujours manquer de ressources si vous démarrez trop de threads simultanément. À un moment donné, vous trouverez peut-être nécessaire d'utiliser un pool de threads. Heureusement, de nombreuses complexités du multithreading sont facilitées en exploitant l'un des nombreux joyaux disponibles, tels que Celluloid et son modèle d'acteur.
Une autre façon de gérer les processus chronophages consiste à utiliser le traitement en arrière-plan. Il existe de nombreuses bibliothèques et services qui vous permettent d'implémenter des tâches en arrière-plan dans vos applications. Certains outils populaires incluent des frameworks de travail basés sur une base de données et des files d'attente de messages.
Le forking, le threading et le traitement en arrière-plan sont tous des alternatives viables. Le choix de celui à utiliser dépend de la nature de votre application, de votre environnement opérationnel et de vos exigences. J'espère que ce tutoriel a fourni une introduction utile aux options disponibles.