Zum Inhalt
wannacode

Sperren

Kurz gesagt: Ein Lock (Mutex) lässt immer nur einen Thread in den kritischen Abschnitt; in Java ist das synchronized oder ReentrantLock, und unter das Lock kommt nur das Wenige, was dorthin muss.

Problem

Wie man die andere Kasse aufhält

In der Lektion „Race Condition“ hat der Verkauf Verkäufe verloren: Zwei Threads machten gleichzeitig „lesen, abziehen, schreiben“, und einer überschrieb den anderen. Die drei Schritte müssen ein Ganzes sein: Solange ein Thread drin ist, muss der andere draußen warten.

Genau das macht ein Lock.

Die Idee

Das Lock

Ein Lock (Mutex, von „mutual exclusion“) ist ein Objekt mit einem Besitzer. Ein Thread nimmt es, erledigt seine Arbeit und gibt es frei. Ein anderer Thread, der es währenddessen verlangt, wartet. Der Code zwischen Nehmen und Freigeben ist der kritische Abschnitt: Höchstens ein Thread ist darin.

In Java kann jedes Objekt ein Lock sein. synchronized (lock) { … } nimmt es beim Betreten des Blocks und gibt es beim Verlassen frei, auch bei einer Exception:
class Stock {
private final Object lock = new Object();
private int left;
Stock(int left) {
this.left = left;
}
void sell() {
synchronized (lock) {
left--;
}
}
int left() {
synchronized (lock) {
return left;
}
}
}
Die zwei Kassen aus der letzten Lektion, mit denselben 100.000 Verkäufen je Kasse:
Stock stock = new Stock(200_000);
Runnable checkout = () -> {
for (int i = 0; i < 100_000; i++) {
stock.sell();
}
};
Thread a = new Thread(checkout);
Thread b = new Thread(checkout);
a.start();
b.start();
a.join();
b.join();
System.out.println("Bestellungen: " + 2 * 100_000);
System.out.println("Verkauft laut Lager:");
System.out.println(200_000 - stock.left());
Ausgabe
Bestellungen: 200000
Verkauft laut Lager:
200000

Genau, bei jedem Lauf. Das Lock ist ein Feld, einmal erzeugt: Beide Kassen sperren dasselbe.

Das Lock-Objekt ist ein eigenes privates Feld und nicht this. So kann kein Code von außen dasselbe Objekt sperren und deine Kassen aufhalten.

Sieh es dir an

Der letzte Artikel, repariert

Die Prüfung „ist einer da“ und das Nehmen liegen im selben kritischen Abschnitt. Wenn jemand drin ist und prüft, kann der andere nicht prüfen, bevor der erste genommen hat:

class Stock {
private final Object lock = new Object();
private int left;
Stock(int left) {
this.left = left;
}
boolean reserve() {
synchronized (lock) {
if (left == 0) {
return false; // keiner mehr da: ablehnen
}
left--; // einer ist da: nehmen
return true;
}
}
int left() {
synchronized (lock) {
return left;
}
}
}
Schritt für Schritt, in Worten
  1. Es sind noch 3 Stück übrig, und 4 Käufer rufen im selben Moment reserve() auf.
  2. Durch synchronized kommt nur einer, die anderen drei warten davor. Käufer 1 geht hinein, sieht 3 und nimmt eins: 2 übrig.
  3. Käufer 1 geht hinaus und gibt das Lock frei. Käufer 2 geht hinein, sieht 2 und nimmt eins: 1 übrig.
  4. Käufer 2 geht hinaus und gibt das Lock frei. Käufer 3 geht hinein, sieht 1 und nimmt eins: 0 übrig.
  5. Käufer 4 geht als letzter hinein und sieht 0: abgelehnt. Niemand sah eine Zahl, die ein anderer gerade ändert.
  6. 3 verkauft, 1 abgelehnt, 0 auf Lager: richtig. Der Preis: Die Käufer haben am Drehkreuz aufeinander gewartet.

Vier Käufer, drei Artikel, alle auf einmal:

Stock stock = new Stock(3);
boolean[] ok = new boolean[4];
List<Thread> buyers = new ArrayList<>();
for (int b = 0; b < 4; b++) {
int buyer = b;
Thread t = new Thread(() -> ok[buyer] = stock.reserve());
buyers.add(t);
t.start();
}
for (Thread t : buyers) {
t.join();
}
int sold = 0;
for (boolean o : ok) {
if (o) sold++;
}
System.out.println("Verkauft: " + sold);
System.out.println("Abgelehnt: " + (4 - sold));
System.out.println("Bestand: " + stock.left());
Ausgabe
Verkauft: 3
Abgelehnt: 1
Bestand: 0

Und hier sind die zwei Käufer aus der letzten Lektion, die den letzten Artikel kauften. Das Belasten der Karte (~100 ms) kommt nach dem Lock: Der Artikel ist reserviert, und die Karte wird belastet, ohne dass der andere wartet:

Stock stock = new Stock(1);
boolean[] bought = new boolean[2];
Runnable[] buyers = new Runnable[2];
for (int b = 0; b < 2; b++) {
int buyer = b;
buyers[b] = () -> {
if (stock.reserve()) {
pause(100); // die Karte wird belastet, ~100 ms
bought[buyer] = true;
}
};
}
Thread first = new Thread(buyers[0]);
Thread second = new Thread(buyers[1]);
first.start();
second.start();
first.join();
second.join();
int sold = (bought[0] ? 1 : 0) + (bought[1] ? 1 : 0);
System.out.println("Bestand vorher: 1");
System.out.println("Verkauft: " + sold);
System.out.println("Abgelehnt: " + (2 - sold));
System.out.println("Bestand: " + stock.left());
Ausgabe
Bestand vorher: 1
Verkauft: 1
Abgelehnt: 1
Bestand: 0

Einer verkauft, einer abgelehnt, 0 auf Lager. Bei jedem Lauf.

Probier es aus

Vergiss die Freigabe nicht

Neben synchronized hat Java ein Lock-Objekt, ReentrantLock: lock() nimmt es, unlock() gibt es frei. Die Freigabe steht in finally, also passiert sie auch bei einer Exception:
class Stock {
private final ReentrantLock lock = new ReentrantLock();
private int left;
Stock(int left) {
this.left = left;
}
void sell() {
lock.lock();
try {
left--;
} finally {
lock.unlock();
}
}
int left() {
lock.lock();
try {
return left;
} finally {
lock.unlock();
}
}
}

Was gibt das Lock frei?

void sell() {
    lock.lock();
    try {
        left--;
    } finally {
        
    }
}

Probier es aus

Das Lock, das nicht sperrt

Ein Kollege hat ein Lock hinzugefügt, aber die Kassen verlieren trotzdem Verkäufe. Welche Zeile ist falsch?

Wähle die Zeile mit dem Fehler.

Die Idee

Was ein Lock kostet

Ein Lock funktioniert, indem es Threads aufeinander warten lässt. Je länger ein Thread drin bleibt, desto länger warten die anderen. Das ist die Contention (Konkurrenz) um das Lock.

Ein Shop mit 10 Artikeln und vier Bestellungen auf einmal. Jede Bestellung nimmt einen Artikel und sendet eine E-Mail (~200 ms), und Network aus der Lektion „Warum Threads“ zählt die Warterunden:

class Network {
private int inFlight = 0;
private int rounds = 0;
// Fragt einen Dienst über das Netzwerk: Die Antwort kommt nach ~200 ms.
String call(String service, String answer) {
synchronized (this) {
if (inFlight == 0) rounds++; // niemand wartet: eine neue Runde
inFlight++;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (this) {
inFlight--;
}
return service + ": " + answer;
}
synchronized int rounds() {
return rounds;
}
}
class Shop {
private final Object lock = new Object();
private final Network net;
private int left = 10;
Shop(Network net) {
this.net = net;
}
// Schlecht: Die E-Mail geht raus, während das Lock gehalten wird.
void orderSlow(String customer) {
synchronized (lock) {
left--;
net.call("E-Mail", customer);
}
}
// Gut: Unter dem Lock liegt nur der Bestand.
void order(String customer) {
synchronized (lock) {
left--;
}
net.call("E-Mail", customer);
}
int left() {
synchronized (lock) {
return left;
}
}
}
Bei orderSlow liegt die E-Mail unter dem Lock:
Network net = new Network();
Shop shop = new Shop(net);
List<Thread> orders = new ArrayList<>();
for (int c = 1; c <= 4; c++) {
String customer = "Kunde " + c;
Thread t = new Thread(() -> shop.orderSlow(customer));
orders.add(t);
t.start();
}
for (Thread t : orders) {
t.join();
}
System.out.println("Bestellungen: 4, bestand: " + shop.left());
System.out.println("Warterunden: " + net.rounds());
Ausgabe
Bestellungen: 4, bestand: 6
Warterunden: 4

Vier Runden: Die E-Mails gehen einzeln raus, und der letzte Kunde wartet etwa 800 ms. Bei order liegt nur der Bestand unter dem Lock:

Network net = new Network();
Shop shop = new Shop(net);
List<Thread> orders = new ArrayList<>();
for (int c = 1; c <= 4; c++) {
String customer = "Kunde " + c;
Thread t = new Thread(() -> shop.order(customer));
orders.add(t);
t.start();
}
for (Thread t : orders) {
t.join();
}
System.out.println("Bestellungen: 4, bestand: " + shop.left());
System.out.println("Warterunden: " + net.rounds());
Ausgabe
Bestellungen: 4, bestand: 6
Warterunden: 1

Derselbe Bestand, eine Runde. Die Regeln:

  • Nur die gemeinsamen Daten unter das Lock. Rechne, formatiere und warte draußen. Drinnen nur lesen und schreiben.
  • Warte nie unter einem Lock: Netzwerk, Festplatte, Datenbank, sleep. Nimm, was du brauchst, gib das Lock frei und warte dann.
  • Ein Lock für ein Datenstück. Der Bestand und der Umsatz sind getrennt: jedes mit eigenem Lock, damit Kassen, die nur eines von beiden anfassen, nicht aufeinander warten.
Genauer, für Neugierige
  • synchronized ist reentrant: Ein Thread, der das Lock schon hält, kann einen weiteren Block mit demselben Lock betreten, ohne auf sich selbst zu warten. ReentrantLock ebenso, daher der Name.
  • ReentrantLock kann mehr als synchronized: tryLock() (nimm es, wenn es frei ist, sonst tu etwas anderes), tryLock(100, TimeUnit.MILLISECONDS) und eine faire Schlange mit new ReentrantLock(true).
  • Ohne das Lock könnte left() auch eine alte Zahl zurückgeben: Das Lock garantiert auch die Sichtbarkeit aus der letzten Lektion.

Ein Lock ist nicht der einzige Weg. Für einen Zähler gibt es atomare Operationen, die Threads gar nicht warten lassen, und Daten, die niemand ändert, brauchen kein Lock: die Lektion „Atomare Operationen und unveränderliche Objekte“. Und zwei Locks, die zwei Threads in unterschiedlicher Reihenfolge nehmen, können das Programm für immer anhalten: die Lektion „Deadlock“.

Übung

Der Umsatz und eine Bestellung mit mehreren Artikeln

Löse die Aufgaben in deinem Editor.

Der Umsatz. Repariere die Aufgabe aus der letzten Lektion: zwei Kassen mit je 50.000 Verkäufen zu 3 €, und Revenue schützt total mit einem Lock. Führe das Programm mehrmals aus: Der Umsatz muss jedes Mal derselbe sein.

Erwartet
Kasse 1: 50000 Verkäufe
Kasse 2: 50000 Verkäufe
Umsatz (€):
300000

Eine Bestellung mit mehreren Artikeln. Es sind 5 Artikel auf Lager. Schreibe reserve(count): Wenn mindestens count da sind, nimmt es sie und gibt true zurück, sonst nimmt es nichts und gibt false zurück. Drei Kunden wollen gleichzeitig je 2 Artikel, jeder in seinem eigenen Thread.

Erwartet
Bestellt: 3
Angenommen: 2
Abgelehnt: 1
Bestand: 1

Weitere Aufgaben

  • Der Preis eines Locks. Führe die zwei Kassen mit je 1.000.000 Verkäufen mit und ohne Lock aus und miss die Zeit. Wie viel langsamer ist es mit dem Lock? Und mit 8 Kassen?
  • Zwei Locks. Füge dem Bestand einen Umsatz mit eigenem Lock hinzu. Ein Verkauf zieht vom Bestand ab und addiert zum Umsatz. Bleiben die beiden zusammen, wenn jemand den Bestand und den Umsatz zwischen den beiden Locks liest? - tryLock. Mache einen Verkauf, der mit tryLock höchstens 50 ms auf das Lock wartet und bei einem Fehlschlag „Versuch es noch einmal“ zurückgibt.

Test

Teste dich

1. Zwei Threads rufen sell() auf, das left-- unter ein und demselben Lock ausführt. Was passiert, wenn der zweite das Lock erreicht, während der erste drin ist?
2. Die Prüfung „sind Artikel da“ liegt unter dem Lock, und das Abziehen steht in einem eigenen Block unter demselben Lock. Zwei Leute kaufen den letzten Artikel. Was passiert?
3. Unter dem Lock liegen das Abziehen vom Bestand und das Senden einer E-Mail (~200 ms). Vier Bestellungen kommen auf einmal. Wie lange wartet die letzte?
4. Was ist falsch an synchronized (new Object()) { left--; }? (C#: lock (new object()), Python: with threading.Lock())

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·