Commençons par clarifier un point de confusion bien trop courant parmi Développeurs Ruby ; à savoir: la concurrence et le parallélisme sont ne pas la même chose (c'est-à-dire simultanée! = parallèle).
En particulier, Ruby concurrence est le moment où deux tâches peuvent démarrer, s'exécuter et se terminer en chevaucher périodes de temps. Cependant, cela ne signifie pas nécessairement qu’ils fonctionneront tous les deux au même instant (par exemple, plusieurs threads sur une machine à un seul cœur). En revanche, parallélisme c'est quand 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 simultanés pas nécessairement fonctionner 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 Interprètes et exécutables Ruby .
Pour un cas de test simple, je vais créer un Mailer
et ajoutez une fonction Fibonacci (plutôt que la méthode sleep()
) pour rendre chaque requête plus gourmande en ressources processeur, 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
On peut alors invoquer ceci Mailer
classe 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 base de référence à des fins de comparaison, commençons par faire un simple test de performance, en appelant 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)
Il n’existe pas de solution universelle pour décider d’utiliser plusieurs processus ou de lire simultanément votre application Ruby. Le tableau ci-dessous résume certains des facteurs clés à prendre en compte.
Processus | Fils |
---|---|
Utilise plus de mémoire | Utilise moins de mémoire |
Si le parent meurt avant la sortie des enfants, les enfants peuvent devenir des processus zombies | Tous les threads meurent lorsque le processus meurt (aucune chance de zombies) |
Plus cher 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 car ils partagent l'espace d'adressage et la mémoire |
Les processus fourchus reçoivent un nouvel espace de mémoire virtuelle (isolation de 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 files d'attente et 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 qui utilisent plusieurs processus:
Exemples de solutions Ruby qui utilisent le multithreading:
Avant d'examiner les options de multithreading Ruby, explorons la voie la plus simple pour générer plusieurs processus.
Dans Ruby, le fork()
l'appel système est utilisé pour créer une «copie» du processus en cours. Ce nouveau processus est planifié au niveau du système d'exploitation, de sorte qu'il peut s'exécuter simultanément avec le processus d'origine, comme n'importe quel 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 tout processus enfants pour quitter et retourne 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 forking car c'est une solution simple pour la concurrence Ruby, elle présente un inconvénient majeur qui est la quantité de mémoire qu'elle consommera. La fourche est un peu chère, surtout si un Copie sur écriture (CoW) n'est pas utilisé par l'interpréteur Ruby que vous utilisez. Si votre application utilise 20 Mo de mémoire, par exemple, une fourchette 100 fois pourrait potentiellement consommer jusqu'à 2 Go de mémoire!
De plus, bien que le multithreading ait également ses propres complexités, un certain nombre de complexités 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 fourchus parent et enfant), la nécessité de communiquer via des tuyaux, et ainsi de suite.
OK, 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 temps système qu'un nombre correspondant de processus car ils partagent l'espace d'adressage et la mémoire.
Dans cet esprit, revisitons notre scénario de test, mais cette fois en utilisant Ruby's Thread
classe:
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 la Verrouillage d'interprète global (GIL) . Grâce au GIL, CRuby (l’implémentation MRI) ne prend pas vraiment en charge le threading.
La Verrou d'interprète global 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ète qui utilise GIL va toujours autoriser exactement un fil et un thread à 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 dotés d'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 coincé 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 d'E / S (par exemple, les tâches qui doivent fréquemment attendre sur le réseau). Alors les fils pouvez toujours utile en 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 alternative de Ruby telle que JRuby ou Rubinius , puisqu'ils n'ont pas de GIL et qu'ils prennent en charge le vrai thread 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'avant, mais cette fois, exécutez-le sur JRuby (au lieu de CRuby):
43.240000 0.140000 43.380000 ( 5.655000)
Maintenant, nous parlons!
Mais…
L'amélioration des performances avec plusieurs threads pourrait amener à croire que nous pouvons simplement continuer à ajouter plus de threads - fondamentalement à l'infini - pour continuer à faire fonctionner notre code de plus en plus vite. Ce serait bien si c'était vrai, mais la réalité est que les threads ne sont pas gratuits et donc, tôt ou tard, vous serez à court de ressources.
Supposons, par exemple, que nous souhaitons exécuter notre exemple de messagerie 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 engendré environ 2000 threads:
can't create Thread: Resource temporarily unavailable (ThreadError)
Comme prévu, tôt ou tard, nous commençons à nous débattre ou à manquer de ressources. L'évolutivité de cette approche est donc clairement limitée.
Heureusement, il y a une meilleure façon; à savoir, regroupement de threads.
Un pool de threads est un groupe de threads pré-instanciés et réutilisables qui sont disponibles pour effectuer le travail selon les besoins. 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 à la fois (c'est-à-dire lorsque le pool est créé) ou paresseux (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 à effectuer, il attribue la tâche à l'un des threads actuellement inactifs. Si aucun thread n'est inactif (et que le nombre maximum de threads a déjà été créé), il attend qu'un thread termine son travail et devienne inactif, puis affecte la tâche à ce thread.
Donc, pour revenir à notre exemple, nous allons commencer par utiliser Queue
(puisque c'est un thread safe type de données) et emploient une implémentation simple du pool de threads:
nécessitent './lib/mailer' nécessitent 'benchmark' nécessitent 'thread'
POOL_SIZE = 10 jobs = Queue.new 10_0000.times 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 un jobs
file d'attente pour les travaux à exécuter. 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 ID des expéditeurs dans la file d'attente des travaux et créé notre pool de 10 threads de travail.
Dans chaque thread de travail, nous sortons 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 les tâches soient placées dans la file d'attente de travaux et à les exécuter.
La bonne nouvelle est que cela fonctionne et évolue sans aucun problème. Malheureusement, cela est assez compliqué même pour notre simple tutoriel.
Grace à Rubis gemme , une grande partie de la complexité du multithreading est parfaitement encapsulée dans un certain nombre de Ruby Gems faciles à utiliser prêtes à l'emploi.
Un bon exemple est Celluloid, l'une de mes gemmes rubis préférées. Le framework Celluloid est un moyen simple et propre d'implémenter des systèmes simultanés basés sur des acteurs dans Ruby. Celluloïd permet aux utilisateurs de créer des programmes simultanés à partir d'objets concurrents tout aussi facilement qu'ils construisent des programmes séquentiels à partir d'objets séquentiels.
Dans le cadre 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 en mesure de créer des programmes Ruby multithread sans vous soucier de problèmes désagréables tels que les blocages, et vous trouverez trivial d’utiliser d’autres fonctionnalités plus sophistiquées telles que 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?
Bien sûr, une autre alternative potentiellement viable, en fonction de vos exigences et contraintes opérationnelles serait d'employer 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 tâches dans une file d'attente et les traiter plus tard sans bloquer le thread actuel). Les exemples notables incluent Sidekiq , Aide sociale , Travail retardé , et Beanstalkd .
Pour ce message, 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, jetons un coup d'œil à 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
Nous pouvons déclencher Sidekiq avec le mail_worker.rb
fichier:
sidekiq -r ./mail_worker.rb
Et puis de IRB :
⇒ irb >> require_relative 'mail_worker' => true >> 100.times 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100
Awesomely 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 utilisant Sucker Punch sera très similaire. Il nous suffira d’inclure SuckerPunch::Job
plutôt que Sidekiq::Worker
, et MailWorker.new.async.perform()
plutôt MailWorker.perform_async()
.
Une concurrence élevée n'est pas seulement réalisable dans Rubis , mais est également plus simple que vous ne le pensez.
Une approche viable consiste simplement à créer 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 et nécessitent moins de temps système, 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, bon nombre des complexités du multithreading sont simplifiées en tirant parti de l'un des nombreux joyaux disponibles, tels que Celluloid et son modèle Actor.
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 travaux d'arrière-plan dans vos applications. Certains outils populaires incluent des cadres de travail et des files d'attente de messages basés sur des bases de données.
Le fork, 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 des exigences. Espérons que ce tutoriel a fourni une introduction utile aux options disponibles.