Musterlösungen

Laboraufgabe “Unterbrechbarkeit von Threads, die blockiert sind”

Ausgangslage mit synchronized

Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:

T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T1: interrupted
T1: finished
T3: working
T3: interrupted
T3: finished
T2: working
T2: interrupted
T2: finished

Interpretation der Ausgabe

Die Interrupted-Eigenschaft wirkt sich jeweils erst aus, wenn der Thread mit dem kritischen Abschnitt beginnt.

pp.TaskRegular: Umbau auf ReentrantLock mit lock()

Es wird eine neue Klassenvariable für den Lock eingeführt:

private static Lock lock = new ReentrantLock();

Es handelt sich um eine static-Variable, damit alle Instanzen von Task darauf zugreifen können und sich gegenseitig koordinieren können (vergleichbar mit dem Monitor Task.class statt this.

Mit der lokalen Variable lock wird unterschieden, ob die InterruptedException während einer Blockade (blocked == true) erfolgt ist. Ansonsten wird dem Verwendungsmuster von lock()/unlock() gefolgt.

@Override
public void run() {
  System.out.printf("%s: startup\n", getName());
  while (!this.stop) {
    var blocked = true;
    lock.lock();
    try {
      blocked = false;
      System.out.printf("%s: working\n", getName());
      Thread.sleep(8000);
    } catch (InterruptedException e) {
      if (blocked) {
        System.out.printf("%s: blocked state interrupted\n", getName());
      } else {
        System.out.printf("%s: interrupted\n", getName());
      }
    } finally {
      lock.unlock();
    }
  }
  System.out.printf("%s: finished\n", getName());
}

Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:

T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T1: interrupted
T1: finished
T2: working
T2: interrupted
T2: finished
T3: working
T3: interrupted
T3: finished

Interpretation der Ausgabe

Das Ergebnis ist ganz analog zur Ausgangslage (mit synchronized). Es wird hierdurch deutlich, dass blocked false ist, wenn sich der Interrupt auswirkt, d.h. also erst, wenn die Blockierung aufgehoben wurde und der Thread in den kritischen Abschnitt eingetreten ist.

pp.TaskInterruptably: Umbau auf ReentrantLock mit lockInterruptably()

Im Gegensatz zur vorigen Lösung wird statt lock() lockInterruptibly() verwendet. Dies kann aber (wenn es blockiert ist) eine InterruptedException werfen. Daher muss der Aufruf von lockInterruptibly() in einem eigenen try-catch erfolgen. Das finally mit lock.unlock() ist hier nicht zutreffend, da in dem Fall der Lock noch nicht geschlossen wäre.

@Override
public void run() {
  System.out.printf("%s: startup\n", getName());
  while (!this.stop) {
    try {
      lock.lockInterruptibly();
      try {
        System.out.printf("%s: working\n", getName());
        Thread.sleep(8000);
      } catch (InterruptedException e) {
        System.out.printf("%s: interrupted (inner)\n", getName());
      } finally {
        lock.unlock();
      }
    } catch (InterruptedException e) {
      System.out.printf("%s: interrupted (outer)\n", getName());
    }
  }
  System.out.printf("%s: finished\n", getName());
}

Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:

T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T3: interrupted (outer)
T3: finished
T2: interrupted (outer)
T2: finished
T1: interrupted (inner)
T1: finished

Interpretation der Ausgabe

Hier hat sich eine andere Situation ergeben: T3 und T2 werden bereits interruptet, während Sie noch aufgrund des Locks blockiert sind. T1 ist hingegen nicht blockiert, sondern arbeitet (warten auf das Ende von sleep), wenn es den Interruot bekommt und tatsächlich abgebrichen wird.

Laboraufgabe “Vergleich von ReentrantLock, ReentrantReadWriteLock, StampedLock und synchronized

Lösung für synchronized (pp.ExpSynchronized)

Die beiden abstrakten Methoden werden mit dem synchronized-Schlüsselwort threadsicher gemacht. Die kurze Implementierung des kritischen Abschnitts ist wie in ExpUnsynchronized.

package pp;

public class ExpSynchronized extends Experiment {

  @Override
  public synchronized void incCounter() {
    this.counter++;
  }

  @Override
  public synchronized int getCounter() {
    return this.counter;
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpSynchronized()).experimentPar();
  }
}

Lombok-Variante: @Synchronized

package pp;

import lombok.Synchronized;

public class ExpSynchronizedLombok extends Experiment {

  @Override
  @Synchronized
  public void incCounter() {
    this.counter++;
  }

  @Override
  @Synchronized
  public int getCounter() {
    return this.counter;
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpSynchronized()).experimentPar();
  }
}

Lösung für ReentrantLock (pp.ExpReentrantLock)

Das Benutzungmuster für ReentrantLock wird auf die beiden kritischen Abschnitte wie in ExpSynchronized angewendet. Dafür wird eine neue Instanzvariable vom Typ ReentrantLock namens lock eingeführt, die im Konstruktor initialisiert wird. Der Bool’sche Parameter beim Konstruktor von ReentrantLock bestimmt die Fairness beim Wechsel zum jeweils nächsten von mehreren an diesem Lock blockierten Threads.

unlock() sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.

package pp;

import java.util.concurrent.locks.ReentrantLock;

public class ExpReentrantLock extends Experiment {
  private final ReentrantLock lock;

  public ExpReentrantLock() {
    this.lock = new ReentrantLock(false); // fair==true => (noch) langsamer
  }

  @Override
  public void incCounter() {
    this.lock.lock();
    try {
      this.counter++;
    } finally {
      this.lock.unlock();
    }
  }

  @Override
  public int getCounter() {
    this.lock.lock();
    try {
      return this.counter;
    } finally {
      this.lock.unlock();
    }
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpReentrantLock()).experimentPar();
  }
}

Lombok-Variante: @Locked

package pp;

import lombok.Locked;

public class ExpReentrantLockLombok extends Experiment {

  @Override
  @Locked
  public void incCounter() {
    this.counter++;
  }

  @Override
  @Locked
  public int getCounter() {
    return this.counter;
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpReentrantLock()).experimentPar();
  }
}

Lösung für ReadWriteLock (pp.ExpReadWriteLock)

Es wird eine neue Instanzvariable vom Typ ReadWriteLock namens lock eingeführt, die im Konstruktor initialisiert wird. Der Bool’sche Parameter beim Konstruktor von ReadWriteLock bestimmt wieder die Fairness beim Wechsel zum jeweils nächsten von mehreren an diesem Lock blockierten Threads.

Zusätzlich gibt es zwei Instanzvariablen vom Typ Lock, die im Konstruktor vom ReadWriteLock geholt werden: Eine mit readLock(), die andere mit writeLock(). Beim Modifizieren der zu schützenden Variable counter in der Methode incCounter wird der Write-Lock beim Lesen in getCounter der Read-Lock benutzt.

unlock() (sowohl auf den Write-Lock, als auch auf den Read-Lock) sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.

package pp;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;;

public class ExpReadWriteLock extends Experiment {
  private final ReadWriteLock lock;
  private final Lock rLock;
  private final Lock wLock;

  public ExpReadWriteLock() {
    this.lock = new ReentrantReadWriteLock(false); // fair==true => (noch)
                                                   // langsamer
    this.rLock = this.lock.readLock();
    this.wLock = this.lock.writeLock();
  }

  @Override
  public void incCounter() {
    this.wLock.lock();
    try {
      this.counter++;
    } finally {
      this.wLock.unlock();
    }
  }

  @Override
  public int getCounter() {
    this.rLock.lock();
    try {
      return this.counter;
    } finally {
      this.rLock.unlock();
    }
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpReadWriteLock()).experimentPar();
  }
}

Lombok-Variante: @Locked.Read/@Locked.Write

package pp;

import lombok.Locked;

public class ExpReadWriteLockLombok extends Experiment {

  @Override
  @Locked.Write
  public void incCounter() {
    this.counter++;
  }

  @Override
  @Locked.Read
  public int getCounter() {
    return this.counter;
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpReadWriteLock()).experimentPar();
  }
}

Lösung für StampedLock (pp.ExpStampedLock)

java.util.concurrent.locks.StampedLock gibt es erst ab Java 8. Es wird eine neue Instanzvariable vom Typ StampedLock namens lock eingeführt, die im Konstruktor initialisiert wird.

Da die vor konkurrierenden Zugriffen zu schützende Variable counter in incCounter() verändert wird, muss hier der writeLock(...) zum Zugriffssschutz verwendet werden. In getCounter() hingegen kann optimistisches Lesen benutzt werden.

unlock() (sowohl auf den Write-Lock, als auch auf den Read-Lock) sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.

package pp;

import java.util.concurrent.locks.StampedLock;;

public class ExpStampedLock extends Experiment {
  private final StampedLock lock;

  public ExpStampedLock() {
    this.lock = new StampedLock();
  }

  @Override
  public void incCounter() {
    var stamp = this.lock.writeLock();
    try {
      this.counter++;
    } finally {
      this.lock.unlockWrite(stamp);
    }
  }

  @Override
  public int getCounter() {
    var stamp = this.lock.tryOptimisticRead();
    var result = this.counter;
    if (!this.lock.validate(stamp)) {
      // nicht erfolgreich
      stamp = this.lock.readLock();
      try {
        result = this.counter;
      } finally {
        this.lock.unlockRead(stamp);
      }
    }
    return result;
  }

  public static void main(String... args) throws InterruptedException {
    (new ExpStampedLock()).experimentPar();
  }
}

Laufzeitvergleich

Auf einem Aurora-DX 40 / Fedora Silverblue 40 Linux (6.11.3-200.fc40.x86_64) Rechner mit AMD Ryzen 7 5700G CPU bei 4,67 GHz ergeben sich mit OpenJDK 64-Bit Server VM Homebrew (build 23.0.1, mixed mode, sharing) folgende Beobachtungen zur Laufzeit (Median aus je drei Messungen):

10 Mio 10 Mio 100 Mio 100 Mio
99,99% Read 50% Read 99,99% Read 50% Read
synchronized 1431ms 961ms 16416ms 10125ms
@Synchronized 1653ms 987ms 14190ms 9227ms
ReentrantLock 435ms 416ms 5775ms 4664ms
@Locked 454ms 466ms 4718ms 4245ms
ReadWriteLock 1409ms 931ms 17414ms 8888ms
@Locked.Read/@Locked.Write 1466ms 934ms 16462ms 9357ms
StampedLock 17ms 188ms 99ms 2153ms

Gegenseitiger Ausschluss mit ReentrantLock ist sehr effizient. StampedLock kann jedoch durch optimistisches Lesen noch vorteilhafter sein, wenn es selten ändernde Zugriffe gibt.

Die Lombok-Varianten @Synchronized statt synchronized, @Locked statt @ReentrantLock und @Locked.Read/@Locked.Write statt ReentrantReadWriteLock haben im Vergleich zu den manuell programmierten Versionen von der Laufzeit her keinen Nachteil, sind aber im Quellcode deutlicher, übersichtlicher, kürzer und damit weniger fehleranfällig.