JavaRush /Java-Blog /Random-DE /Grundlagen der Parallelität: Deadlocks und Objektmonitore...
Snusmum
Level 34
Хабаровск

Grundlagen der Parallelität: Deadlocks und Objektmonitore (Abschnitte 1, 2) (Übersetzung des Artikels)

Veröffentlicht in der Gruppe Random-DE
Quellartikel: http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html Gepostet von Martin Mois Dieser Artikel ist Teil unseres Java Concurrency Fundamentals- Kurses . In diesem Kurs tauchen Sie in die Magie der Parallelität ein. Sie erlernen die Grundlagen von Parallelität und parallelem Code und machen sich mit Konzepten wie Atomizität, Synchronisation und Thread-Sicherheit vertraut. Schauen Sie es sich hier an !

Inhalt

1. Liveness  1.1 Deadlock  1.2 Starvation 2. Objektmonitore mit wait() und notify()  2.1 Verschachtelte synchronisierte Blöcke mit wait() und notify()  2.2 Bedingungen in synchronisierten Blöcken 3. Design für Multithreading  3.1 Unveränderliches Objekt  3.2 API-Design  3.3 Lokaler Thread-Speicher
1. Vitalität
Bei der Entwicklung von Anwendungen, die zur Erreichung ihrer Ziele Parallelität nutzen, kann es zu Situationen kommen, in denen sich verschiedene Threads gegenseitig blockieren können. Wenn die Anwendung in dieser Situation langsamer als erwartet läuft, würden wir sagen, dass sie nicht wie erwartet läuft. In diesem Abschnitt werfen wir einen genaueren Blick auf Probleme, die die Überlebensfähigkeit einer Multithread-Anwendung gefährden können.
1.1 Gegenseitige Sperrung
Der Begriff „Deadlock“ ist unter Softwareentwicklern wohlbekannt und selbst die meisten normalen Benutzer verwenden ihn von Zeit zu Zeit, wenn auch nicht immer im richtigen Sinne. Streng genommen bedeutet dieser Begriff, dass jeder von zwei (oder mehr) Threads darauf wartet, dass der andere Thread eine von ihm gesperrte Ressource freigibt, während der erste Thread selbst eine Ressource gesperrt hat, auf deren Zugriff der zweite Thread wartet: Zum besseren Verständnis Um das Problem zu lösen, schauen Sie sich den Thread 1: locks resource A, waits for resource B Thread 2: locks resource B, waits for resource A folgenden Code an: public class Deadlock implements Runnable { private static final Object resource1 = new Object(); private static final Object resource2 = new Object(); private final Random random = new Random(System.currentTimeMillis()); public static void main(String[] args) { Thread myThread1 = new Thread(new Deadlock(), "thread-1"); Thread myThread2 = new Thread(new Deadlock(), "thread-2"); myThread1.start(); myThread2.start(); } public void run() { for (int i = 0; i < 10000; i++) { boolean b = random.nextBoolean(); if (b) { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); } } } else { System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2."); synchronized (resource2) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2."); System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1."); synchronized (resource1) { System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1."); } } } } } } Wie Sie dem obigen Code entnehmen können, starten zwei Threads und versuchen, zwei statische Ressourcen zu sperren. Für Deadlocks benötigen wir jedoch eine unterschiedliche Reihenfolge für beide Threads. Daher verwenden wir eine Instanz des Random-Objekts, um auszuwählen, welche Ressource der Thread zuerst sperren möchte. Wenn die boolesche Variable b wahr ist, wird zuerst Ressource1 gesperrt, und dann versucht der Thread, die Sperre für Ressource2 zu erlangen. Wenn b falsch ist, sperrt der Thread Ressource2 und versucht dann, Ressource1 abzurufen. Dieses Programm muss nicht lange laufen, um den ersten Deadlock zu erreichen, d. h. Das Programm bleibt für immer hängen, wenn wir es nicht unterbrechen: [thread-1] Trying to lock resource 1. [thread-1] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 1. [thread-2] Locked resource 1. [thread-1] Trying to lock resource 2. [thread-1] Locked resource 2. [thread-2] Trying to lock resource 2. [thread-1] Trying to lock resource 1. In diesem Lauf hat Tread-1 die Sperre für Ressource2 erhalten und wartet auf die Sperre von Ressource1, während Tread-2 über die Sperre für Ressource1 verfügt und auf Ressource2 wartet. Wenn wir den Wert der booleschen Variablen b im obigen Code auf true setzen würden, könnten wir keinen Deadlock beobachten, da die Reihenfolge, in der Thread-1 und Thread-2 Sperren anfordern, immer gleich wäre. In dieser Situation würde einer der beiden Threads zuerst die Sperre erhalten und dann den zweiten anfordern, der noch verfügbar ist, da der andere Thread auf die erste Sperre wartet. Im Allgemeinen können wir die folgenden notwendigen Bedingungen für das Auftreten eines Deadlocks unterscheiden: - Gemeinsame Ausführung: Es gibt eine Ressource, auf die jeweils nur ein Thread zugreifen kann. - Ressourcensperre: Beim Erwerb einer Ressource versucht ein Thread, eine weitere Sperre für eine bestimmte Ressource zu erlangen. - Keine Präemption: Es gibt keinen Mechanismus zum Freigeben einer Ressource, wenn ein Thread die Sperre für einen bestimmten Zeitraum hält. - Zirkuläres Warten: Während der Ausführung kommt es zu einer Ansammlung von Threads, in denen zwei (oder mehr) Threads darauf warten, dass der andere eine gesperrte Ressource freigibt. Obwohl die Liste der Bedingungen lang erscheint, kommt es bei gut ausgeführten Multithread-Anwendungen nicht selten zu Deadlock-Problemen. Sie können sie jedoch verhindern, indem Sie eine der oben genannten Bedingungen entfernen: - Gemeinsame Ausführung: Diese Bedingung kann häufig nicht entfernt werden, wenn die Ressource nur von einer Person genutzt werden muss. Aber das muss nicht der Grund sein. Bei der Verwendung von DBMS-Systemen besteht eine mögliche Lösung darin, anstelle einer pessimistischen Sperre für eine Tabellenzeile, die aktualisiert werden muss, eine Technik namens Optimistic Locking zu verwenden . – Eine Möglichkeit, das Halten einer Ressource zu vermeiden, während auf eine andere exklusive Ressource gewartet wird, besteht darin, alle erforderlichen Ressourcen zu Beginn des Algorithmus zu sperren und sie alle freizugeben, wenn es nicht möglich ist, sie alle auf einmal zu sperren. Dies ist natürlich nicht immer möglich; möglicherweise sind die Ressourcen, die gesperrt werden müssen, im Voraus unbekannt, oder dieser Ansatz führt einfach zu einer Ressourcenverschwendung. - Wenn die Sperre nicht sofort erlangt werden kann, besteht eine Möglichkeit, einen möglichen Deadlock zu umgehen, darin, eine Zeitüberschreitung einzuführen. Zum Beispiel die ReentrantLock- Klasseaus dem SDK bietet die Möglichkeit, ein Ablaufdatum für die Sperre festzulegen. - Wie wir im obigen Beispiel gesehen haben, tritt kein Deadlock auf, wenn sich die Reihenfolge der Anforderungen zwischen verschiedenen Threads nicht unterscheidet. Dies lässt sich leicht steuern, wenn Sie den gesamten Blockierungscode in einer Methode unterbringen können, die alle Threads durchlaufen müssen. In komplexeren Anwendungen könnten Sie sogar die Implementierung eines Deadlock-Erkennungssystems in Betracht ziehen. Hier müssen Sie eine Art Thread-Überwachung implementieren, bei der jeder Thread meldet, dass er die Sperre erfolgreich erworben hat und versucht, die Sperre zu erlangen. Wenn Threads und Sperren als gerichteter Graph modelliert werden, können Sie erkennen, wenn zwei verschiedene Threads Ressourcen halten, während sie gleichzeitig versuchen, auf andere gesperrte Ressourcen zuzugreifen. Wenn Sie dann die blockierenden Threads dazu zwingen können, die erforderlichen Ressourcen freizugeben, können Sie die Deadlock-Situation automatisch beheben.
1.2 Fasten
Der Scheduler entscheidet, welchen Thread im RUNNABLE-Zustand er als nächstes ausführen soll. Die Entscheidung basiert auf der Thread-Priorität; Daher erhalten Threads mit niedrigerer Priorität weniger CPU-Zeit als Threads mit höherer Priorität. Was wie eine vernünftige Lösung aussieht, kann bei Missbrauch auch Probleme verursachen. Wenn Threads mit hoher Priorität die meiste Zeit ausgeführt werden, scheinen Threads mit niedriger Priorität zu verhungern, weil sie nicht genug Zeit haben, ihre Arbeit ordnungsgemäß zu erledigen. Daher wird empfohlen, die Thread-Priorität nur dann festzulegen, wenn ein zwingender Grund dafür besteht. Ein nicht offensichtliches Beispiel für Thread-Aushungern ist beispielsweise die Methode finalize(). Es bietet der Java-Sprache die Möglichkeit, Code auszuführen, bevor ein Objekt durch Garbage Collection erfasst wird. Wenn Sie sich jedoch die Priorität des finalisierenden Threads ansehen, werden Sie feststellen, dass dieser nicht mit der höchsten Priorität ausgeführt wird. Folglich kommt es zu einem Thread-Ausfall, wenn die finalize()-Methoden Ihres Objekts im Vergleich zum Rest des Codes zu viel Zeit in Anspruch nehmen. Ein weiteres Problem mit der Ausführungszeit ergibt sich aus der Tatsache, dass nicht definiert ist, in welcher Reihenfolge die Threads den synchronisierten Block durchlaufen. Wenn viele parallele Threads Code durchlaufen, der in einem synchronisierten Block eingerahmt ist, kann es vorkommen, dass einige Threads länger als andere warten müssen, bevor sie in den Block gelangen. Theoretisch werden sie vielleicht nie dort ankommen. Die Lösung dieses Problems ist die sogenannte „faire“ Sperrung. Faire Sperren berücksichtigen Thread-Wartezeiten, wenn sie bestimmen, wer als nächstes übergeben wird. Eine Beispielimplementierung für faires Sperren ist im Java SDK verfügbar: java.util.concurrent.locks.ReentrantLock. Wenn ein Konstruktor mit einem auf „true“ gesetzten booleschen Flag verwendet wird, gewährt ReentrantLock Zugriff auf den Thread, der am längsten gewartet hat. Dies garantiert die Abwesenheit von Hunger, führt aber gleichzeitig zu dem Problem, Prioritäten zu ignorieren. Aus diesem Grund werden Prozesse mit niedrigerer Priorität, die häufig an dieser Barriere warten, möglicherweise häufiger ausgeführt. Zu guter Letzt kann die Klasse ReentrantLock nur Threads berücksichtigen, die auf eine Sperre warten, also Threads, die oft genug gestartet wurden und die Barriere erreichten. Wenn die Priorität eines Threads zu niedrig ist, passiert dies nicht oft und daher passieren Threads mit hoher Priorität die Sperre immer noch häufiger.
2. Objektmonitore zusammen mit wait() und notify()
Beim Multithread-Computing kommt es häufig vor, dass einige Arbeitsthreads darauf warten, dass ihr Produzent Arbeit für sie erstellt. Aber wie wir gelernt haben, ist das aktive Warten in einer Schleife beim Überprüfen eines bestimmten Werts im Hinblick auf die CPU-Zeit keine gute Option. Auch die Verwendung der Methode Thread.sleep() ist in dieser Situation nicht besonders geeignet, wenn wir direkt nach der Ankunft mit der Arbeit beginnen möchten. Zu diesem Zweck verfügt die Programmiersprache Java über eine weitere Struktur, die in diesem Schema verwendet werden kann: wait() und notify(). Die Methode wait(), die von allen Objekten der Klasse java.lang.Object geerbt wird, kann verwendet werden, um den aktuellen Thread anzuhalten und zu warten, bis uns ein anderer Thread mit der Methode notify() weckt. Um ordnungsgemäß zu funktionieren, muss der Thread, der die Methode „wait()“ aufruft, eine Sperre halten, die er zuvor mit dem Schlüsselwort „synchonized“ erworben hat. Wenn wait() aufgerufen wird, wird die Sperre aufgehoben und der Thread wartet, bis ein anderer Thread, der jetzt die Sperre hält, notify() für dieselbe Objektinstanz aufruft. In einer Multithread-Anwendung kann es natürlich sein, dass mehr als ein Thread auf eine Benachrichtigung für ein bestimmtes Objekt wartet. Daher gibt es zwei verschiedene Methoden zum Aufwecken von Threads: notify() und notifyAll(). Während die erste Methode einen der wartenden Threads aufweckt, weckt die notifyAll()-Methode alle auf. Beachten Sie jedoch, dass es wie beim synchronisierten Schlüsselwort keine Regel gibt, die bestimmt, welcher Thread als nächstes aktiviert wird, wenn notify() aufgerufen wird. In einem einfachen Beispiel mit einem Produzenten und einem Verbraucher spielt dies keine Rolle, da es uns egal ist, welcher Thread aktiviert wird. Der folgende Code zeigt, wie wait() und notify() dazu verwendet werden können, Consumer-Threads darauf zu warten, dass neue Arbeiten von einem Producer-Thread in die Warteschlange gestellt werden: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } Die Methode main() startet fünf Consumer-Threads und einen Producer-Thread und wartet dann auf deren Abschluss. Der Producer-Thread fügt dann den neuen Wert zur Warteschlange hinzu und benachrichtigt alle wartenden Threads, dass etwas passiert ist. Verbraucher erhalten eine Warteschlangensperre (dh ein zufälliger Verbraucher) und gehen dann in den Ruhezustand, um später wieder aktiviert zu werden, wenn die Warteschlange wieder voll ist. Wenn der Produzent seine Arbeit beendet hat, benachrichtigt er alle Verbraucher, um sie aufzuwecken. Wenn wir den letzten Schritt nicht ausgeführt hätten, würden die Verbraucherthreads ewig auf die nächste Benachrichtigung warten, da wir kein Timeout für das Warten festgelegt haben. Stattdessen können wir die Methode „wait(long timeout)“ verwenden, um zumindest nach einiger Zeit aufgeweckt zu werden.
2.1 Verschachtelte synchronisierte Blöcke mit wait() und notify()
Wie im vorherigen Abschnitt erwähnt, wird durch den Aufruf von wait() auf dem Monitor eines Objekts nur die Sperre auf diesem Monitor aufgehoben. Andere Sperren, die vom selben Thread gehalten werden, werden nicht freigegeben. Wie leicht verständlich ist, kann es im Arbeitsalltag vorkommen, dass der Thread, der wait() aufruft, die Sperre weiter hält. Wenn auch andere Threads auf diese Sperren warten, kann es zu einer Deadlock-Situation kommen. Schauen wir uns das Sperren im folgenden Beispiel an: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } Wie wir bereits erfahren haben , ist das Hinzufügen von „synchonized“ zu einer Methodensignatur gleichbedeutend mit dem Erstellen eines „synchonized(this){}“-Blocks. Im obigen Beispiel haben wir versehentlich das synchronisierte Schlüsselwort zur Methode hinzugefügt und dann die Warteschlange mit dem Monitor des Warteschlangenobjekts synchronisiert, um diesen Thread in den Ruhezustand zu versetzen, während er auf den nächsten Wert aus der Warteschlange wartet. Dann gibt der aktuelle Thread die Sperre für die Warteschlange frei, nicht jedoch die Sperre für diese. Die Methode putInt() benachrichtigt den schlafenden Thread darüber, dass ein neuer Wert hinzugefügt wurde. Aber zufällig haben wir dieser Methode auch das Schlüsselwort synchronisiert hinzugefügt. Nachdem der zweite Thread nun eingeschlafen ist, hält er immer noch die Sperre. Daher kann der erste Thread nicht auf die putInt()-Methode zugreifen, während die Sperre vom zweiten Thread gehalten wird. Infolgedessen haben wir eine Deadlock-Situation und ein eingefrorenes Programm. Wenn Sie den obigen Code ausführen, geschieht dies unmittelbar nach dem Start des Programms. Im Alltag ist diese Situation möglicherweise nicht so offensichtlich. Die von einem Thread gehaltenen Sperren hängen möglicherweise von Parametern und Bedingungen ab, die zur Laufzeit auftreten, und der synchronisierte Block, der das Problem verursacht, befindet sich im Code möglicherweise nicht so nah an der Stelle, an der wir den Aufruf von wait() platziert haben. Dies macht es schwierig, solche Probleme zu finden, insbesondere da sie im Laufe der Zeit oder unter hoher Last auftreten können.
2.2 Bedingungen in synchronisierten Blöcken
Oft müssen Sie überprüfen, ob eine Bedingung erfüllt ist, bevor Sie eine Aktion an einem synchronisierten Objekt ausführen. Wenn Sie beispielsweise eine Warteschlange haben, möchten Sie warten, bis diese voll ist. Daher können Sie eine Methode schreiben, die prüft, ob die Warteschlange voll ist. Wenn es noch leer ist, schicken Sie den aktuellen Thread in den Ruhezustand, bis er aufgeweckt wird: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } Der obige Code synchronisiert sich mit der Warteschlange, bevor er wait() aufruft, und wartet dann in einer While-Schleife, bis mindestens ein Element in der Warteschlange erscheint. Der zweite synchronisierte Block verwendet erneut die Warteschlange als Objektmonitor. Es ruft die poll()-Methode der Warteschlange auf, um den Wert abzurufen. Zu Demonstrationszwecken wird eine IllegalStateException ausgelöst, wenn die Umfrage null zurückgibt. Dies geschieht, wenn die Warteschlange keine Elemente zum Abrufen enthält. Wenn Sie dieses Beispiel ausführen, werden Sie feststellen, dass IllegalStateException sehr oft ausgelöst wird. Obwohl wir mithilfe des Warteschlangenmonitors korrekt synchronisiert haben, wurde eine Ausnahme ausgelöst. Der Grund ist, dass wir zwei verschiedene synchronisierte Blöcke haben. Stellen Sie sich vor, wir haben zwei Threads, die am ersten synchronisierten Block angekommen sind. Der erste Thread betrat den Block und ging in den Ruhezustand, da die Warteschlange leer war. Dasselbe gilt auch für den zweiten Thread. Da nun beide Threads aktiv sind (dank des notifyAll()-Aufrufs, der vom anderen Thread für den Monitor aufgerufen wurde), sehen beide den vom Produzenten hinzugefügten Wert (Element) in der Warteschlange. Dann erreichten beide die zweite Schranke. Hier hat der erste Thread den Wert eingegeben und aus der Warteschlange abgerufen. Wenn der zweite Thread eintritt, ist die Warteschlange bereits leer. Daher erhält es null als von der Warteschlange zurückgegebenen Wert und löst eine Ausnahme aus. Um solche Situationen zu verhindern, müssen Sie alle Vorgänge, die vom Status des Monitors abhängen, im selben synchronisierten Block ausführen: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } Hier führen wir die poll()-Methode im selben synchronisierten Block wie die isEmpty()-Methode aus. Dank des synchronisierten Blocks können wir sicher sein, dass jeweils nur ein Thread eine Methode für diesen Monitor ausführt. Daher kann kein anderer Thread zwischen den Aufrufen von isEmpty() und poll() Elemente aus der Warteschlange entfernen. Fortsetzung der Übersetzung hier .
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION