|
|||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||
|
Die Kosten der Synchronisation
|
||||||||||||||||||||||||
In den bisherigen beiden Beitrag unserer Reihe über Aspekte des Java-Memory-Modells (JMM) haben wir uns die Grundzüge des Modells und seine Sichtbarkeitsregeln angesehen (siehe / JMM2 /). Weiter haben wir ein Beispiel für einen typischen Fehler betrachtet, der sich aus der Mißachtung des JMM ergeben kann (siehe / JMM1 /). In diesem Beitrag wollen wir nun genauer darauf eingehen, warum man die Regeln des JMM überhaupt kennen sollte. Vielfach wird ausschließlich mit Synchronisation gearbeitet, so dass volatile-, final- und atomare Variablen kaum eine Rolle spielen. Wozu soll man sich also mit den JMM-Regeln dafür befassen? In diesem Beitrag wollen wir uns ansehen, welche Schwachpunkte Synchronisation hat und warum sie in Zukunft in zunehmendem Maße durch andere Techniken, die volatile-, final- und atomare Variablen benötigen, ersetzt werden wird. Synchronisation von konkurrienden Zugriffen auf gemeinsam verwendete veränderliche Daten mit Hilfe von Locks ist in vielen Fällen unerlässlich, um die Konsistenz der Daten sicher zu stellen. Leider sind mit der Synchronisation Kosten verbunden. Zum einen sind es Performance-Kosten, die einfach dadurch entstehen, dass Synchronisation mit Arbeit verbunden ist, die das Laufzeitsystem erbringen muss. Zum anderen sind es Kosten, die durch einen Mangel an Skalierbarkeit entstehen. Performance-Einbußen durch SynchronisationDas Anfordern und Freigeben eines Locks löst eine Reihe von Aktionen im Laufzeitsystem aus, die CPU-Zeit und damit Ablauf-Performance kosten.So muss beim Anfordern eines Locks die zu dem Lock gehörende Warteschlange gefunden, abgefragt und ggf. modifiziert werden. In der Warteschlange stehen all die Threads, die darauf warten, das Lock nach seiner Freigabe zu bekommen. Die Verwaltung dieser Warteschlange kostet CPU-Zeit und Ressourcen. Die tatsächliche Zuteilung eines Locks an einen Thread löst nicht nur den Update von Verwaltungsinformation in der virtuellen Maschine aus (das Lock ist jetzt besetzt und diese Information muss vermerkt werden), sondern auch Memory Barriers, die dafür sorgen, dass der nun aktivierte Thread seinen Cache auffrischt. Eine solche Memory Barrier ist eine Anweisung, die die virtuelle Maschine an die Hardware der Maschine erteilt, damit Prozessoren ihre Caches mit dem Hauptspeicher abgleichen. Dieser Abgleich ist erforderlich, weil er von den Sichtbarkeitsregeln des JMM verlangt wird, wie wir im letzten Beitrag erläutert haben (siehe / JMM2 /). Eine solche Memory Barrier reduziert die Effizienz der Prozessoren, weil sie in den Caching-Algorithmus der Hardware eingreift. Das heißt, die mit der Synchronisation verbundenen Speichereffekte kosten Performance. Die Freigabe eines Locks löst erneut Zugriffe auf die zu dem Lock gehörende Warteschlange aus, denn einer der wartenden Threads muss aufgeweckt werden. Die Freigabe des Locks löst außerdem erneut eine Memory Barrier aus, damit der freigebende Thread seine Cache-Inhalte den anderen Threads sichtbar macht. All dies sind Aufwände, die allein durch die Synchronisation entstehen. In einer Single-CPU-Umgebung sind dies zusätzliche Laufzeitkosten, die nicht durch Laufzeitgewinn an anderer Stelle kompensiert werden. In einer Multiprozessor-Umgebung wird der Overhead in der Regel durch die erhöhte Ausnutzung der Prozessorleistung ausgeglichen oder sogar überkompensiert. Wie hoch die Kosten der Synchronisation sind, läßt sich allgemein nicht sagen, sondern kann nur durch eine entsprechende Messung in der jeweils relevanten Umgebung verlässlich bestimmt werden, weil der Overhead von der Implementierung der virtuellen Maschine und deren Zusammenspiel mit dem Betriebsystem und der Hardware abhängt. Dass die Implementierungen unterschiedlich sind, kann man bereits daran sehen, dass in der virtuellen Maschine von Sun die alten impliziten Locks dahingehend optimiert sind, dass sie relativ preiswert sind, wenn es keine Konkurrenz beim Anfordern des Locks gibt, wohingegen die neuen expliziten Locks dahingehend optimiert sind, dass sie effizient sind, gerade wenn es sehr viel Konkurrenz gibt. Die Laufzeiteinbußen durch Memory Barriers, die Verwaltung der Warteschlange, etc. machen aber nur einen Teil der Kosten der Synchronisation aus. Hinzu kommt noch die Einbuße an Skalierbarkeit. Skalierbarkeitseinbußen durch SynchronisationSynchronisation mit Hilfe von Locks führt dazu, dass gewisse Teile des Programms immer nur von einem Thread zu einer Zeit ausgeführt werden können. Das führt unter Umständen dazu, dass viele Threads warten und nur wenige Threads aktiv sind. Insgesamt ergibt sich daraus eine schlechte Ausnutzung der Prozessorleistung. Das ist in Multicore- und Multi- Prozessor-Umgebungen nachteilig, weil die Leistung der vielen Kerne bzw. Prozessoren nicht angemessen genutzt wird. Wenn im ungünstigsten Falle nur ein einziger Thread aktiv ist und alle anderen warten, dann wird auch nur ein einziger Kern bzw. Prozessor ausgelastet und alle anderen bleiben ungenutzt. Das heißt, Programme mit viel Synchronisation skalieren schlecht in dem Sinne, dass sie zusätzliche Ressourcen in Form von zusätzlichen parallelen CPUs nur unzureichend ausnutzen.Üblicherweise wird von einer Anwendung aber erwartet, dass sie auf einer Architektur mit höherer Prozessorleistung schneller abläuft. Dies wird auch erwartet, wenn die höhere Prozessorleistung nicht durch eine erhöhte Prozessorgeschwindigkeit, sondern durch durch eine erhöhte Anzahl von Prozessoren bzw. Prozessorkernen erbracht wird. Dabei ist die naive Erwartung an ein Programm meist, dass es beim Ablauf auf einem System mit zwei CPUs doppelt so schnell läuft wie auf einem System mit einer einzelnen CPU vom gleichen Typ. Diese einfache Rechnung ist aber falsch. Amdahl's LawDer theoretisch erzielbare Performance-Gewinn durch zusätzliche Prozessoren wird nach dem Gesetz von Amdahl (siehe / AMDL /) bestimmt. Dazu ermittelt man den Prozentsatz des Programms, der parallel abläuft, und den Teil des Programms, der nicht parallelisierbar ist. Zu dem nicht parallellisierbaren Teil des Programms gehören alle synchronisierten Teile des Programms.Wenn F der Bruchteil ist, der nicht parallel ablaufen kann, und N die Zahl der Prozessoren, dann ist der maximal erreichbare Performance-Zugewinn begrenzt durch:
Bei unendlich vielen Prozessoren, konvergiert der maximale Performance-Zugewinn gegen 1/F. Wenn also 50% eines Programms parallel ablaufen und 50% sequentiell, dann wird das Programm maximal (bei unendlich vielen Prozessoren) doppelt so schnell. Beim Umstieg von einem auf zwei Prozessoren wird es lediglich um maximal 25% schneller. Das heißt, die naive Rechnung "doppelt so viele CPUs = doppelt so schnell" ist meist höchst unzutreffend. Der Zugewinn wächst mit der Zahl der verfügbaren Prozessoren, aber auch mit dem Prozentsatz der Parallelverarbeitung in der Anwendung. Synchronisation jedoch reduziert den Anteil, der parallel ablaufen kann. Hier ist eine ernüchternde Grafik, die den Performance-Gewinn je zusätzlichem Prozessor zeigt abhängig vom nicht-parallelisierbaren Anteil des Programms:
Vollständig parallel?Nun ist es in der Praxis schwierig, den Prozentsatz an Parallelverarbeitung in einer Anwendung präzise zu bestimmen. Bestenfalls kann man schätzen. Auch dabei leitet die Intuition gelegentlich in die Irre. Selbst Programmteile, die hochgradig parallel aussehen, haben sequentielle Anteile, was oftmals übersehen wird. Sehen wir uns dazu ein Beispiel an: eine Consumer-Producer-Situation, in der Produzenten-Threads Daten in eine LinkedBlockingQueue stecken, die von Konsumenten-Threads aus der Queue geholt und verarbeitet werden.Das sieht nach einer hochgradigen Parallelverarbeitung aus und man könnte auf den Gedanken kommen, dass F, der Prozentsatz des nicht-parallelen Anteils, gleich Null ist. Das ist aber nicht der Fall, wie der Blick auf die Implementierung der LinkedBlockingQueue zeigt. Hier ist ein Ausschnitt aus dem Source-Code der Implementierung, nämlich die offer-Methode, mit der die Produzenten Objekte in der Queue ablegen:
public boolean offer(E o) {
Man kann sehen, dass es in dieser Methode einen Anteil gibt, der nicht parallel abläuft, sondern der durch ein Lock sequentialisiert ist. Das heißt, um die Konsistenz der von den Threads gemeinsam verwendeten Queue zu sichern, ist ein gewisser Anteil an Sequentialisierung der Zugriffe unerlässlich. Die Sequentialisierung in dieser Methode ist dabei keineswegs überflüssig oder ungeschickt implementiert. Im Gegenteil, die Implementierung der LinkedBlockingQueue ist hochgradig perfektioniert und nach allen Regeln der Kunst optimiert. Beispielsweise wird Lock-Splitting eingesetzt: statt eines einzigen Locks für die gesamte Datenstruktur gibt es ein putLock und ein takeLock, damit sich Produzenten und Konsumenten nicht gegenseitig behindern. Außerdem werden die Zugriffe auf das Feld count, das die jeweils aktuelle Zahl der Objekte in der Queue angibt, nicht mit Synchronisation gemacht, sondern lock-free mit Hilfe eines AtomicInteger. Zusätzlich werden konkurrierende Zugriffe auf Felder der Datenstruktur von vornherein vermieden, indem lokale Kopien auf dem Stack angelegt und verwendet werden. Aber selbst mit einer so hochoptimierten Implementierung, die sich bemüht, Synchronisation nach Möglichkeit zu vermeiden, bleibt ein kleiner Anteil an Synchronisation unvermeidlich. Die take-Methode sieht so ähnlich aus; auch dort gibt es einen sequentiellen Anteil. Man gelangt also zu der Erkenntnis, dass selbst hochgradig parallele Verarbeitungen geringe sequentielle Anteile haben, die die Skalierbarkeit der Anwendung reduzieren. Wenn man dann noch überlegt, dass wir in der Regel Frameworks und andere Third-Party-Komponenten (wie hier die LinkedBlockingQueue aus dem JDK) einsetzen, die nicht-parallele Anteilen mitbringen, dann wird langsam klar, dass ein Anteil von 99,9% Parallelverarbeitung in einer Anwendung zwar theoretisch denkbar ist, in der Praxis aber wohl kaum vorkommen dürfte. Sequentialisierung bekämpfen
Was kann man also tun, um die sequentiellen Anteile der eigenen Software zu reduzieren?
ZusammenfassungIn diesem Beitrag haben wir gesehen, dass Synchronisation teuer ist und worin die Kosten bestehen. In Multi-Prozessor- oder Multicore-Umgebungen wirkt sich die Synchronisation negativ auf die Skalierbarkeit des Programms aus. Tendenziell wird man also versuchen, Synchronisation zu reduzieren oder nach Möglichkeit ganz zu vermeiden. Techniken dafür haben wir kurz angesprochen. Dabei spielen volatile-, final- und atomare Variablen eine Rolle. Deshalb werden wir uns in den nächsten Beiträgen diese Variablen genauer ansehen.Literaturverweise und weitere Informationsquellen
Die gesamte Serie über das Java Memory Model:
|
|||||||||||||||||||||||||
© Copyright 1995-2015 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/39.JMM-CostOfSynchronization/39.JMM-CostOfSynchronization.html> last update: 22 Mar 2015 |