Upgraden von ReadWriteLock

Extreme Java: Lock & Crete
Keine Kommentare

Die Java-Klasse ReentrantReadWriteLock kann einen Read Lock nicht auf einen Write Lock upgraden. Kotlins Erweiterungsfunktion ReentrantReadWriteLock.write() schummelt ein wenig, indem sie den Read Lock vor dem Upgrade loslässt und so die Tür für Race Conditions öffnet. Eine bessere Lösung ist StampedLock, das über eine Methode verfügt, mit der versucht wird, den Lock in einen Write Lock umzuwandeln.

In Java 5 kam das ReadWriteLock-Interface mit einer Implementierung von ReentrantReadWriteLock. Sie hatte die sinnvolle Einschränkung, dass wir einen Write Lock zu einem Read Lock herabstufen, jedoch keinen Read Lock auf einen Write Lock upgraden konnten. Wenn wir es versuchten, bekamen wir sofort einen Deadlock. Der Grund für diese Einschränkung: Wenn zwei Threads einen Read Lock hätten, was würde geschehen, wenn beide gleichzeitig versuchten, ein Upgrade durchzuführen? Nur einer könnte erfolgreich sein – aber was ist mit dem anderen Thread? Um sicherzugehen, wird jeder Thread, der ein Upgrade versucht, konsequent blockiert.

Das Herabstufen der ReentrantReadWriteLock funktioniert gut, und wir können in diesem Fall gleichzeitig einen Read Lock und einen Write Lock halten. Eine Herabstufung bedeutet, dass wir, während wir einen Write Lock halten, auch den Read Lock sperren und dann den Write Lock freigeben. Das bedeutet, dass wir anderen Threads das Lesen, aber nicht das Schreiben gestatten (Listing 1).

import java.util.concurrent.locks.*;
// This runs through fine
public class DowngradeDemo {
  public static void main(String... args) {
    var rwlock = new ReentrantReadWriteLock();
    System.out.println(rwlock); // w=0, r=0
    rwlock.writeLock().lock();
    System.out.println(rwlock); // w=1, r=0
    rwlock.readLock().lock();
    System.out.println(rwlock); // w=1, r=1
    rwlock.writeLock().unlock();
    // at this point other threads can also acquire read locks
    System.out.println(rwlock); // w=0, r=1
    rwlock.readLock().unlock();
    System.out.println(rwlock); // w=0, r=0
  }
}

Der Versuch, einen ReentrantReadWriteLock von Lesen auf Schreiben zu aktualisieren, führt zu einem Deadlock (Listing 2).

// This deadlocks
public class UpgradeDemo {
  public static void main(String... args) {
    var rwlock = new ReentrantReadWriteLock();
    System.out.println(rwlock); // w=0, r=0
    rwlock.readLock().lock();
    System.out.println(rwlock); // w=0, r=1
    rwlock.writeLock().lock(); // deadlock
    System.out.println(rwlock); 
    rwlock.readLock().unlock();
    System.out.println(rwlock);
    rwlock.writeLock().unlock();
    System.out.println(rwlock);
  }
}

ReadWriteLock in Kotlin

Eines der Dinge, die ich im Corona-Lockdown geschafft habe, ist Kotlin zu lernen. Von allen Quellen, die ich mir angeschaut habe, hat mir das Buch „Kotlin in Action“ [1] am besten gefallen. Sehr empfehlenswert, besonders für Java-Programmierer.

Nachdem ich Kotlin nun ein paar Monate lang studiert habe, bin ich der Meinung, dass es sich positiv auf die Entwicklungsdauer einer App auswirkt, da es jede Menge Zeitersparnis mit sich bringt. Ich kann verstehen, warum die Sprache so viele Fans hat. In den letzten zwei Jahrzehnten habe ich mir neben Java viele andere Sprachen angesehen, von Ruby über Clojure und Scala bis hin zu Swift. Kotlin war jedoch bisher der Gewinner meiner „Zweitsprachen“. Ich kann mir sogar vorstellen, einen Teil meiner JavaSpecialists-Website [2] mit Kotlin neu zu schreiben, nur so zum Spaß.

Eine scheinbar themenfremde Bemerkung: Meiner Meinung nach lesen wir ein Buch nur dann wirklich, wenn wir Fehler entdecken. Daher ist das höchste Kompliment, das ich einem Autor machen kann, ihm eine lange Liste von Fehlern zu schicken. Gut, einige Verlage schludern bei der Schlussredaktion ziemlich heftig, und jede Seite ist mit Fehlern übersät. Aber von diesen spreche ich hier nicht, sondern vielmehr von ausgezeichneten Büchern wie „Effective Java“ [3], „Mastering Lambdas“ [4], „Head First Design Patterns“ [5].

Analog dazu weiß ich, dass ich eine neue Computersprache richtig lerne, wenn ich anfange, Fehler in der eigentlichen Sprache oder im API zu entdecken. Genau darum geht es in meinem „The Java Specialists’ Newsletter“: Seit zwanzig Jahren mache ich mich über all die seltsamen Anomalien in der Programmiersprache Java lustig. Mit Kotlin ist es jedoch schwieriger. Wenn ich auf einen Fehler in einem Buch hinweise, bedanken sich die Autoren normalerweise bei mir, geben zu, dass sie einen Fehler gemacht haben und versuchen, ihn in einer zukünftigen Version zu beheben. Bei Kotlin finde ich es viel schwieriger, meine Argumente zu vermitteln. Vielleicht liegt es daran, dass ich ein bekannter „Java Guy“ bin, dass die Kotlin-Gemeinschaft meint, ihre Sprache verteidigen zu müssen. Es könnte auch daran liegen, dass ich in Kotlin nicht erfahren genug bin, um der Diskussion etwas Nützliches hinzuzufügen. Ich war sogar beim Schreiben dieses Textes etwas besorgt, aus Angst, dass er missverstanden werden könnte. Nach dieser langen Verteidigung wollen wir uns einmal ansehen, wie Kotlin mit ReadWriteLock umgeht.

Doch bevor wir das tun, ein letzter Blick auf Java. Viele Programmierer haben mich gefragt, warum ReentrantReadWriteLock nicht try-with-resource zum automatischen Entsperren unterstützt. Ich habe darüber in den Newslettern 190 [6] und 190b [7] geschrieben. Dann kamen Lambdas auf, und es wäre sinnvoll gewesen, eine idiomatische Implementierung von locking/unlocking mit dem in einem Lambda enthaltenen Körper zu erstellen. Allerdings unterstützen Java-8-Lambdas das Werfen von Checked Exceptions nicht so gut. Daher müssen wir in Java den gesamten Code zum locking/unlocking von Hand schreiben. Das ist mühsam und fehleranfällig. Ein Hinweis auf ein Problem ist, dass IntelliJ IDEA über vordefinierte Livetemplates verfügt, um diesen Code zu generieren. Wenn unsere IDE Code für uns generiert (getter/setter, toString, equals/hashCode, constructors, locking/unlocking), ist das ein Zeichen für einen Language Smell.

In Kotlin werden Lambdas auf eine etwas andere Weise kompiliert als in Java. Es gibt Vor- und Nachteile, aber darauf werde ich hier nicht eingehen. Kotlin verfügt auch über Erweiterungsfunktionen, die es uns angeblich erlauben, Funktionalität zu bestehenden Klassen hinzuzufügen. Es handelt sich um einen Taschenspielertrick, aber um einem guten.

Der Kotlin-Code in Listing 3 ähnelt unserer DowngradeDemo. Der einzige Unterschied besteht in der vierten println(), die in unserer Java-Version // w=0, r=1 und in Kotlin // w=1, r=0 anzeigt. In Java haben wir die Read Locks und die Write Locks nicht in der gleichen Reihenfolge entsperrt, in der sie gesperrt waren. Sobald wir das Downgrade von Write auf Lock durchgeführt hatten, hätten andere Threads den Read Lock erhalten können. Das bedeutet, dass die Kotlin-Version nicht zulässt, dass andere Threads den Read Lock erhalten. Es handelt sich nicht um ein echtes Downgrade der Sperre.

// DowngradeDemoKotlin.kt
import java.util.concurrent.locks.*
import kotlin.concurrent.*
 
fun main() {
  val rwlock = ReentrantReadWriteLock()
  println(rwlock) // w=0, r=0
  rwlock.write {
    println(rwlock) // w=1, r=0
    rwlock.read {
      println(rwlock) // w=1, r=1
    }
    println(rwlock) // w=1, r=0
  }
  println(rwlock) // w=0, r=0
}

Stellt euch meine Überraschung vor, als ich versuchte, die Lesesperre zu einer Schreibsperre aufzuwerten (Listing 4). Kein Deadlock, hurra!

// UpgradeDemoKotlin.kt
fun main() {
  val rwlock = ReentrantReadWriteLock()
  println(rwlock) // w=0, r=0
  rwlock.read {
    println(rwlock) // w=0, r=1
    rwlock.write {
      println(rwlock) // w=1, r=0
    }
    println(rwlock) // w=0, r=1
  }
  println(rwlock) // w=0, r=0
}

Seht euch jedoch den Status jedes println() an. In Listing 3 haben wir // w=1, r=1 in der Mitte. Aber diesmal nicht. Wir halten nur den Write Lock, nicht aber den Read Lock. In Listing 5 werfen wir einen Blick auf die Implementierung der Kotlin-Erweiterungsfunktion ReentrantReadWriteLock.write().

/**
 * Executes the given [action] under the write lock of this lock.
 *
 * The function does upgrade from read to write lock if needed,
 * but this upgrade is not atomic as such upgrade is not
 * supported by [ReentrantReadWriteLock].
 * In order to do such upgrade this function first releases all
 * read locks held by this thread, then acquires write lock, and
 * after releasing it acquires read locks back again.
 *
 * Therefore if the [action] inside write lock has been initiated
 * by checking some condition, the condition must be rechecked
 * inside the [action] to avoid possible races.
 *
 * @return the return value of the action.
 */
@kotlin.internal.InlineOnly
public inline
fun <T> ReentrantReadWriteLock.write(action: () -> T): T {
  val rl = readLock()
 
  val readCount = if (writeHoldCount == 0) readHoldCount else 0
  repeat(readCount) { rl.unlock() }
 
  val wl = writeLock()
  wl.lock()
  try {
    return action()
  } finally {
    repeat(readCount) { rl.lock() }
    wl.unlock()
  }
}

Eine äquivalente Version von Listing 4 würde in Java aussehen wie in Listing 6 gezeigt.

public class UpgradeDemoKotlinAsJava {
  public static void main(String... args) {
    var rwlock = new ReentrantReadWriteLock();
    System.out.println(rwlock); // w=0, r=0
    rwlock.readLock().lock();
    try {
      System.out.println(rwlock); // w=0, r=1
      int readCount = rwlock.getWriteHoldCount() == 0
        ? rwlock.getReadHoldCount() : 0;
      for (int i = 0; i < readCount; i++)
        rwlock.readLock().unlock();
      rwlock.writeLock().lock();
      try {
        System.out.println(rwlock); // w=1, r=0
      } finally {
        for (int i = 0; i < readCount; i++)
          rwlock.readLock().lock();
        rwlock.writeLock().unlock();
      }
      System.out.println(rwlock); // w=0, r=1
    } finally {
      rwlock.readLock().unlock();
    }
    System.out.println(rwlock); // w=0, r=0
  }
}

Die Dokumentation in der Kotlin-Funktion besagt ausdrücklich, dass die Read Locks freigegeben werden, bevor der Write Lock erworben wird, und dass jeder Zustand davor innerhalb des Write Locks nochmals überprüft werden muss. Das ist auf eine (sinnvolle) Einschränkung in ReentrantReadWriteLock zurückzuführen, die bereits am Anfang erwähnt wurde.

Ich wäre überrascht, einen solchen Code im JDK zu sehen. In Java sind wir bei der Vermeidung von Race Conditions vorsichtiger. Ein Thread Deadlock ist einer mysteriösen Race Condition vorzuziehen. Eine Warnung in die Dokumentation zu schreiben, ist meines Erachtens nicht genug. Wer liest das überhaupt? Stichwort POLA [8].

Upgraden mit StampedLock

Java 8 StampedLock gibt uns mehr Kontrolle darüber, wie ein fehlgeschlagenes Upgrade gehandhabt werden soll. Ein paar Dinge, bevor wir anfangen: StampedLock ist nicht reentrant, was bedeutet, dass wir nicht gleichzeitig einen Read Lock und einen Write Lock halten können. Ein Stempel ist nicht an einen bestimmten Thread gebunden, d. h. wir können auch nicht zwei Write Locks gleichzeitig von einem Thread halten. Wir können viele Read Locks gleichzeitig halten, jede mit einem anderen Stempel. Aber wir können nur einen einzigen Write Lock erhalten. Listing 7 zeigt eine Demo.

public class StampedLockDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    var stamps = new ArrayList();
    System.out.println(sl); // Unlocked
    for (int i = 0; i < 42; i++) {
      stamps.add(sl.readLock());
    }
    System.out.println(sl); // Read-Locks:42
    stamps.forEach(sl::unlockRead);
    System.out.println(sl); // Unlocked
 
    var stamp1 = sl.writeLock();
    System.out.println(sl); // Write-Locked
    var stamp2 = sl.writeLock(); // deadlocked
    System.out.println(sl); // Not seen...
  }
}

Da StampedLock nicht weiß, welcher Thread die Locks besitzt, würde das DowngradeDemo einen Deadlock verursachen (Listing 8).

public class StampedLockDowngradeFailureDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long wstamp = sl.writeLock();
    System.out.println(sl); // Write-locked
    long rstamp = sl.readLock(); // deadlocked
    System.out.println(sl); // Not seen ...
  }
}

StampedLock erlaubt es uns jedoch zu versuchen, unsere Locks zu upgraden oder zu downgraden. Dadurch wird auch der Stempel auf den neuen Typ umgestellt. In Listing 9 sieht man zum Beispiel, wie wir das Downgrade korrekt durchführen können. Beachtet, dass wir den Write Lock nicht aufheben müssen, da der Stempel von Write nach Read konvertiert wurde.

public class StampedLockDowngradeDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long wstamp = sl.writeLock();
    System.out.println(sl); // Write-locked
    long rstamp = sl.tryConvertToReadLock(wstamp);
    if (rstamp != 0) {
      System.out.println("Converted write to read");
      System.out.println(sl); // Read-locks:1
      sl.unlockRead(rstamp);
      System.out.println(sl); // Unlocked
    } else { // this cannot happen (famous last words)
      sl.unlockWrite(wstamp);
      throw new AssertionError("Failed to downgrade lock");
    }
  }
}

Eine kleine Geschichte, die euch vielleicht erstaunen wird: Mein Freund Victor Grazi entdeckte einen Fehler in einer frühen Version von StampedLock. Wenn wir einen Write Lock auf einen Read Lock herabgestuft haben, blieben Threads, die auf einen Read Lock warteten, blockiert, bis der Read Lock schließlich aufgehoben wurde. Das Erstaunliche an der Geschichte ist, dass er diesen Fehler entdeckte, während er in seinem Java-Concurrent-Animated-Programm [9] herumklickte.

Wir können auch versuchen, einen Read Lock in einen Write Lock umzuwandeln. Im Gegensatz zur Kotlin-Erweiterungsfunktion ReentrantReadWriteLock.write() erfolgt die Konvertierung hierbei atomar. Sie kann jedoch immer noch fehlschlagen, z. B. wenn ein anderer Thread den Read Lock ebenfalls hält. In diesem Fall wäre ein vernünftiger Ansatz, auszusteigen und es erneut zu versuchen oder vielleicht mit einem Write Lock zu beginnen. Werfen wir in Listing 10 zunächst einen Blick auf den einfachen Fall der Umwandlung von Read zu Write.

public class StampedLockUpgradeDemo {
  public static void main(String... args) {
    var sl = new StampedLock();
    System.out.println(sl); // Unlocked
    long rstamp = sl.readLock();
    System.out.println(sl); // Read-locks:1
    long wstamp = sl.tryConvertToWriteLock(rstamp);
    if (wstamp != 0) {
      // works if no one else has a read-lock
      System.out.println("Converted read to write");
      System.out.println(sl); // Write-locked
      sl.unlockWrite(wstamp);
    } else {
      // we do not have an exclusive hold on read-lock
      System.out.println("Could not convert read to write");
      sl.unlockRead(rstamp);
    }
    System.out.println(sl); // Unlocked
  }
}

Die StampedLock-Javadoc-Dokumentation [10] zeigt mehrere Idiome/Arten, wie StampedLock verwendet werden kann. Zwei davon zeigen, wie Upgrades durchgeführt werden könnten, entweder aus einer pessimistischen oder einer optimistischen Lesart heraus. Die Upgradeidiome schneiden dann am besten ab, wenn die Wahrscheinlichkeit, dass wir zum Schreiben ein Upgrade benötigen, relativ gering ist und wenn dieses Upgrade eine hohe Erfolgschance hat.

Die Idiome sind gewöhnungsbedürftig. Zuerst sehen sie etwas undurchsichtig aus, mit beschrifteten Brüchen und scheinbar falsch verstandenen for-Schleifen. Die Optimistic-Read-Idiome in Java 8 [11] waren einfacher zu verstehen. Der Vorteil des moderneren Codes ist jedoch, dass wir weniger Wiederholungen unseres Read Codes haben. Ich bin nicht davon überzeugt, dass die Prüfung auf if (stamp == 0L) continue retryHoldingLock; den Code schneller macht. Normalerweise wollen wir bei Optimistic Reads so schnell wie möglich von tryOptimisticRead() zu validate() übergehen, um die Wahrscheinlichkeit zu minimieren, dass in der Zwischenzeit ein weiterer Thread schreibt. Ich verfügte über eine Benchmark, um dies zu beweisen, die jedoch für eine alte Version von StampedLock gedacht war, weshalb ich diese Forschung erneut durchführen muss.

Um das Optimistic-Read-Idiom in Aktion zu sehen, solltet ihr einen Blick auf den unter [12] gezeigten Commit von jdk.internal.foreign.MemoryScope werfen – völliger Zufall, dass das eingecheckt wurde, während ich damit beschäftigt war, diesen Text mit StampedLock zu schreiben. Danke an Doug Lea für den Hinweis!

Links & Literatur

[1] Jemerov, Dmitry; Isakova, Svetlana: „Kotlin in Action“, Manning Publications, 2017

[2] https://www.javaspecialists.eu/

[3] Bloch, Joshua: „Effective Java. Best Practices für die Java-Plattform“, dpunkt.verlag GmbH, 2018

[4] Naftalin, Maurice: „Mastering Lambdas. Java Programming in a Multicore World“, McGraw-Hill Education, 2014

[5] Freeman, Eric; Robson, Elisabeth; Bates, Bert; Sierra, Kathy: „Head First Design Patterns. A Brain Friendly Guide“, O’Reilly, 2014

[6] https://www.javaspecialists.eu/archive/Issue190.html

[7] https://www.javaspecialists.eu/archive/Issue190b.html

[8] https://en.wikipedia.org/wiki/Principle_of_least_astonishment

[9] https://sourceforge.net/projects/javaconcurrenta/

[10] https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/concurrent/locks/StampedLock.html

[11] https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/StampedLock.html

[12] cr.openjdk.java.net/~mcimadamore/8246050/webrev/raw_files/new/src/jdk.incubator.foreign/share/classes/jdk/internal/foreign/MemoryScope.java

Unsere Redaktion empfiehlt:

Relevante Beiträge

Abonnieren
Benachrichtige mich bei
guest
0 Comments
Inline Feedbacks
View all comments
X
- Gib Deinen Standort ein -
- or -