Wie Sie wahrscheinlich wissen, gibt es für Ruby einige Implementierungen wie MRI, JRuby, Rubinius, Opal, RubyMotion usw., und jede von ihnen kann ein anderes Muster der Codeausführung verwenden. Dieser Artikel konzentriert sich auf die ersten drei von ihnen und vergleicht MRI
Wie Sie wahrscheinlich wissen, gibt es für Ruby einige Implementierungen, wie z. B. MRI, JRuby, Rubinius, Opal, RubyMotion usw., und jede von ihnen kann ein anderes Muster von Code Ausführung. Dieser Artikel konzentriert sich auf die ersten drei von ihnen und vergleicht MRI (die derzeit populärste Implementierung) mit JRuby und Rubinius, indem er einige Beispielskripte ausführt, die die Eignung von Forking und Threading in verschiedenen Situationen bewerten sollen, z. B. bei der Verarbeitung von CPU-intensiven Algorithmen, beim Kopieren von Dateien usw. Bevor Sie mit dem "Learning by Doing" beginnen, müssen Sie ein paar grundlegende Begriffe überarbeiten.
Gabel
ist ein neuer untergeordneter Prozess (eine Kopie des übergeordneten Prozesses)
kommuniziert mit anderen über Interprozesskommunikationskanäle (IPC) wie Nachrichtenwarteschlangen, Dateien, Sockets usw.
besteht auch dann, wenn der übergeordnete Prozess endet
ist ein POSIX-Aufruf - funktioniert hauptsächlich auf Unix-Plattformen
Thema
ist "nur" ein Ausführungskontext, der innerhalb eines Prozesses arbeitet
teilt den gesamten Speicher mit anderen (standardmäßig wird weniger Speicher als bei einem Fork verwendet)
kommuniziert mit anderen über gemeinsame Speicherobjekte
stirbt mit einem Prozess
führt zu typischen Multi-Threading-Problemen wie Starvation, Deadlocks usw.
Es gibt eine Vielzahl von Tools, die Forks und Threads verwenden und täglich im Einsatz sind, z. B. Unicorn (Forks) und Puma (Threads) auf der Ebene der Anwendungsserver, Resque (Forks) und Sidekiq (Threads) auf der Ebene der Hintergrundaufgaben, usw.
Die folgende Tabelle zeigt die Unterstützung für Forking und Threading in den wichtigsten Ruby-Implementierungen.
Ruby-Implementierung
Forking
Einfädeln
MRI
Ja
Ja (begrenzt durch GIL**)
JRuby
–
Ja
Rubinius
Ja
Ja
Zwei weitere Zauberwörter tauchen in diesem Thema wie ein Bumerang auf - Parallelität und Gleichzeitigkeit - wir müssen sie ein wenig erklären. Zunächst einmal können diese Begriffe nicht austauschbar verwendet werden. Kurz gesagt - wir können von Parallelität sprechen, wenn zwei oder mehr Aufgaben genau zur gleichen Zeit bearbeitet werden. Von Gleichzeitigkeit spricht man, wenn zwei oder mehr Aufgaben in sich überschneidenden Zeiträumen (nicht notwendigerweise zur gleichen Zeit) bearbeitet werden. Ja, das ist eine grobe Erklärung, aber gut genug, um Ihnen den Unterschied zu verdeutlichen und den Rest dieses Artikels zu verstehen.
Die folgende Tabelle zeigt die Unterstützung für Parallelität und Gleichzeitigkeit.
Ruby-Implementierung
Parallelität (über Forks)
Parallelität (über Threads)
Gleichzeitigkeit
MRI
Ja
Nein
Ja
JRuby
–
Ja
Ja
Rubinius
Ja
Ja (seit Version 2.X)
Ja
Das ist das Ende der Theorie - sehen wir es uns in der Praxis an!
Ein separater Speicher muss nicht unbedingt die gleiche Menge wie der übergeordnete Prozess beanspruchen. Es gibt einige Techniken zur Speicheroptimierung. Eine davon ist Copy on Write (CoW), die es dem Elternprozess ermöglicht, zugewiesenen Speicher mit dem Kindprozess zu teilen, ohne ihn zu kopieren. Mit CoW wird zusätzlicher Speicher nur dann benötigt, wenn ein Kindprozess Änderungen am gemeinsamen Speicher vornimmt. Im Ruby-Kontext ist nicht jede Implementierung CoW-freundlich, z.B. unterstützt MRI es seit der Version 2.X. Vor dieser Version verbrauchte jeder Fork genauso viel Speicher wie ein Elternprozess.
Einer der größten Vorteile/Nachteile von MRI (streichen Sie die unpassende Alternative) ist die Verwendung von GIL (Global Interpreter Lock). Kurz gesagt ist dieser Mechanismus für die Synchronisierung der Ausführung von Threads verantwortlich, was bedeutet, dass nur ein Thread zur gleichen Zeit ausgeführt werden kann. Aber Moment mal... Bedeutet das, dass es überhaupt keinen Sinn macht, Threads in MRI zu verwenden? Die Antwort kommt mit dem Verständnis der GIL-Interna... oder zumindest mit einem Blick auf die Codebeispiele in diesem Artikel.
Testfall
Um zu zeigen, wie Forking und Threading in den Implementierungen von Ruby funktionieren, habe ich eine einfache Klasse namens Test und ein paar andere, die von ihr erben. Jede Klasse hat eine andere Aufgabe zu bearbeiten. Standardmäßig wird jede Aufgabe viermal in einer Schleife ausgeführt. Außerdem läuft jede Aufgabe gegen drei Arten der Codeausführung: sequentiell, mit Forks und mit Threads. Hinzu kommt, Benchmark.bmbm führt den Codeblock zweimal aus - das erste Mal, um die Laufzeitumgebung zum Laufen zu bringen, das zweite Mal, um zu messen. Alle in diesem Artikel vorgestellten Ergebnisse wurden beim zweiten Durchlauf erzielt. Natürlich, auch bmbm Methode garantiert zwar keine perfekte Isolierung, aber die Unterschiede zwischen mehreren Codeläufen sind unbedeutend.
erfordern "Benchmark"
Klasse Test
BETRAG = 4
def run
Benchmark.bmbm do |b|
b.report("sequentiell") { sequentiell }
b.report("forking") { forking }
b.report("threading") { threading }
end
end
privat
def sequentiell
AMOUNT.times { perform }
end
def forking
AMOUNT.times do
fork do
durchführen
end
end
Process.waitall
rescue NotImplementedError => e
# fork-Methode ist in JRuby nicht verfügbar
setzt e
end
def threading
threads = []
AMOUNT.times do
threads << Thread.new do
ausführen.
end
end
threads.map(&:join)
end
def durchführen
raise "nicht implementiert"
end
end
Belastungstest
Führt Berechnungen in einer Schleife aus, um eine hohe CPU-Last zu erzeugen.
class LoadTest < Test
def perform
1000.mal { 1000.mal { 2**3**4 } }
end
end
Lassen wir es laufen...
LoadTest.new.run
...und überprüfen Sie die Ergebnisse
MRI
JRuby
Rubinius
sequenziell
1.862928
2.089000
1.918873
Verzweigung
0.945018
–
1.178322
Einfädeln
1.913982
1.107000
1.213315
Wie Sie sehen können, sind die Ergebnisse der sequentiellen Läufe ähnlich. Natürlich gibt es einen kleinen Unterschied zwischen den Lösungen, der aber durch die zugrunde liegende Implementierung der gewählten Methoden in den verschiedenen Interpretern verursacht wird.
Das Forking bringt in diesem Beispiel einen erheblichen Leistungsgewinn (der Code läuft fast doppelt so schnell).
Threading führt zu ähnlichen Ergebnissen wie Forking, allerdings nur für JRuby und Rubinius. Die Ausführung des Beispiels mit Threads im MRT verbraucht etwas mehr Zeit als die sequenzielle Methode. Dafür gibt es mindestens zwei Gründe. Erstens erzwingt GIL die sequentielle Ausführung von Threads. In einer perfekten Welt sollte die Ausführungszeit die gleiche sein wie bei der sequentiellen Ausführung, aber es entsteht auch ein Zeitverlust für GIL-Operationen (Umschalten zwischen Threads usw.). Zweitens wird auch ein gewisser Overhead für die Erstellung von Threads benötigt.
Dieses Beispiel gibt uns keine Antwort auf die Frage nach dem Sinn von Anwendungsfäden im MRT. Schauen wir uns ein anderes Beispiel an.
Snooze-Test
Führt eine Schlafmethode aus.
class SnoozeTest < Test
def perform
sleep 1
end
end
Hier sind die Ergebnisse
MRI
JRuby
Rubinius
sequenziell
4.004620
4.006000
4.003186
Verzweigung
1.022066
–
1.028381
Einfädeln
1.001548
1.004000
1.003642
Wie Sie sehen können, liefert jede Implementierung ähnliche Ergebnisse nicht nur bei den sequentiellen und Forking-Läufen, sondern auch bei den Threading-Läufen. Warum also hat MRI den gleichen Leistungszuwachs wie JRuby und Rubinius? Die Antwort liegt in der Implementierung von schlafen.
MRTs schlafen Methode ist implementiert mit rb_thread_wait_for C-Funktion, die eine andere Funktion namens nativer_schlaf. Werfen wir einen kurzen Blick auf die Implementierung (der Code wurde vereinfacht, die ursprüngliche Implementierung finden Sie unter hier):
static void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
GVL_UNLOCK_BEGIN();
{
// hier einige Dinge tun
}
GVL_UNLOCK_END();
thread_debug("native_sleep donen");
}
Der Grund, warum diese Funktion so wichtig ist, liegt darin, dass sie nicht nur den strikten Ruby-Kontext verwendet, sondern auch auf den Systemkontext umschaltet, um dort einige Operationen durchzuführen. In solchen Situationen hat der Ruby-Prozess nichts zu tun... Ein gutes Beispiel für Zeitverschwendung? Nicht wirklich, denn es gibt eine GIL, die sagt: "In diesem Thread gibt es nichts zu tun? Wechseln wir in einen anderen und kommen wir nach einer Weile hierher zurück". Dies könnte durch Entsperren und Sperren der GIL mit GVL_UNLOCK_BEGIN() und GVL_UNLOCK_END() Funktionen.
Die Situation wird klar, aber schlafen Methode ist selten nützlich. Wir brauchen mehr Beispiele aus der Praxis.
Test zum Herunterladen von Dateien
Führt einen Prozess zum Herunterladen und Speichern einer Datei aus.
erfordern "net/http"
class DownloadFileTest < Test
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
end
end
Die folgenden Ergebnisse brauchen nicht kommentiert zu werden. Sie sind denen des obigen Beispiels sehr ähnlich.
1.003642
JRuby
Rubinius
sequenziell
0.327980
0.334000
0.329353
Verzweigung
0.104766
–
0.121054
Einfädeln
0.085789
0.094000
0.088490
Ein weiteres gutes Beispiel ist der Kopiervorgang einer Datei oder eine andere E/A-Operation.
Schlussfolgerungen
Rubinius unterstützt sowohl Forking als auch Threading vollständig (seit Version 2.X, als GIL entfernt wurde). Ihr Code könnte nebenläufig sein und parallel laufen.
JRuby leistet gute Arbeit mit Threads, unterstützt aber überhaupt kein Forking. Parallelität und Gleichzeitigkeit können mit Threads erreicht werden.
MRI unterstützt Forking, aber Threading ist durch das Vorhandensein von GIL eingeschränkt. Gleichzeitigkeit kann mit Threads erreicht werden, aber nur, wenn laufender Code außerhalb des Ruby-Interpreter-Kontextes stattfindet (z.B. IO-Operationen, Kernel-Funktionen). Es gibt keine Möglichkeit, Parallelität zu erreichen.