Nebenläufigkeit und Parallelität in Ruby: Ein praktisches Tutorial

Veröffentlicht: 2022-03-11

Beginnen wir damit, einen allzu häufigen Punkt der Verwirrung unter Ruby-Entwicklern zu beseitigen; nämlich: Nebenläufigkeit und Parallelität sind nicht dasselbe (dh nebenläufig != parallel).

Insbesondere Ruby -Parallelität liegt vor, wenn zwei Aufgaben in überlappenden Zeiträumen gestartet, ausgeführt und abgeschlossen werden können. Das bedeutet jedoch nicht unbedingt, dass sie beide gleichzeitig laufen (z. B. mehrere Threads auf einem Single-Core-Rechner). Im Gegensatz dazu läuft Parallelität , wenn zwei Tasks buchstäblich gleichzeitig ausgeführt werden (z. B. mehrere Threads auf einem Multicore-Prozessor).

Der entscheidende Punkt hierbei ist, dass gleichzeitige Threads und/oder Prozesse nicht notwendigerweise parallel ausgeführt werden.

Dieses Tutorial bietet eine praktische (eher als theoretische) Behandlung der verschiedenen Techniken und Ansätze, die für Nebenläufigkeit und Parallelität in Ruby verfügbar sind.

Weitere Ruby-Beispiele aus der Praxis finden Sie in unserem Artikel über Ruby-Interpreter und -Runtimes.

Unser Testfall

Für einen einfachen Testfall erstelle ich eine Mailer -Klasse und füge eine Fibonacci-Funktion (anstelle der sleep() Methode) hinzu, um jede Anfrage wie folgt CPU-intensiver zu machen:

 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

Wir können diese Mailer -Klasse dann wie folgt aufrufen, um E-Mails zu senden:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(Hinweis: Der Quellcode für diesen Testfall ist hier auf github verfügbar.)

Um eine Baseline zu Vergleichszwecken zu erstellen, beginnen wir mit einem einfachen Benchmark, indem wir den Mailer 100 Mal aufrufen:

 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 }

Dies ergab auf einem Quad-Core-Prozessor mit MRI Ruby 2.0.0p353 folgende Ergebnisse:

 15.250000 0.020000 15.270000 ( 15.304447)

Mehrere Prozesse vs. Multithreading

Es gibt keine allgemeingültige Antwort, wenn es darum geht, zu entscheiden, ob Sie mehrere Prozesse verwenden oder Ihre Ruby-Anwendung multithreaden sollen. Die folgende Tabelle fasst einige der zu berücksichtigenden Schlüsselfaktoren zusammen.

Prozesse Fäden
Verwendet mehr Speicher Verbraucht weniger Speicher
Wenn Eltern sterben, bevor Kinder ausgetreten sind, können Kinder zu Zombieprozessen werden Alle Threads sterben, wenn der Prozess stirbt (keine Chance für Zombies)
Teurer für gegabelte Prozesse, um den Kontext zu wechseln, da das Betriebssystem alles speichern und neu laden muss Threads haben erheblich weniger Overhead, da sie Adressraum und Speicher gemeinsam nutzen
Verzweigte Prozesse erhalten einen neuen virtuellen Speicherplatz (Prozessisolation) Threads teilen sich denselben Speicher, müssen also Probleme mit gleichzeitigem Speicher kontrollieren und behandeln
Erfordert Kommunikation zwischen Prozessen Kann über Queues und Shared Memory "kommunizieren".
Langsamer zu erstellen und zu zerstören Schneller zu erstellen und zu zerstören
Einfacher zu codieren und zu debuggen Kann wesentlich komplexer zu codieren und zu debuggen sein

Beispiele für Ruby-Lösungen, die mehrere Prozesse verwenden:

  • Resque: Eine von Redis unterstützte Ruby-Bibliothek zum Erstellen von Hintergrundjobs, deren Platzierung in mehreren Warteschlangen und der späteren Verarbeitung.
  • Unicorn: Ein HTTP-Server für Rack-Anwendungen, der darauf ausgelegt ist, nur schnelle Clients auf Verbindungen mit niedriger Latenz und hoher Bandbreite zu bedienen und die Vorteile von Funktionen in Unix/Unix-ähnlichen Kerneln zu nutzen.

Beispiele für Ruby-Lösungen, die Multithreading verwenden:

  • Sidekiq: Ein voll funktionsfähiges Hintergrundverarbeitungs-Framework für Ruby. Es soll sich einfach in jede moderne Rails-Anwendung integrieren lassen und eine viel höhere Leistung als andere vorhandene Lösungen bieten.
  • Puma: Ein Ruby-Webserver, der für Nebenläufigkeit entwickelt wurde.
  • Thin: Ein sehr schneller und einfacher Ruby-Webserver.

Mehrere Prozesse

Bevor wir uns mit den Multithreading-Optionen von Ruby befassen, wollen wir den einfacheren Weg zum Spawnen mehrerer Prozesse untersuchen.

In Ruby wird der Systemaufruf fork() verwendet, um eine „Kopie“ des aktuellen Prozesses zu erstellen. Dieser neue Prozess wird auf Betriebssystemebene geplant, sodass er wie jeder andere unabhängige Prozess gleichzeitig mit dem ursprünglichen Prozess ausgeführt werden kann. ( Hinweis: fork() ist ein POSIX-Systemaufruf und ist daher nicht verfügbar, wenn Sie Ruby auf einer Windows-Plattform ausführen.)

OK, also lassen Sie uns unseren Testfall ausführen, aber diesmal mit fork() , um mehrere Prozesse zu verwenden:

 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 wartet darauf, dass alle untergeordneten Prozesse beendet werden, und gibt ein Array von Prozessstatus zurück.)

Dieser Code liefert nun folgende Ergebnisse (wieder auf einem Quad-Core-Prozessor mit MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

Nicht zu schäbig! Wir haben den Mailer ~5x schneller gemacht, indem wir nur ein paar Codezeilen geändert haben (dh unter Verwendung von fork() ).

Seien Sie jedoch nicht übermäßig aufgeregt. Obwohl es verlockend sein mag, Forking zu verwenden, da es eine einfache Lösung für Ruby-Parallelität ist, hat es einen großen Nachteil, nämlich die Menge an Speicher, die es verbraucht. Forking ist etwas teuer, insbesondere wenn ein Copy-on-Write (CoW) nicht von dem von Ihnen verwendeten Ruby-Interpreter verwendet wird. Wenn Ihre App beispielsweise 20 MB Speicher verwendet, kann ein 100-maliges Forken möglicherweise bis zu 2 GB Speicher verbrauchen!

Obwohl Multithreading auch seine eigenen Komplexitäten hat, gibt es eine Reihe von Komplexitäten, die bei der Verwendung von fork() berücksichtigt werden müssen, wie z , und so weiter.

Ruby-Multithreading

Okay, versuchen wir jetzt, dasselbe Programm schneller zu machen, indem wir stattdessen Ruby-Multithreading-Techniken verwenden.

Mehrere Threads innerhalb eines einzelnen Prozesses haben erheblich weniger Overhead als eine entsprechende Anzahl von Prozessen, da sie Adressraum und Speicher gemeinsam nutzen.

Sehen wir uns vor diesem Hintergrund unseren Testfall noch einmal an, aber diesmal mit Rubys Thread -Klasse:

 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) }

Dieser Code liefert nun folgende Ergebnisse (wieder auf einem Quad-Core-Prozessor mit MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

Schade. Das ist sicher nicht sehr beeindruckend! So was ist los? Warum führt dies zu fast denselben Ergebnissen wie bei der synchronen Ausführung des Codes?

Die Antwort, die für viele Ruby-Programmierer der Untergang der Existenz ist, ist das Global Interpreter Lock (GIL) . Dank der GIL unterstützt CRuby (die MRI-Implementierung) Threading nicht wirklich.

Die globale Interpretersperre ist ein Mechanismus, der in Computersprachinterpretern verwendet wird, um die Ausführung von Threads zu synchronisieren, sodass jeweils nur ein Thread ausgeführt werden kann. Ein Interpreter, der GIL verwendet, lässt immer genau einen Thread und nur einen Thread gleichzeitig zu , selbst wenn er auf einem Mehrkernprozessor ausgeführt wird. Ruby MRI und CPython sind zwei der häufigsten Beispiele für beliebte Interpreter mit GIL.

Zurück zu unserem Problem: Wie können wir Multithreading in Ruby ausnutzen, um die Leistung angesichts der GIL zu verbessern?

Nun, in der MRT (CRuby) lautet die unglückliche Antwort, dass Sie im Grunde feststecken und dass Multithreading nur sehr wenig für Sie tun kann.

Ruby-Parallelität ohne Parallelität kann dennoch sehr nützlich sein für Aufgaben, die IO-lastig sind (z. B. Aufgaben, die häufig im Netzwerk warten müssen). Threads können also in der MRT für IO-lastige Aufgaben immer noch nützlich sein. Es gibt schließlich einen Grund, warum Threads erfunden und verwendet wurden, noch bevor Multi-Core-Server üblich waren.

Wenn Sie jedoch die Möglichkeit haben, eine andere Version als CRuby zu verwenden, können Sie eine alternative Ruby-Implementierung wie JRuby oder Rubinius verwenden, da diese keine GIL haben und echtes paralleles Ruby-Threading unterstützen.

Threaded mit JRuby

Um den Punkt zu beweisen, hier sind die Ergebnisse, die wir erhalten, wenn wir genau dieselbe Thread-Version des Codes wie zuvor ausführen, aber diesmal auf JRuby (anstelle von CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

Jetzt reden wir!

Aber…

Threads sind nicht kostenlos

Die verbesserte Leistung mit mehreren Threads könnte zu der Annahme führen, dass wir einfach immer mehr Threads hinzufügen können – im Grunde unendlich –, um unseren Code immer schneller und schneller laufen zu lassen. Das wäre in der Tat schön, wenn es wahr wäre, aber die Realität ist, dass Threads nicht kostenlos sind und Ihnen früher oder später die Ressourcen ausgehen.

Nehmen wir zum Beispiel an, dass wir unseren Beispiel-Mailer nicht 100 Mal, sondern 10.000 Mal ausführen möchten. Mal sehen was passiert:

 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! Ich habe einen Fehler mit meinem OS X 10.8 erhalten, nachdem ich ungefähr 2.000 Threads erzeugt habe:

 can't create Thread: Resource temporarily unavailable (ThreadError)

Wie erwartet, fangen wir früher oder später an zu prügeln oder gehen die Ressourcen vollständig aus. Die Skalierbarkeit dieses Ansatzes ist also deutlich begrenzt.

Thread-Pooling

Glücklicherweise gibt es einen besseren Weg; nämlich Thread-Pooling.

Ein Thread-Pool ist eine Gruppe von vorinstanziierten, wiederverwendbaren Threads, die für die Ausführung von Aufgaben nach Bedarf verfügbar sind. Thread-Pools sind besonders nützlich, wenn statt einer kleinen Anzahl längerer Aufgaben eine große Anzahl kurzer Aufgaben ausgeführt werden soll. Dies vermeidet den Overhead, einen Thread viele Male zu erstellen.

Ein wichtiger Konfigurationsparameter für einen Thread-Pool ist normalerweise die Anzahl der Threads im Pool. Diese Threads können entweder alle auf einmal (dh wenn der Pool erstellt wird) oder verzögert (dh nach Bedarf, bis die maximale Anzahl von Threads im Pool erstellt wurde) instanziiert werden.

Wenn dem Pool eine auszuführende Aufgabe übergeben wird, weist er die Aufgabe einem der derzeit im Leerlauf befindlichen Threads zu. Wenn keine Threads im Leerlauf sind (und die maximale Anzahl von Threads bereits erstellt wurde), wartet er darauf, dass ein Thread seine Arbeit beendet und im Leerlauf ist, und weist dann die Aufgabe diesem Thread zu.

Thread-Pooling

Um zu unserem Beispiel zurückzukehren, beginnen wir mit der Verwendung von Queue (da es sich um einen Thread-sicheren Datentyp handelt) und wenden eine einfache Implementierung des Thread-Pools an:

Benötige „./lib/mailer“ Benötige „Benchmark“ Benötige „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)

Im obigen Code haben wir damit begonnen, eine jobs für die Jobs zu erstellen, die ausgeführt werden müssen. Wir haben Queue für diesen Zweck verwendet, da es Thread-sicher ist (wenn also mehrere Threads gleichzeitig darauf zugreifen, behält es die Konsistenz bei), was die Notwendigkeit einer komplizierteren Implementierung vermeidet, die die Verwendung eines Mutex erfordert.

Anschließend haben wir die IDs der Mailer in die Job-Warteschlange verschoben und unseren Pool von 10 Worker-Threads erstellt.

Innerhalb jedes Worker-Threads holen wir Elemente aus der Job-Warteschlange.

Der Lebenszyklus eines Worker-Threads besteht also darin, kontinuierlich darauf zu warten, dass Aufgaben in die Job-Warteschlange gestellt werden, und sie auszuführen.

Die gute Nachricht ist also, dass dies problemlos funktioniert und skaliert. Leider ist dies selbst für unser einfaches Tutorial ziemlich kompliziert.

Zelluloid

Dank des Ruby-Gem-Ökosystems ist ein Großteil der Komplexität des Multithreading in einer Reihe einfach zu verwendender Ruby-Gems sofort einsatzbereit gekapselt.

Ein großartiges Beispiel ist Celluloid, einer meiner liebsten Rubin-Edelsteine. Das Zelluloid-Framework ist eine einfache und saubere Möglichkeit, akteurbasierte nebenläufige Systeme in Ruby zu implementieren. Zelluloid ermöglicht es Menschen, nebenläufige Programme aus nebenläufigen Objekten genauso einfach zu erstellen, wie sie sequentielle Programme aus sequentiellen Objekten erstellen.

Im Zusammenhang mit unserer Diskussion in diesem Beitrag konzentriere ich mich speziell auf die Pools-Funktion, aber tun Sie sich selbst einen Gefallen und sehen Sie sich das genauer an. Mit Celluloid können Sie Ruby-Programme mit mehreren Threads erstellen, ohne sich um unangenehme Probleme wie Deadlocks kümmern zu müssen, und Sie werden es trivial finden, andere anspruchsvollere Funktionen wie Futures und Promises zu verwenden.

So einfach funktioniert eine Multithread-Version unseres Mailer-Programms mit 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

Sauber, einfach, skalierbar und robust. Was kann man mehr verlangen?

Hintergrundjobs

Natürlich wäre eine weitere potenziell praktikable Alternative, abhängig von Ihren betrieblichen Anforderungen und Einschränkungen, die Beschäftigung von Hintergrundjobs. Es gibt eine Reihe von Ruby Gems, die die Hintergrundverarbeitung unterstützen (dh Jobs in einer Warteschlange speichern und später verarbeiten, ohne den aktuellen Thread zu blockieren). Bemerkenswerte Beispiele sind Sidekiq, Resque, Delayed Job und Beanstalkd.

Für diesen Beitrag verwende ich Sidekiq und Redis (ein Open-Source-Schlüsselwert-Cache und -Speicher).

Lassen Sie uns zunächst Redis installieren und lokal ausführen:

 brew install redis redis-server /usr/local/etc/redis.conf

Lassen Sie uns bei laufender lokaler Redis-Instanz einen Blick auf eine Version unseres Beispiel-Mailer-Programms ( mail_worker.rb ) mit Sidekiq werfen:

 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

Wir können Sidekiq mit der Datei mail_worker.rb auslösen:

 sidekiq -r ./mail_worker.rb

Und dann von 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

Genial einfach. Und es kann einfach skaliert werden, indem einfach die Anzahl der Arbeiter geändert wird.

Eine weitere Option ist die Verwendung von Sucker Punch, einer meiner bevorzugten asynchronen RoR-Verarbeitungsbibliotheken. Die Implementierung mit Sucker Punch wird sehr ähnlich sein. Wir müssen nur SuckerPunch SuckerPunch::Job statt Sidekiq::Worker und MailWorker.new.async.perform() statt MailWorker.perform_async() .

Fazit

Hohe Parallelität ist nicht nur in Ruby erreichbar, sondern auch einfacher als Sie vielleicht denken.

Ein praktikabler Ansatz besteht darin, einen laufenden Prozess einfach zu forken, um seine Verarbeitungsleistung zu vervielfachen. Eine andere Technik besteht darin, Multithreading zu nutzen. Obwohl Threads leichter als Prozesse sind und weniger Overhead erfordern, können Ihnen dennoch die Ressourcen ausgehen, wenn Sie zu viele Threads gleichzeitig starten. An einem bestimmten Punkt kann es erforderlich sein, einen Thread-Pool zu verwenden. Glücklicherweise werden viele der Komplexitäten des Multithreading durch die Nutzung einer Reihe von verfügbaren Edelsteinen wie Celluloid und seinem Actor-Modell erleichtert.

Eine weitere Möglichkeit, zeitaufwändige Prozesse zu handhaben, ist die Verwendung der Hintergrundverarbeitung. Es gibt viele Bibliotheken und Dienste, mit denen Sie Hintergrundjobs in Ihre Anwendungen implementieren können. Einige beliebte Tools umfassen datenbankgestützte Job-Frameworks und Nachrichtenwarteschlangen.

Forking, Threading und Hintergrundverarbeitung sind alles mögliche Alternativen. Die Entscheidung, welche Sie verwenden, hängt von der Art Ihrer Anwendung, Ihrer Betriebsumgebung und Ihren Anforderungen ab. Hoffentlich hat dieses Tutorial eine nützliche Einführung in die verfügbaren Optionen gegeben.