Einführung in die Parallelverarbeitung + neue parallele Ausführungseinheit Ractor in Ruby

Dieser Artikel bietet eine Einführung in die Parallelverarbeitung. Es wird auch die neue parallele Ausführungseinheit Ractor erwähnt, die in Ruby entwickelt wird.

Zunächst werde ich die Begriffe zusammenfassen, die bei der Erörterung dieses Themas häufig verwechselt werden.

Über Parallelverarbeitung (parallel) und Parallelverarbeitung (gleichzeitig)

Bei der ** Parallelverarbeitung ** werden zu einem bestimmten Zeitpunkt mehrere Prozesse gleichzeitig ausgeführt. ** Parallelverarbeitung ** verarbeitet mehrere Prozesse in der Reihenfolge nach Zeitteilung. Im Gegensatz zur Parallelverarbeitung wird zu einem bestimmten Zeitpunkt immer nur ein Prozess gleichzeitig ausgeführt.

Wenn der Zeitpunkt, zu dem mehrere Prozesse ausgeführt werden, in chronologischer Reihenfolge angezeigt wird, wird das unten gezeigte Bild erhalten. (Die Verarbeitung wird nur in dem Teil mit der blauen Linie ausgeführt.) image.png

Dieser Artikel befasst sich mit dem Verhalten der Parallelverarbeitung. Beachten Sie jedoch, dass sich Code, der für die Parallelverarbeitung geschrieben wird, möglicherweise wie Parallelverarbeitung verhält. (Beispielsweise kann eine 1-Kern-CPU nicht zwei oder mehr Prozesse parallel betreiben.) Das Betriebssystem und die VM planen diesen Bereich auf gute Weise.

Multiprozess und Multithread

Im Allgemeinen gibt es zwei Hauptmethoden, um eine parallele Verarbeitung zu erreichen: ** Multiprozess ** und ** Multithread **. Multi-Prozess ist eine Methode zum Erstellen mehrerer Prozesse und zum Ausführen von jeweils einem Prozess. Multithreading ist eine Methode zum Erstellen mehrerer Threads in einem Prozess und zum Ausführen von jeweils einem Prozess.

Bei mehreren Prozessen wird der Speicherplatz in jedem Prozess getrennt. Daher ist es grundsätzlich unmöglich, Variablen zwischen Prozessen zu übergeben. Es ist auch sehr sicher, da es unbeabsichtigte speichervermittelte Interaktionen zwischen Prozessen verhindert. Der Nachteil ist, dass jeder Prozess über einen Speicherplatz verfügt, sodass die Gesamtspeicherauslastung tendenziell zunimmt. (Unter Linux wird der Speicher zwischen den Prozessen jedoch durch den Mechanismus "Beim Schreiben kopieren" so weit wie möglich gemeinsam genutzt.)

Beim Multithreading verfügt ein Prozess über mehrere Threads, sodass der Speicherplatz von den Threads gemeinsam genutzt wird. Daher kann die Speichernutzung unterdrückt werden, und je nach Implementierung ist das Erstellen und Umschalten von Threads einfacher als das Erstellen und Umschalten von Prozessen. Da sich Threads jedoch gegenseitig über den Speicher beeinflussen können, treten häufig Fehler wie Datenkonflikte auf. Im Allgemeinen muss die Multithread-Programmierung viele Dinge berücksichtigen und ist schwierig korrekt zu implementieren.

Die Einheit, in der ein Prozess in paralleler Verarbeitung ausgeführt wird, wird als ** parallele Ausführungseinheit ** bezeichnet. Im Fall von mehreren Prozessen ist die parallele Ausführungseinheit ein Prozess, und im Fall von mehreren Threads ist es ein Thread.

(Ergänzung) So realisieren Sie die Thread-Verarbeitung

Es gibt zwei Hauptimplementierungsmethoden für die Thread-Verarbeitung: ** nativer Thread ** und ** grüner Thread **. Der native Thread ist eine Methode zur Realisierung der Multithread-Verarbeitung unter Verwendung der Betriebssystemimplementierung. Da es Sache des Betriebssystems ist, Threads zu planen (zu entscheiden, welcher Thread ausgeführt werden soll), wird die Implementierung des Verarbeitungssystems einfach. Auf der anderen Seite gibt es einen Nachteil, dass die Verarbeitung der Thread-Erstellung und -Schaltung (sogenannter Kontextwechsel) schwer ist. (Übrigens ist nativer Thread ein Konzept, das Kernel-Thread und Lightweight-Prozess kombiniert, aber Details werden weggelassen. Ich bin der Meinung, dass nativer Thread und Kernel-Thread häufig gemischt werden.)

Der grüne Thread ist ein Thread, der ursprünglich von einer sprachverarbeitenden virtuellen Maschine implementiert wurde (z. B. yarv of cruby, jvm of java usw.), und ist eine Methode zur Realisierung der Multi-Thread-Verarbeitung. Die Goroutine von Golang ist auch eine Art grüner Faden, und ihre Leichtigkeit ist zu berühmt. In Cruby wurde es vor 1.9 von Green Thread implementiert, aber jetzt wurde es geändert, um nativen Thread zu verwenden. Grüne Threads werden auch als Benutzer-Threads bezeichnet.

Beispiel für einen Multithread-Code mit mehreren Prozessen

Als Beispiel wird die Implementierung der Parallelverarbeitung in Ruby gezeigt. In Ruby können Sie die Parallelverarbeitung mithilfe des Edelsteins Parallel einfach beschreiben.

Der Multiprozesscode sieht folgendermaßen aus:

multi_process.rb


require 'parallel'

Parallel.each(1..10, in_processes: 10) do |i|
  sleep 10
  puts i
end

Wenn Sie diesen Code ausführen und die Prozessliste anzeigen, sieht er folgendermaßen aus: Sie können sehen, dass ein Hauptprozess und 10 untergeordnete Prozesse stattfinden.

$ ps aux | grep ruby
  PID  %CPU %MEM      VSZ    RSS   TT  STAT STARTED      TIME COMMAND PRI     STIME     UTIME
79050   9.7  0.1  4355568  14056 s005  S+    2:39PM   0:00.28 ruby mp.rb
79072   0.0  0.0  4334968   1228 s005  S+    2:39PM   0:00.00 ruby mp.rb
79071   0.0  0.0  4334968   1220 s005  S+    2:39PM   0:00.00 ruby mp.rb
79070   0.0  0.0  4334968   1244 s005  S+    2:39PM   0:00.00 ruby mp.rb
79069   0.0  0.0  4334968   1244 s005  S+    2:39PM   0:00.00 ruby mp.rb
79068   0.0  0.0  4334968   1172 s005  S+    2:39PM   0:00.00 ruby mp.rb
79067   0.0  0.0  4334968   1180 s005  S+    2:39PM   0:00.00 ruby mp.rb
79066   0.0  0.0  4334968   1208 s005  S+    2:39PM   0:00.00 ruby mp.rb
79065   0.0  0.0  4334968   1252 s005  S+    2:39PM   0:00.00 ruby mp.rb
79064   0.0  0.0  4334968   1168 s005  S+    2:39PM   0:00.00 ruby mp.rb
79063   0.0  0.0  4334968   1168 s005  S+    2:39PM   0:00.00 ruby mp.rb

Der Multithread-Code sieht folgendermaßen aus:

multi_threads.rb


require 'parallel'

Parallel.each(1..10, in_threads: 10) do |i|
  sleep 10
  puts i
end

Schauen Sie sich auch hier die Thread-Liste an.

Wenn Sie dem Befehl "ps" "-L" hinzufügen, wird der Thread wie ein Prozess angezeigt. Ohne -L gibt es nur einen Prozess, aber mit -L werden 11 Zeilen angezeigt. Darüber hinaus zeigt die Spalte "NLWP" die Anzahl der Threads im Prozess an. Da dies 11 ist (Hauptthread x1 + Arbeitsthread x10), ist ersichtlich, dass die Verarbeitung Multithreading ist.

$ ps aux | grep mt.rb
 4419  1.0  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
$ ps aux -L | grep mt.rb
  PID   LWP %CPU NLWP %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
 4419  4419  6.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4453  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4454  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4455  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4456  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4457  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4458  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4460  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4461  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4462  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb
 4419  4463  0.0   11  0.6 850176 12384 pts/1    Sl+  15:41   0:00 ruby mt.rb

Schwierigkeiten bei der Multithread-Programmierung

Bei der Multithread-Verarbeitung können verschiedene Probleme auftreten, da die Verarbeitung parallel zu mehreren Threads ausgeführt wird, die sich einen Speicher teilen. Eines der Hauptprobleme ist ** Data Racing **.

Datenrennen können mit Code wie dem folgenden stattfinden. Dieser Code versucht, die Summe der Ganzzahlen von 1 bis 10 zu ermitteln. Aufgrund von Datenrennproblemen ist es jedoch möglicherweise nicht möglich, die Summe korrekt zu ermitteln.

require 'parallel'

sum = 0;

Parallel.each(1..10, in_threads: 10) do |i|
  add = sum + i
  sum = add
end

puts sum

In diesem Code teilt jeder Thread die Variable "sum" und jeder Thread liest und schreibt gleichzeitig sum. Infolgedessen kann der in einem Thread geschriebene Inhalt von einem anderen Thread überschrieben werden. Daher besteht das Problem, dass der obige Code die Summe möglicherweise nicht normal berechnen kann.

Ein üblicher Weg, um Probleme mit Datenrennen zu lösen, besteht darin, exklusive Sperren zwischen Threads vorzunehmen.

require 'parallel'

sum = 0;
m = Mutex.new

Parallel.each(1..10, in_threads: 10) do |i|
  m.lock
  add = sum + i
  sum = add
  m.unlock
end

puts sum

Infolgedessen wird jeweils nur ein Thread ausgeführt, während die Sperre gehalten wird, und das Datenrennen wird eliminiert.

Code, der diese Probleme richtig berücksichtigt und beim Multithreading gut funktioniert, wird als "thread-sicher" bezeichnet.

Informationen zur globalen Interpreter-Sperre

** GIL ** wird häufig in der Multithread-Verarbeitung in leichtgewichtigen Sprachen (Ruby, Python usw.) erwähnt. In Ruby heißt es übrigens GVL (Giant VM Lock).

GIL verhindert, dass mehrere Threads gleichzeitig ausgeführt werden, indem eine exklusive Steuerung zwischen Threads durchgeführt wird. Mit anderen Worten, es kann immer nur ein Thread gleichzeitig in einem Interpreter und einer VM ausgeführt werden. Gründe und Vorteile dieses Bedarfs sind:

  1. Bei der Multithread-Programmierung ist es nicht mehr erforderlich, die exklusive Verarbeitung für jede einzelne Datenstruktur zu beschreiben.
  1. Native Plug-In-Implementierungen sind häufig nicht threadsicher, aber um sie sicher auszuführen, ohne ihre Implementierungen zu ändern
  2. Die VM-Implementierung selbst ist nicht threadsicher

Dank GIL funktioniert der gerade illustrierte Ruby-Code für die Multithread-Programmierung ohne Mutex einwandfrei. Dieses Verhalten unterscheidet sich nicht von Rubys grundlegender Idee, die Programmierung zu vereinfachen.

Die Tatsache, dass jeweils nur ein Thread ausgeführt werden kann, bedeutet jedoch, dass die ursprüngliche Parallelverarbeitung nicht möglich ist. Aus diesem Grund wird häufig erwähnt, dass Ruby und Python nicht für die parallele Berechnung geeignet sind.

In Ausnahmefällen bricht der Thread beim Warten auf E / A die GIL ab, sodass mehrere Threads gleichzeitig die Verarbeitung ausführen können. Aus diesem Grund wird bei einer Verarbeitung, die viel E / A-Wartezeit erfordert (Webserver usw.), Multithreading praktisch sogar in einem Verarbeitungssystem mit GIL verwendet.

Implementierungsbeispiel

Da HTTP-Server normalerweise jede Anforderung gleichzeitig verarbeiten müssen, wird häufig eine parallele Verarbeitung implementiert. Typische HTTP-Server in Ruby sind ** Einhorn ** und ** Puma **, ersteres ist eine Multiprozess-Implementierung und letzteres ist eine Multithread-Implementierung.

Die Leistung von Einhorn und Puma wird in [diesem Blog] verglichen (https://deliveroo.engineering/2016/12/21/unicorn-vs-puma-rails-server-benchmarks.html).

Das Fazit dieses Blogs lautet wie folgt:

image.png image.png Quelle

Dies ist selbst unter Berücksichtigung des obigen Mechanismus ein überzeugendes Ergebnis.

Über Ractor

Bisher haben wir erklärt, wie Parallelverarbeitung realisiert werden kann, und die Implementierung in Ruby und ihre Leistung gezeigt. Bei der Multithread-Verarbeitung in Ruby besteht das Problem, dass die ursprüngliche Leistung aufgrund von GVL nicht erreicht werden kann. Ractor (ehemals Guild) ist ein neuer Ruby-Parallelverarbeitungsmechanismus, der zur Lösung dieses Problems entwickelt wurde.

Ractor kann eine echte Multithread-Leistung erzielen und gleichzeitig den Vorteil beibehalten, dass die herkömmliche GVL-Multithread-Programmierung einfacher zu handhaben ist.

Ich werde den Mechanismus erklären.

Ractors Idee

Datenrennen treten auf, weil mehrere Threads in eine einzelne Variable lesen und schreiben können, da sich die Threads den Speicher teilen. Der Weg, dies zu lösen, ist

  1. Machen Sie alle Variablen schreibgeschützt (unveränderlich)
  2. Von Threads gemeinsam genutzte Variablen werden nach Typ angegeben und zur Kompilierungszeit für Prozesse erkannt, die nicht threadsicher sind.
  3. Machen Sie den Speicher für jede parallele Ausführungseinheit unabhängig

In Ractor wurden drei Methoden angewendet. Diese neue parallele Ausführungseinheit heißt Ractor. Ein Ruby-Prozess hat einen oder mehrere Ractors und ein Ractor hat einen oder mehrere Threads. Da jeder Ractor in einem separaten Speicherbereich arbeitet, ist es kein Problem, Speicher wie in herkömmlichen Threads gemeinsam zu nutzen.

image.png Quelle: https://www.slideshare.net/KoichiSasada/guild-prototype

Darüber hinaus kann Ruby-Code vor der Einführung von Ractor die Abwärtskompatibilität aufrechterhalten, indem er in einem Ractor ausgeführt wird.

So teilen Sie Daten zwischen Ractors

Da Ractors keinen gemeinsamen Speicher haben, ist es möglicherweise umständlich, Informationen weiterzugeben. Um dies zu lösen, gibt es auch eine Funktion namens "Kanal", die die Kommunikation zwischen Ractors realisiert. Objekte, die Sie freigeben möchten, können nur über "Kanal" übergeben werden.

Objekte werden in ** gemeinsam nutzbare Objekte ** und ** nicht gemeinsam nutzbare Objekte ** eingeteilt.

Ein gemeinsam nutzbares Objekt ist ein Objekt, z. B. eine schreibgeschützte Konstante, das keine Datenrennen verursachen kann, selbst wenn es von Faktoren gemeinsam genutzt wird. Gemeinsam nutzbare Objekte können über den Kanal frei geteilt werden.

Nicht gemeinsam nutzbare Objekte beziehen sich auf allgemeine veränderbare Objekte. Wenn Sie dieses Objekt durch einen Kanal führen, wird die Semantik tief kopiert oder verschoben. Im Fall von Deep Copy steigen die Kosten für die Kopierverarbeitung und die Speichernutzung, aber es ist genauso sicher und leicht zu verstehen wie Multiprozesse. Bei der Verschiebungssemantik wird das Eigentum an dem Objekt auf einen anderen Faktor übertragen. Daher kann der ursprüngliche Ractor nicht auf das Objekt verweisen, aber im Gegensatz zu Deep Copy steigen die Verarbeitungskosten und die Speichernutzung nicht so stark wie beim Kopieren.

Zusammenfassung:

Auf diese Weise realisiert Ractor eine einfache Multithread-Programmierung bei gleichzeitiger Wahrung der Thread-Sicherheit.

Ractor ist eine parallele Ausführungseinheit zwischen Prozessen und Threads. Durch die richtige Auswahl der Informationen, die der Entwickler zwischen Ractors teilen möchte, kann eine parallele Verarbeitung realisiert werden, ohne die RAM-Nutzung im Vergleich zu Multiprozessen zu erhöhen und ohne die Leistung aufgrund von GIL zu beeinträchtigen.

Ractors Geschenk

Ractor erhält als neues Feature in Ruby 3 viel Aufmerksamkeit. Es scheint, dass sich Ractor selbst noch in der Entwicklung befindet, und es wird eine Weile dauern, bis es die Reichweite allgemeiner Ruby-Benutzer erreicht. In Zukunft wird erwartet, dass die Ruby-Multithread-Bibliothek in Ractor erneut implementiert wird. Es kann nahe an der Zeit sein, in der HTTP-Server, die Puma ersetzen, zum Mainstream werden.

Verweise

Dies ist ein Satz, der für das Studium zusammengefasst wurde. Ich würde mich freuen, wenn Sie auf Fehler hinweisen könnten!

Recommended Posts

Einführung in die Parallelverarbeitung + neue parallele Ausführungseinheit Ractor in Ruby
Einführung in Ruby 2
Parallele Ausführung in Java
Einführung in Micronaut 2 ~ Unit Test ~
Gemessene Parallelverarbeitung mit Java
Wie man in Ruby auf unbestimmte Zeit iteriert
Versuchen Sie, Yuma in Ruby zu implementieren
Einführung in Algorithmen mit Java-kumulativer Summe
So installieren Sie Bootstrap in Ruby
[Super Einführung] Über Symbole in Ruby
Wechseln Sie in einem neuen Ruby on Rails-Projekt von SQLite3 zu PostgreSQL
So debuggen Sie die Verarbeitung im Ruby on Rails-Modell nur mit der Konsole
So fügen Sie die Verarbeitung mit einer beliebigen Anzahl von Elementen in die iterative Verarbeitung in Ruby ein
Verfahren zum lokalen Erstellen einer Ruby-Ausführungsumgebung
So implementieren Sie die asynchrone Verarbeitung in Outsystems
So starten Sie einen Index aus einer beliebigen Zahl in der iterativen Ruby-Verarbeitung