Gleichzeitige Systeme können mithilfe verschiedener Parallelitätsmodelle implementiert werden. Ein Parallelitätsmodell gibt an, wie Threads im System zusammenarbeiten, um die ihnen zugewiesenen Aufgaben zu erledigen. Verschiedene Parallelitätsmodelle verwenden unterschiedliche Methoden zur Aufteilung der Arbeit, und Threads können auf unterschiedliche Weise miteinander kommunizieren und kooperieren. Dieses Tutorial zum Parallelitätsmodell bietet eine ausführliche Erläuterung des zum Zeitpunkt des Schreibens am häufigsten verwendeten Parallelitätsmodells.
Das Parallelitätsmodell ähnelt dem von verteilten Systemen
Das in diesem Text erwähnte Parallelitätsmodell unterscheidet sich von dem, das in verteilten Systemen verwendet wird Der Rahmen ist ähnlich. In einem gleichzeitigen System kommunizieren verschiedene Threads miteinander. Verschiedene Prozesse kommunizieren in einem verteilten System (möglicherweise auf verschiedenen Computern). Im Wesentlichen sind Threads und Prozesse sehr ähnlich. Aus diesem Grund ähneln unterschiedliche Parallelitätsmodelle häufig unterschiedlichen verteilten Frameworks.
Natürlich bringen verteilte Systeme auch zusätzliche Herausforderungen mit sich, wie z. B. Netzwerkausfälle, Abstürze von Remote-Computern und -Prozessen usw. In einem gleichzeitigen System, das auf einem großen Server läuft, können jedoch ähnliche Probleme auftreten, wenn eine CPU ausfällt, eine Netzwerkkarte ausfällt, eine Festplatte ausfällt usw. Obwohl die Wahrscheinlichkeit eines solchen Fehlers gering sein mag, ist er theoretisch möglich.
Da das Parallelitätsmodell dem verteilten System-Framework ähnelt, können sie oft einige Ideen voneinander lernen. Beispielsweise ähnelt das Modell zur Arbeitsverteilung auf Worker (Threads) dem Lastausgleich in verteilten Systemen. Auch die Techniken zur Fehlerbehandlung wie Protokollierung, Fehlertoleranz usw. sind dieselben.
Parallelarbeiter
Das erste Parallelitätsmodell, wir nennen es das Parallelarbeitermodell. Eingehende Aufgaben werden verschiedenen Mitarbeitern zugewiesen. Hier ist ein Diagramm:
Im parallelen Worker-Parallelitätsmodell verteilt ein Agent eingehende Arbeit an verschiedene Worker. Jeder Arbeiter erledigt die gesamte Aufgabe. Der gesamte Worker arbeitet parallel und läuft in verschiedenen Threads und möglicherweise auf verschiedenen CPUs.
Wenn in einer Autofabrik ein paralleles Arbeitermodell implementiert wird, wird jedes Auto von einem Arbeiter produziert. Dieser Arbeiter erhält Bauanweisungen und baut alles von Anfang bis Ende.
Das Parallel-Worker-Parallelitätsmodell ist das am weitesten verbreitete Parallelitätsmodell in Java-Anwendungen (obwohl sich dies ändert). Viele Parallelitätsdienstprogrammklassen im Java-Paket java.util.concurrent sind für die Verwendung dieses Modells konzipiert. Sie können Spuren dieses Modells auch in Java-Unternehmensanwendungen sehen.
Vorteile von Parallelarbeitern
Der Vorteil des Parallelitätsmodells für Parallelarbeiter besteht darin, dass es relativ einfach zu verstehen ist. Um die Parallelität Ihrer Anwendung zu erhöhen, fügen Sie einfach weitere Worker hinzu.
Wenn Sie beispielsweise eine Webcrawler-Funktion implementieren, verwenden Sie eine unterschiedliche Anzahl von Workern, um eine bestimmte Anzahl von Seiten zu crawlen, und sehen, welcher Worker eine kürzere Crawlzeit benötigt (was eine höhere Leistung bedeutet). Da Web Scraping eine IO-intensive Aufgabe ist, kann es sein, dass auf Ihrem Computer mehrere Threads pro CPU/Kern vorhanden sind. Ein Thread pro CPU ist zu wenig, da er lange Zeit im Leerlauf ist und auf das Herunterladen von Daten wartet.
Nachteile von Parallel-Workern
Das Parallel-Worker-Parallelitätsmodell hat unter der Oberfläche einige Nachteile. Die meisten Nachteile erkläre ich in den folgenden Abschnitten.
Der Erwerb gemeinsamer Zustände ist kompliziert
In Wirklichkeit ist das Parallelitätsmodell für parallele Arbeiter komplizierter als oben erläutert. Dieser gemeinsam genutzte Worker muss häufig auf einige gemeinsam genutzte Daten zugreifen, entweder im Speicher oder in einer gemeinsam genutzten Datenbank. Das folgende Diagramm zeigt die Komplexität des parallelen Worker-Parallelitätsmodells.
Ein Teil dieses gemeinsamen Status befindet sich in einem Kommunikationsmechanismus wie einer Arbeitswarteschlange. Ein Teil dieses gemeinsamen Status betrifft jedoch Geschäftsdaten, Datencache, Datenbankverbindungspool usw.
Sobald sich der Shared State in das parallele Worker-Parallelitätsmodell einschleicht, wird es kompliziert. Dieser Thread muss auf irgendeine Weise auf die gemeinsam genutzten Daten zugreifen, um sicherzustellen, dass Änderungen eines Threads für andere Threads sichtbar sind (in den Hauptspeicher übertragen und nicht nur im CPU-Cache der CPU stecken bleiben, die diesen Thread ausführt). Threads müssen Race Conditions, Deadlocks und viele andere Probleme mit der Parallelität gemeinsamer Zustände vermeiden.
Außerdem geht beim Zugriff auf gemeinsam genutzte Datenstrukturen der parallele Rechenteil verloren, wenn Threads aufeinander warten. Viele gleichzeitige Datenstrukturen sind verstopft, was bedeutet, dass jeweils ein Thread oder eine begrenzte Anzahl von Threads darauf zugreifen kann. Dies kann zu Konflikten um diese gemeinsam genutzten Datenstrukturen führen. Hohe Konflikte führen naturgemäß zu einem gewissen Grad an Serialisierung bei der Ausführung von Codeteilen, die auf gemeinsam genutzte Datenstrukturen zugreifen.
Moderne nicht blockierende gleichzeitige Algorithmen können Konflikte reduzieren und die Leistung verbessern, aber nicht blockierende Algorithmen sind schwierig zu implementieren.
Persistente Datenstrukturen sind eine weitere Option. Eine persistente Datenstruktur behält bei Änderung immer ihre vorherige Version. Wenn außerdem mehrere Threads auf dieselbe persistente Datenstruktur verweisen und einer der Threads diese ändert, erhält der modifizierende Thread einen Verweis auf die neue Struktur. Alle anderen Threads behalten Verweise auf die alten Strukturen, die unverändert bleiben. Die Programmiersprache Scala enthält mehrere persistente Datenstrukturen.
Persistente Datenstrukturen erbringen keine gute Leistung und bieten gleichzeitig eine elegante und übersichtliche Lösung für die gleichzeitige Änderung gemeinsam genutzter Datenstrukturen.
Zum Beispiel fügt eine persistente Liste alle neuen Elemente zum Kopf der Liste hinzu und gibt einen Verweis auf das neu hinzugefügte Element zurück (dadurch wird dann der Rest der Liste ausgeführt). Alle anderen Threads behalten weiterhin einen Verweis auf das erste vorherige Element in der Liste bei und die Liste erscheint für andere Threads unverändert. Sie können dieses neu hinzugefügte Element nicht sehen.
Eine solche persistente Liste wird als verknüpfte Liste implementiert. Leider funktionieren verknüpfte Listen in moderner Software nicht gut. Jedes Element in der Liste ist ein separates Objekt, und diese Objekte können über den gesamten Speicher des Computers verteilt sein. Moderne CPUs können viel schneller sequenziell auf Daten zugreifen, sodass die Implementierung auf einem Array anstelle einer Liste zu einer höheren Leistung auf moderner Hardware führt. Ein Array speichert Daten sequentiell. Dieser CPU-Cache kann größere Blöcke gleichzeitig in den Cache laden, und auf die Daten kann nach dem Laden direkt in diesem CPU-Cache zugegriffen werden. Dies lässt sich mit einer verknüpften Liste nicht umsetzen, da die Elemente in der verknüpften Liste über den gesamten RAM verteilt sind.
Staatenlose Arbeiter
Der im System freigegebene Zustand kann von anderen Threads geändert werden. Daher muss der Worker diesen Status jedes Mal erneut lesen, wenn er ihn benötigt, um zu bestätigen, ob er an der neuesten Kopie arbeitet. Dies gilt unabhängig davon, ob sich der freigegebene Status im Speicher oder in einer externen Datenbank befindet. Ein Worker, der den Zustand nicht intern beibehält (sondern jedes Mal neu gelesen werden muss), wird als zustandslos bezeichnet.
Es wird langsamer, wenn Sie die Daten jedes Mal neu lesen müssen. Vor allem, wenn dieser Zustand in einer externen Datenbank gespeichert ist.
Die Reihenfolge der Aufgaben kann nicht bestimmt werden
Ein weiterer Nachteil des Parallel-Worker-Modells besteht darin, dass die Reihenfolge der Ausführung von Aufgaben nicht bestimmt werden kann. Es gibt keine Möglichkeit zu garantieren, welche Aufgabe zuerst und welche zuletzt ausgeführt wird. Aufgabe A kann einem Arbeiter vor Aufgabe B gegeben werden, aber Aufgabe B kann vor Aufgabe A ausgeführt werden.
Die von Natur aus nichtdeterministische Natur des Parallel-Worker-Modells macht es schwierig, über den Zustand des Systems zu einem bestimmten Zeitpunkt nachzudenken. Es wäre auch schwierig sicherzustellen, dass eine Aufgabe vor der anderen erledigt wird (im Grunde unmöglich).
Montagelinie
Das zweite Parallelitätsmodell nenne ich das Fließband-Parallelitätsmodell. Ich habe diesen Namen einfach gewählt, um die Metapher „Parallelarbeiter“ einfacher zu treffen. Andere Entwickler verwenden andere Namen (z. B. reaktive Systeme oder ereignisgesteuerte Systeme), um sich auf die Plattform oder Community zu verlassen. Hier ein Beispielbild zur Veranschaulichung:
Dieser Arbeiter ist wie ein Arbeiter am Fließband in einer Fabrik. Jeder Arbeiter leistet nur einen Teil der Gesamtarbeit. Wenn dieser Teil abgeschlossen ist, überträgt der Arbeiter die Aufgabe an den nächsten Arbeiter.
Jeder Worker läuft in seinem eigenen Thread und es gibt keinen gemeinsamen Status zwischen den Workern. Daher wird dies manchmal als Shared-Nothing-Parallelitätsmodell bezeichnet.
Systeme, die das Pipeline-Parallelitätsmodell verwenden, werden normalerweise mit nicht blockierendem E/A entworfen. Nicht blockierendes E/A bedeutet, dass der Worker nicht auf den Abschluss des E/A-Aufrufs wartet, wenn er einen E/A-Vorgang startet (z. B. das Lesen einer Datei oder von Daten aus einer Netzwerkverbindung). E/A-Vorgänge sind so langsam, dass das Warten auf den Abschluss des E/A-Vorgangs eine CPU-Verschwendung darstellt. Diese CPU kann gleichzeitig einige andere Dinge tun. Wenn der E/A-Vorgang endet, werden die Ergebnisse des E/A-Vorgangs (z. B. der Status des Datenlesens oder Datenschreibens) an einen anderen Worker übergeben.
Für nicht blockierende E/A bestimmt dieser E/A-Vorgang den Grenzbereich zwischen Arbeitern. Ein Worker tut, was er kann, bis er eine E/A-Operation starten muss. Dann gibt es die Aufgabe auf, es zu kontrollieren. Wenn dieser E/A-Vorgang endet, arbeitet der nächste Worker in der Pipeline weiter an dieser Aufgabe, bis dieser ebenfalls einen E/A-Vorgang starten muss, und so weiter.
Tatsächlich laufen diese Aufgaben nicht unbedingt am Fließband ab. Da die meisten Systeme mehr als nur eine Aufgabe ausführen, hängt der Aufgabenfluss zwischen den Mitarbeitern davon ab, welche Aufgabe erledigt werden muss. Tatsächlich werden mehrere verschiedene virtuelle Pipelines gleichzeitig ausgeführt. Das folgende Diagramm zeigt, wie Aufgaben in einem realen Pipelinesystem ablaufen.
Aufgaben können sogar mehr als einen Worker ausführen, um gleichzeitig ausgeführt zu werden. Beispielsweise könnte ein Job sowohl auf einen Task-Executor als auch auf ein Task-Protokoll verweisen. Dieses Diagramm veranschaulicht, wie alle drei Pipelines letztendlich ihre Aufgaben an denselben Worker weiterleiten (der letzte Worker befindet sich in der mittleren Pipeline):
Pipelines können sogar erhalten komplexer als dies.
Reaktives System, ereignisgesteuertes System
Systeme, die ein Pipeline-Parallelitätsmodell verwenden, werden oft als reaktive Systeme, ereignisgesteuerte Systeme bezeichnet. Arbeiter in diesem System reagieren auf Ereignisse im System, die entweder von außen empfangen oder von anderen Arbeitern ausgesendet werden. Beispiele für Ereignisse können eine HTTP-Anfrage oder das Ende einer Datei, die in den Speicher geladen wird, usw. sein.
Zum Zeitpunkt des Verfassens dieses Artikels sind viele interessante reaktive/ereignisgesteuerte Plattformen verfügbar, weitere werden in Zukunft folgen. Einige der häufigeren sehen so aus:
Vert.x
Akka
Node.JS(JavaScript)
Für mich persönlich finde ich Vert. 🎜>
Actor VS
Akteur und Kanal sind zwei ähnliche Beispiele im Pipeline-Modell (oder reaktiven System-/ereignisgesteuerten Modell). Im Akteurmodell wird jeder Arbeiter als Akteur bezeichnet. Schauspieler können sich gegenseitig Nachrichten senden. Nachrichten werden gesendet und dann asynchron ausgeführt. Wie zuvor beschrieben können Akteure zur Umsetzung einer oder mehrerer Aufgaben eingesetzt werden. Hier ist ein Modell von Akteuren:Vorteile von Pipelines
Dieses Pipeline-Parallelitätsmodell hat mehrere Vorteile gegenüber dem Parallel-Worker-Modell. In den folgenden Abschnitten werde ich auf die größten Vorteile eingehen.Kein gemeinsamer Status
Die Tatsache, dass Worker ihren Status nicht mit anderen Workern teilen, bedeutet, dass sie sich nicht um jegliche Parallelität kümmern müssen Problem zu erreichen. Dies erleichtert die Umsetzung der Arbeitskräfte. Wenn Sie einen Worker implementieren und nur ein Thread diese Arbeit ausführt, handelt es sich im Wesentlichen um eine Single-Threaded-Implementierung.Stateful Worker
Da der Worker weiß, dass kein anderer Thread seine Daten ändern wird, ist dieser Worker Status. Zustandsbehaftet bedeutet, dass sie die Daten, die sie für die Bearbeitung benötigen, im Speicher behalten und die letzten Änderungen einfach zurück in das externe Speichersystem schreiben können. Ein Stateful Worker ist daher schneller als ein Stateless Worker.Bessere Hardware-Integration
Single-Threaded-Code hat diesen Vorteil, er passt sich oft besser an die zugrunde liegende Hardware an. Erstens können Sie optimiertere Datenstrukturen und Algorithmen erstellen, wenn Sie davon ausgehen, dass der Code im Single-Thread-Modus ausgeführt werden kann. Zweitens können Single-Threaded Stateful Worker, wie oben erwähnt, Daten im Speicher zwischenspeichern. Beim Zwischenspeichern von Daten im Speicher besteht auch hier eine hohe Wahrscheinlichkeit, dass die Daten auch im CPU-Cache der CPU des ausführenden Threads zwischengespeichert werden. Dadurch wird der Datenzugriff beschleunigt.Ich bezeichne es beim Schreiben des Codes als Hardware-Integration, und zwar auf eine Weise, die von der Funktionsweise der zugrunde liegenden Hardware profitiert. Manche Entwickler nennen dies die Art und Weise, wie die Hardware funktioniert. Ich bevorzuge den Begriff „Hardware-Integration“, da Computer nur sehr wenige mechanische Teile haben und das Wort „Sympathie“ in diesem Artikel als Metapher für „bessere Anpassung“ verwendet wird, während das Wort „konform“ meiner Meinung nach „Vernünftiger“ ausdrückt.
Wie auch immer, das ist eine Kleinigkeit. Benutzen Sie einfach die Wörter, die Ihnen gefallen.
Sequentielle Aufgaben sind möglich
Es ist möglich, ein gleichzeitiges System basierend auf dem Pipeline-Parallelitätsmodell zu implementieren, um die Reihenfolge der Aufgaben für einige sicherzustellen Umfang von. Sequentielle Aufgaben erleichtern die Einschätzung des Zustands des Systems zu einem bestimmten Zeitpunkt. Darüber hinaus können Sie alle eingehenden Aufgaben in ein Protokoll schreiben. Dieses Protokoll kann zur Wiederherstellung ab der Fehlerstelle verwendet werden, falls ein Teil des Systems ausfällt. Diese Aufgabe kann in einer bestimmten Reihenfolge in das Protokoll geschrieben werden, und diese Reihenfolge wird zu einer festen Aufgabenreihenfolge. Das Diagramm des Designs sieht so aus:
Eine feste Aufgabensequenz umzusetzen ist sicherlich nicht einfach, aber möglich. Wenn möglich, würden Aufgaben wie das Sichern, Wiederherstellen von Daten, Kopieren von Daten usw. erheblich vereinfacht. Dies kann alles über Protokolldateien erfolgen.
Nachteile von Pipelining
Der Hauptnachteil des Pipeline-Parallelitätsmodells besteht darin, dass die Aufgabenausführung häufig auf mehrere Mitarbeiter verteilt ist und über Sie erfolgt Mehrere Klassen im Projekt. Daher ist es schwieriger, genau zu erkennen, welcher Code für eine bestimmte Aufgabe ausgeführt wird.
Das Schreiben von Code kann ebenfalls schwierig werden. Worker-Code wird oft als Callback-Funktionen geschrieben. Code, der mehr verschachtelte Rückrufe enthält, kann dazu führen, dass einige Entwickler sich langweilen, welche Rückrufe aufgerufen werden sollen. Callback-Hölle bedeutet lediglich, dass es schwieriger ist, den Überblick darüber zu behalten, was Ihr Code tut, und auch zu bestimmen, welche Daten jeder Callback benötigt, um auf seine Daten zuzugreifen.
Mit dem parallelen Worker-Parallelitätsmodell wird dies einfacher. Sie können diesen Worker-Code öffnen und den ausgeführten Code fast von Anfang bis Ende lesen. Natürlich kann der parallele Worker-Code auch auf verschiedene Klassen verteilt sein, aber die Reihenfolge der Ausführung lässt sich aus dem Code leichter ablesen.
Funktionale Parallelität
Funktionale Parallelität ist das dritte Parallelitätsmodell, das in den letzten Jahren sehr viel diskutiert wurde.
Die Grundidee der funktionalen Parallelität besteht darin, Ihr Programm mithilfe von Funktionsaufrufen zu implementieren. Funktionen werden als „Agenten“ oder „Akteure“ betrachtet, die sich gegenseitig Nachrichten senden, ähnlich wie das Pipeline-Parallelitätsmodell (auch bekannt als reaktive Systeme oder ereignisgesteuerte Systeme). Wenn eine Funktion eine andere aufruft, ähnelt das dem Senden einer Nachricht.
Alle an die Funktion übergebenen Parameter werden kopiert, sodass keine Entität außerhalb der empfangenden Funktion diese Daten kombinieren kann. Diese Kopie ist von entscheidender Bedeutung, um statische Bedingungen für gemeinsam genutzte Daten zu vermeiden. Dadurch ähnelt die Funktion einer atomaren Operation. Jeder Funktionsaufruf kann unabhängig von jedem anderen Funktionsaufruf ausgeführt werden.
Während ein Funktionsaufruf einzeln ausgeführt werden kann, kann jeder Funktionsaufruf auf einer separaten CPU ausgeführt werden. Das bedeutet, dass ein implementierter Funktionsalgorithmus auf mehreren CPUs parallel ausgeführt werden kann.
Mit Java 7 erhalten wir das Paket java.util.concurrent mit ForkAndJoinPool, mit dem Sie ähnliche Dinge wie funktionale Parallelität erreichen können. Mit Java 8 erhalten wir einen parallelen Stream, der Ihnen hilft, die Iteration großer Sammlungen zu parallelisieren. Denken Sie daran, dass es Entwickler gibt, die mit ForkAndJoinPool unzufrieden sind (Links zu einigen Kritikpunkten finden Sie in meinem ForkAndJoinPool-Tutorial).
Der schwierigste Teil der funktionalen Parallelität besteht darin, zu wissen, welche Funktionsaufrufe parallelisiert werden sollen. Das Koordinieren von Funktionsaufrufen über CPUs hinweg bringt einen Mehraufwand mit sich. Eine von einer Funktion abgeschlossene Arbeitseinheit erfordert einen gewissen Overhead. Wenn die Funktionsaufrufe sehr klein sind, kann der Versuch, sie zu parallelisieren, tatsächlich langsamer sein als eine Single-Threaded-Ausführung nur auf der CPU.
Nach meinem Verständnis (das ist natürlich nicht perfekt) können Sie ein reaktives System und einen Zeitantrieb verwenden, um einen Algorithmus zu implementieren und die Zerlegung einer Arbeit abzuschließen. Dies ähnelt der funktionalen Parallelität. Mit einem ereignisgesteuerten Modell haben Sie einfach mehr Kontrolle darüber, wie viel und wie parallelisiert wird (glaube ich).
Außerdem ist die Aufteilung einer Aufgabe auf mehrere CPUs mit Koordinationskosten verbunden, die nur dann sinnvoll sind, wenn diese Aufgabe derzeit die einzige Aufgabe ist, die von diesem Programm ausgeführt wird. Wenn das System jedoch mehrere andere Aufgaben ausführt (z. B. Webserver, Datenbankserver und viele andere Systeme), macht es keinen Sinn, eine einzelne Aufgabe zu parallelisieren. Die anderen CPUs des Computers sind ohnehin mit anderen Aufgaben beschäftigt, sodass es keinen Grund gibt, sie durch eine langsamere funktionale parallele Aufgabe zu stören. Es ist höchstwahrscheinlich ratsam, ein Pipeline-Parallelitätsmodell zu verwenden, da es weniger Overhead verursacht (sequentielle Ausführung im Single-Threaded-Modus) und den zugrunde liegenden Hardwareanforderungen besser entspricht.
Welches Parallelitätsmodell ist das beste
Welches Parallelitätsmodell ist das beste?
Das ist normalerweise der Fall. Die Antwort hängt davon ab, wie Ihr System aussehen wird. Wenn Ihre Aufgaben von Natur aus parallel und unabhängig sind und keinen gemeinsamen Status erfordern, können Sie zur Implementierung Ihres Systems ein paralleles Arbeitsmodell verwenden.
Viele Aufgaben sind jedoch nicht von Natur aus parallel und unabhängig. Für diese Art von Systemen hat dieses Pipeline-Parallelitätsmodell meiner Meinung nach mehr Vor- als Nachteile und größere Vorteile als das Parallel-Worker-Modell.
Sie müssen nicht einmal den Code der Pipeline-Struktur selbst schreiben. Moderne Plattformen wie Vert.x erledigen bereits einen Großteil davon für Sie. Ich persönlich werde in meinem nächsten Projekt Designs erkunden, die auf einer Plattform wie Vert.x laufen. Ich bin der Meinung, dass Java EE keine Vorteile haben wird.
Das Obige ist eine detaillierte Einführung in das Java-Parallelitätsmodell. Weitere verwandte Inhalte finden Sie auf der chinesischen PHP-Website (www.php.cn)!