Vermeiden Sie Synchronisations-Deadlocks

In meinem früheren Artikel "Double-Checked Locking: Clever, aber gebrochen" ( JavaWorld,Februar 2001) habe ich beschrieben, wie einige gängige Techniken zur Vermeidung von Synchronisation tatsächlich unsicher sind, und eine Strategie empfohlen: "Im Zweifelsfall synchronisieren". Im Allgemeinen sollten Sie synchronisieren, wann immer Sie eine Variable lesen, die möglicherweise zuvor von einem anderen Thread geschrieben wurde, oder wann immer Sie eine Variable schreiben, die möglicherweise später von einem anderen Thread gelesen wird. Während die Synchronisierung eine Leistungseinbuße mit sich bringt, ist die mit der unkontrollierten Synchronisation verbundene Beeinträchtigung nicht so groß, wie einige Quellen vorgeschlagen haben, und hat sich mit jeder aufeinanderfolgenden JVM-Implementierung stetig verringert. Es scheint also weniger Grund als je zuvor zu geben, die Synchronisierung zu vermeiden. Ein anderes Risiko ist jedoch mit einer übermäßigen Synchronisation verbunden: Deadlock.

Was ist ein Deadlock?

Wir sagen, dass eine Reihe von Prozessen oder Threads blockiert ist, wenn jeder Thread auf ein Ereignis wartet, das nur ein anderer Prozess in der Menge verursachen kann. Eine andere Möglichkeit, einen Deadlock zu veranschaulichen, besteht darin, einen gerichteten Graphen zu erstellen, dessen Eckpunkte Threads oder Prozesse sind und dessen Kanten die Beziehung "Warten auf" darstellen. Wenn dieses Diagramm einen Zyklus enthält, ist das System blockiert. Sofern das System nicht für die Wiederherstellung nach Deadlocks ausgelegt ist, führt ein Deadlock dazu, dass das Programm oder System hängen bleibt.

Synchronisations-Deadlocks in Java-Programmen

In Java können Deadlocks auftreten, da das synchronizedSchlüsselwort bewirkt, dass der ausführende Thread blockiert, während auf die Sperre oder den Monitor gewartet wird, die dem angegebenen Objekt zugeordnet sind. Da der Thread möglicherweise bereits Sperren enthält, die anderen Objekten zugeordnet sind, können zwei Threads darauf warten, dass der andere eine Sperre aufhebt. In einem solchen Fall werden sie für immer warten. Das folgende Beispiel zeigt eine Reihe von Methoden, die möglicherweise einen Deadlock verursachen. Beide Methoden erfassen Sperren für zwei Sperrobjekte cacheLockund tableLockbevor sie fortfahren. In diesem Beispiel handelt es sich bei den als Sperren fungierenden Objekten um globale (statische) Variablen. Dies ist eine gängige Technik zur Vereinfachung des Sperrverhaltens von Anwendungen, indem das Sperren auf einer gröberen Granularitätsebene durchgeführt wird:

Listing 1. Ein möglicher Synchronisations-Deadlock

öffentliches statisches Objekt cacheLock = new Object (); öffentliches statisches Objekt tableLock = new Object (); ... public void oneMethod () {synchronisiert (cacheLock) {synchronisiert (tableLock) {doSomething (); }}} public void anotherMethod () {synchronisiert (tableLock) {synchronisiert (cacheLock) {doSomethingElse (); }}}

Stellen Sie sich nun vor, dass Thread A aufruft, oneMethod()während Thread B gleichzeitig aufruft anotherMethod(). Stellen Sie sich weiter vor, dass Thread A die Sperre erhält cacheLockund gleichzeitig Thread B die Sperre erhält tableLock. Jetzt sind die Threads festgefahren: Keiner der Threads gibt seine Sperre auf, bis er die andere Sperre erlangt, aber keiner kann die andere Sperre erlangen, bis der andere Thread sie aufgibt. Wenn ein Java-Programm blockiert, warten die blockierenden Threads einfach ewig. Während andere Threads möglicherweise weiterhin ausgeführt werden, müssen Sie das Programm möglicherweise beenden, neu starten und hoffen, dass es nicht erneut blockiert.

Das Testen auf Deadlocks ist schwierig, da Deadlocks von Timing, Last und Umgebung abhängen und daher selten oder nur unter bestimmten Umständen auftreten können. Code kann wie in Listing 1 zu einem Deadlock führen, jedoch erst dann einen Deadlock aufweisen, wenn eine Kombination aus zufälligen und nicht zufälligen Ereignissen auftritt, z. B. wenn das Programm einer bestimmten Laststufe ausgesetzt ist, auf einer bestimmten Hardwarekonfiguration ausgeführt wird oder einer bestimmten ausgesetzt ist Mischung aus Benutzeraktionen und Umgebungsbedingungen. Deadlocks ähneln Zeitbomben, die darauf warten, in unserem Code zu explodieren. Wenn sie dies tun, hängen unsere Programme einfach.

Eine inkonsistente Reihenfolge der Sperren führt zu Deadlocks

Glücklicherweise können wir eine relativ einfache Anforderung an die Sperrenerfassung stellen, die Synchronisations-Deadlocks verhindern kann. Die Methoden von Listing 1 können Deadlocks verursachen, da jede Methode die beiden Sperren in einer anderen Reihenfolge abruft. Wenn Listing 1 so geschrieben worden wäre, dass jede Methode die beiden Sperren in derselben Reihenfolge erfasst hätte, könnten zwei oder mehr Threads, die diese Methoden ausführen, unabhängig vom Timing oder anderen externen Faktoren keinen Deadlock ausführen, da kein Thread die zweite Sperre erhalten könnte, ohne die bereits zu halten zuerst. Wenn Sie garantieren können, dass Sperren immer in einer konsistenten Reihenfolge erworben werden, blockiert Ihr Programm nicht.

Deadlocks sind nicht immer so offensichtlich

Sobald Sie sich auf die Wichtigkeit der Schlossreihenfolge eingestellt haben, können Sie das Problem von Listing 1 leicht erkennen. Analoge Probleme könnten sich jedoch als weniger offensichtlich erweisen: Möglicherweise befinden sich die beiden Methoden in getrennten Klassen, oder die beteiligten Sperren werden implizit durch Aufrufen synchronisierter Methoden anstatt explizit über einen synchronisierten Block erworben. Betrachten Sie diese beiden kooperierenden Klassen Modelund Viewin einem vereinfachten MVC-Framework (Model-View-Controller):

Listing 2. Ein subtilerer möglicher Synchronisations-Deadlock

öffentliche Klasse Model {private View myView; public synchronized void updateModel (Objekt someArg) {doSomething (someArg); myView.somethingChanged (); } öffentliches synchronisiertes Objekt getSomething () {return someMethod (); }} öffentliche Klasse View {privates Modell zugrunde liegendes Modell; public synchronized void SomethingChanged () {doSomething (); } public synchronized void updateView () {Object o = myModel.getSomething (); }}

Listing 2 enthält zwei kooperierende Objekte mit synchronisierten Methoden. Jedes Objekt ruft die synchronisierten Methoden des anderen auf. Diese Situation ähnelt Listing 1: Zwei Methoden erwerben Sperren für dieselben zwei Objekte, jedoch in unterschiedlicher Reihenfolge. Die inkonsistente Sperrenreihenfolge in diesem Beispiel ist jedoch viel weniger offensichtlich als in Listing 1, da die Sperrenerfassung ein impliziter Teil des Methodenaufrufs ist. Wenn ein Thread aufruft, Model.updateModel()während ein anderer Thread gleichzeitig aufruft View.updateView(), kann der erste Thread die ModelSperre erhalten und auf die Sperre warten View, während der andere die ViewSperre erhält und für immer auf die Sperre wartet Model.

Sie können das Potenzial für einen Synchronisations-Deadlock noch tiefer begraben. Betrachten Sie dieses Beispiel: Sie haben eine Methode zum Überweisen von Geldern von einem Konto auf ein anderes. Sie möchten Sperren für beide Konten erwerben, bevor Sie die Übertragung durchführen, um sicherzustellen, dass die Übertragung atomar ist. Betrachten Sie diese harmlos aussehende Implementierung:

Listing 3. Ein noch subtilerer möglicher Synchronisations-Deadlock

 public void transferMoney (Konto von Konto, Konto zu Konto, DollarAmount BetragToTransfer) {synchronisiert (vonAccount) {synchronisiert (zuAccount) {if (fromAccount.hasSufficientBalance (BetragToTransfer) {fromAccount.debit (BetragToTransfer); }} 

Selbst wenn alle Methoden, die mit zwei oder mehr Konten ausgeführt werden, dieselbe Reihenfolge verwenden, enthält Listing 3 die Keime des gleichen Deadlock-Problems wie Listing 1 und 2, jedoch auf noch subtilere Weise. Überlegen Sie, was passiert, wenn Thread A ausgeführt wird:

 transferMoney (accountOne, accountTwo, Betrag); 

Gleichzeitig führt Thread B Folgendes aus:

 transferMoney (accountTwo, accountOne, anotherAmount); 

Wieder versuchen die beiden Threads, die gleichen zwei Sperren zu erhalten, jedoch in unterschiedlicher Reihenfolge. Das Deadlock-Risiko besteht immer noch, jedoch in einer viel weniger offensichtlichen Form.

So vermeiden Sie Deadlocks

Eine der besten Möglichkeiten, um das Potenzial eines Deadlocks zu verhindern, besteht darin, zu vermeiden, dass mehr als eine Sperre gleichzeitig erworben wird, was häufig praktisch ist. Wenn dies jedoch nicht möglich ist, benötigen Sie eine Strategie, die sicherstellt, dass Sie mehrere Sperren in einer konsistenten, definierten Reihenfolge erwerben.

Abhängig davon, wie Ihr Programm Sperren verwendet, ist es möglicherweise nicht kompliziert sicherzustellen, dass Sie eine konsistente Sperrreihenfolge verwenden. In einigen Programmen, z. B. in Listing 1, werden alle kritischen Sperren, die möglicherweise an mehreren Sperren beteiligt sind, aus einem kleinen Satz von Singleton-Sperrobjekten gezogen. In diesem Fall können Sie eine Sperrenerfassungsreihenfolge für den Satz von Sperren definieren und sicherstellen, dass Sie Sperren immer in dieser Reihenfolge erfassen. Sobald die Sperrreihenfolge definiert ist, muss sie lediglich gut dokumentiert werden, um eine konsistente Verwendung im gesamten Programm zu fördern.

Verkleinern Sie synchronisierte Blöcke, um Mehrfachverriegelungen zu vermeiden

In Listing 2 wird das Problem komplizierter, da durch das Aufrufen einer synchronisierten Methode die Sperren implizit erfasst werden. Normalerweise können Sie mögliche Deadlocks vermeiden, die sich aus Fällen wie Listing 2 ergeben, indem Sie den Umfang der Synchronisation auf einen möglichst kleinen Block beschränken. Muss Model.updateModel()das ModelSchloss wirklich halten , während es anruftView.somethingChanged()? Oft nicht; Die gesamte Methode wurde wahrscheinlich als Verknüpfung synchronisiert und nicht, weil die gesamte Methode synchronisiert werden musste. Wenn Sie jedoch synchronisierte Methoden durch kleinere synchronisierte Blöcke innerhalb der Methode ersetzen, müssen Sie dieses Sperrverhalten als Teil des Javadoc der Methode dokumentieren. Anrufer müssen wissen, dass sie die Methode ohne externe Synchronisation sicher aufrufen können. Anrufer sollten auch das Sperrverhalten der Methode kennen, damit sie sicherstellen können, dass Sperren in einer konsistenten Reihenfolge erworben werden.

Eine ausgefeiltere Technik zum Ordnen von Schlössern

In anderen Situationen, wie beispielsweise im Beispiel eines Bankkontos in Listing 3, wird die Anwendung der Regel mit fester Reihenfolge noch komplizierter. Sie müssen eine Gesamtreihenfolge für die Gruppe von Objekten definieren, die zum Sperren berechtigt sind, und diese Reihenfolge verwenden, um die Reihenfolge der Sperrenerfassung auszuwählen. Das klingt chaotisch, ist aber in der Tat unkompliziert. Listing 4 veranschaulicht diese Technik; Es verwendet eine numerische Kontonummer, um eine Bestellung für AccountObjekte auszulösen . (Wenn dem zu sperrenden Objekt eine natürliche Identitätseigenschaft wie eine Kontonummer fehlt, können Sie die Object.identityHashCode()Methode verwenden, um stattdessen eine zu generieren.)

Listing 4. Verwenden Sie eine Bestellung, um Sperren in einer festen Reihenfolge zu erhalten

public void transferMoney (Konto von Konto, Konto zu Konto, Dollarbetrag Betrag zu Übertragung) {Konto firstLock, secondLock; if (fromAccount.accountNumber () == toAccount.accountNumber ()) löst eine neue Ausnahme aus ("Kann nicht vom Konto auf sich selbst übertragen werden"); sonst wenn (fromAccount.accountNumber () <toAccount.accountNumber ()) {firstLock = fromAccount; secondLock = toAccount; } else {firstLock = toAccount; secondLock = fromAccount; } synchronized (firstLock) {synchronized (secondLock) {if (fromAccount.hasSufficientBalance (BetragToTransfer) {fromAccount.debit (BetragToTransfer); toAccount.credit (BetragToTransfer);}}}}

Nun spielt die Reihenfolge, in der die Konten im Aufruf an angegeben werden, transferMoney()keine Rolle mehr. Die Schlösser werden immer in der gleichen Reihenfolge erworben.

Der wichtigste Teil: Dokumentation

Ein kritisches - aber oft übersehenes - Element jeder Sperrstrategie ist die Dokumentation. Leider wird selbst in Fällen, in denen bei der Entwicklung einer Sperrstrategie große Sorgfalt angewendet wird, häufig viel weniger Aufwand für deren Dokumentation aufgewendet. Wenn Ihr Programm einen kleinen Satz von Singleton-Sperren verwendet, sollten Sie Ihre Annahmen zur Reihenfolge der Sperren so klar wie möglich dokumentieren, damit zukünftige Betreuer die Anforderungen für die Reihenfolge der Sperren erfüllen können. Wenn eine Methode eine Sperre erwerben muss, um ihre Funktion auszuführen, oder mit einer bestimmten Sperre aufgerufen werden muss, sollte das Javadoc der Methode diese Tatsache beachten. Auf diese Weise wissen zukünftige Entwickler, dass beim Aufrufen einer bestimmten Methode möglicherweise eine Sperre erworben werden muss.

Nur wenige Programme oder Klassenbibliotheken dokumentieren ihre Sperrverwendung angemessen. Jede Methode sollte mindestens die Sperren dokumentieren, die sie erwirbt, und ob Aufrufer eine Sperre halten müssen, um die Methode sicher aufzurufen. Darüber hinaus sollten Klassen dokumentieren, ob oder unter welchen Bedingungen sie threadsicher sind.

Konzentrieren Sie sich auf das Sperrverhalten zur Entwurfszeit

Da Deadlocks oft nicht offensichtlich sind und selten und unvorhersehbar auftreten, können sie in Java-Programmen schwerwiegende Probleme verursachen. Indem Sie das Sperrverhalten Ihres Programms zur Entwurfszeit berücksichtigen und Regeln definieren, wann und wie mehrere Sperren erworben werden sollen, können Sie die Wahrscheinlichkeit von Deadlocks erheblich verringern. Denken Sie daran, die Sperrenerfassungsregeln Ihres Programms und die Verwendung der Synchronisierung sorgfältig zu dokumentieren. Die Zeit, die für die Dokumentation einfacher Sperrannahmen aufgewendet wird, zahlt sich aus, indem die Wahrscheinlichkeit von Deadlocks und anderen Parallelitätsproblemen später erheblich verringert wird.

Brian Goetz ist ein professioneller Softwareentwickler mit mehr als 15 Jahren Erfahrung. Er ist Hauptberater bei Quiotix, einem Softwareentwicklungs- und Beratungsunternehmen mit Sitz in Los Altos, Kalifornien.