ProjektBaut auf Ein eigener Webserver auf
image: teaser.webp:contain image_alt: "Zwei Threads, die je einen Lock halten und auf den des anderen warten: ein Deadlock" kind: projekt language: Java / Dart languages: [java, dart] duration: 3 Doppelstunden curriculum: "Oberstufe: Rechner und Netze (Client-Server-Struktur)" topics: [client-server] requires: [webserver]

Nebenläufigkeit

Ein Webserver beantwortet viele Anfragen zugleich, ein Spiel rechnet, während es zeichnet: Teile eines Programms laufen nebeneinander. Das geht schief, sobald sie dieselben Daten ändern. In dieser Aktivität probierst du zuerst ohne Code aus, wo es schiefgeht, mit zwei Threads, deren Schritte du selbst in eine Reihenfolge bringst. Dann schützt du gemeinsame Daten mit einem Lock und siehst, wie zwei Locks einander blockieren können.

1. Zwei Threads, ein Zähler

Im Webserver zählte ein Zähler die Besuche, und jede Verbindung lief in einem eigenen Thread. Zählen sieht aus wie ein einziger Schritt, counter ← counter + 1. Der Rechner macht daraus drei: den Wert lesen, eins dazuzählen, den neuen Wert zurückschreiben. Jeder Thread hat dafür seine eigene Variable, a und b; counter teilen sich beide.

Zwei Threads können ihre Schritte in jeder Reihenfolge machen. Welcher als Nächstes dran ist, entscheidest hier du: Jeder Knopf führt die nächste Zeile eines Threads aus.

Ausprobieren: Lass erst A alle drei Schritte machen, dann B. Achte darauf, welchen Wert b beim Lesen bekommt und was am Ende in counter steht. Dann von vorn, diesmal abwechselnd: A, B, A, B, A, B.
Aufgabe 1Eine Erhöhung geht verloren

Beide Threads erhöhen den Zähler um eins. Finde eine Reihenfolge der sechs Schritte, nach der counter am Ende 1 ist und nicht 2.

Tipp

Wann liest B den Zähler, und was hat A bis dahin schon zurückgeschrieben?

Nächster Tipp

Lass beide Threads lesen, bevor einer schreibt.

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Liest B den Zähler, bevor A seinen neuen Wert zurückgeschrieben hat, rechnen beide mit 0 und schreiben beide 1. Eine der beiden Erhöhungen ist verloren, ohne Fehlermeldung. Das heißt lost update; dass das Ergebnis von der Reihenfolge abhängt, in der die Threads drankommen, heißt Race Condition.

Aufgabe 2Wie oft geht es gut?

Es gibt 20 Reihenfolgen der sechs Schritte, in denen jeder Thread seine drei Zeilen der Reihe nach ausführt. In wie vielen davon ist counter am Ende 2?

Tipp

Damit nichts verloren geht, muss ein Thread seinen Wert zurückgeschrieben haben, bevor der andere liest. Wie viele Reihenfolgen erlauben das?

2. Ein Lock

Ein Lock (auch Mutex) sorgt dafür, dass immer nur ein Thread einen Abschnitt ausführt. lock m nimmt den Lock m; hält ihn schon ein anderer Thread, wartet der Thread an dieser Zeile, bis unlock m ihn freigibt.

Ausprobieren: Versuch wieder, eine Erhöhung zu verlieren. Achte darauf, wann der Knopf eines Threads grau wird und was dann in seiner Spalte steht.

3. Zwei Locks

Eine Bank überweist zwischen zwei Konten x und y. Damit niemand dazwischenfunkt, nimmt jede Überweisung die Locks beider Konten. A überweist von x nach y und nimmt erst m (für x), dann n. B überweist in die andere Richtung und nimmt erst n, dann m.

Aufgabe 3Keiner kommt weiter

Finde eine Reihenfolge, nach der keiner der beiden Threads mehr einen Schritt machen kann.

Tipp

Jeder Thread braucht beide Locks. Was passiert, wenn jeder schon einen hat?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Hält jeder Thread einen Lock und wartet auf den des anderen, kommt keiner mehr weiter: ein Deadlock. Es hilft, wenn alle Threads ihre Locks in derselben Reihenfolge nehmen, hier immer erst m, dann n. Dann kann der zweite gar nicht erst einen Lock halten, auf den der erste wartet.

Edsger W. Dijkstra 2002: ein Mann mit Brille, grauem Bart und roter Pullunderweste, im Halbprofil.

Edsger W. Dijkstra (1930–2002), niederländischer Informatiker. Sein Verfahren für den kürzesten Weg erdachte er 1956 in zwanzig Minuten auf einer Caféterrasse in Amsterdam, ohne Stift und Papier. Navis und der Routenplaner suchen ihre Wege mit seiner Idee.

Dass du Schleifen und Funktionen schreibst, statt mit goto kreuz und quer durchs Programm zu springen, geht auch auf ihn zurück: »Go To Statement Considered Harmful«, 1968.

Für Programme, die nebeneinander laufen, erfand er 1962 oder 1963 den Semaphor, der regelt, wer an eine gemeinsame Sache darf, und 1965 eine Prüfungsaufgabe über Deadlocks: die speisenden Philosophen.

Foto: Hamilton Richards, 2002, CC BY-SA 3.0, zugeschnitten (Wikimedia Commons)

4. Im Code

In Java ist ein Thread ein Objekt der Klasse Thread. Ihm gibst du mit, was er tun soll, als Lambda; start() lässt ihn loslaufen, join() wartet, bis er fertig ist. Dieses Programm lässt zwei Threads je hunderttausendmal count++ ausführen:

int count = 0;

void main() throws InterruptedException {
    Thread a = new Thread(() -> { for (int i = 0; i < 100000; i++) count++; });
    Thread b = new Thread(() -> { for (int i = 0; i < 100000; i++) count++; });
    a.start();
    b.start();
    a.join();
    b.join();
    IO.println(count);
}

Dart hat keine Threads, die sich Variablen teilen. Ein Dart-Programm macht immer nur eins auf einmal. Trotzdem kann eine Erhöhung verloren gehen: an einem await. Dort hält eine async-Funktion an, bis etwas fertig ist, etwa das Speichern, und in der Zwischenzeit läuft anderer Code weiter. Diese Funktion zahlt Geld auf ein Konto ein und speichert dabei:

var balance = 0;

Future<void> save() => Future.delayed(const Duration(milliseconds: 10));

Future<void> deposit(int amount) async {
  final before = balance;
  await save();
  balance = before + amount;
}

Future.wait startet mehrere solche Aufrufe und wartet, bis alle fertig sind:

balance = 0;
await Future.wait([deposit(5), deposit(7)]);
print(balance);
Vermutung 4Was kommt heraus?

Was gibt das Programm Race aus? Überleg zuerst, dann probier es mehrmals aus: Leg Race.java in /workspace/nebenlaeufigkeit und führe es mit java Race.java aus.

Was kommt heraus?
Vermutung 4Was kommt heraus?

Was gibt print(balance) aus, nachdem Future.wait([deposit(5), deposit(7)]) fertig ist? Überleg zuerst, dann probier es aus, in einer Datei /workspace/nebenlaeufigkeit/bank.dart mit einem main, das async ist, und dart run bank.dart.

Was kommt heraus?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Bei jedem Lauf kommt eine andere Zahl heraus, fast immer weniger als 200000. count++ ist in Java genau die drei Schritte aus dem Widget: lesen, eins dazu, zurückschreiben. Die beiden Threads laufen auf zwei Kernen wirklich gleichzeitig, und jedes Mal, wenn sie zwischen Lesen und Schreiben aufeinandertreffen, geht eine Erhöhung verloren. Wie oft das passiert, hängt davon ab, wann der Rechner welchen Thread laufen lässt. Darum ist der Fehler so tückisch: Ein Lauf kann auch einmal stimmen.

Es kommt 7 heraus, bei jedem Lauf. deposit(5) liest 0 und hält am await an; dann liest deposit(7) ebenfalls 0 und hält an. Danach schreibt der erste 5 zurück, der zweite 7. Genau wie im Widget haben beide gelesen, bevor einer geschrieben hat. Anders als bei Threads passiert das in Dart immer an derselben Stelle, am await, und darum jedes Mal gleich.

Ein Lock heißt in Java oft einfach synchronized. Eine Methode mit diesem Wort kann immer nur ein Thread zugleich ausführen; jeder andere wartet, bis sie wieder frei ist. Das ist der Lock aus dem Widget, um die ganze Methode herum.

Damit nichts verloren geht, darf eine Einzahlung erst beginnen, wenn die vorige fertig ist. In einer Schleife mit await geht das: Jedes await deposit(…) wartet, bis diese Einzahlung gespeichert ist.

Aufgabe 5Ein Zähler, der nichts verliert

Schreib in /workspace/nebenlaeufigkeit/Counter.java eine Klasse Counter mit einer Methode increment(), die den Zähler um eins erhöht, und einer Methode get(), die ihn zurückgibt; beide synchronized. Dazu public static int countWithThreads(int threads, int times): Sie startet threads Threads, von denen jeder times-mal increment() auf demselben Counter aufruft, wartet mit join() auf alle und gibt dann den Zählerstand zurück.

Tipp

Ein Thread bekommt seine Arbeit als Lambda: new Thread(() -> { for (int i = 0; i < times; i++) counter.increment(); }). Merk dir alle Threads in einem Array Thread[], damit du danach auf jeden mit join() warten kannst.

Nächster Tipp

Erst alle starten, dann auf alle warten: zwei Schleifen. Weil join() unterbrochen werden kann, braucht countWithThreads throws InterruptedException.

Aufgabe 5Ein Zähler, der nichts verliert

Schreib in /workspace/nebenlaeufigkeit/bank.dart, unter balance, save und deposit von oben, eine Funktion Future<int> depositAll(List<int> amounts): Sie setzt balance auf 0, zahlt jeden Betrag mit deposit ein, ohne dass einer verloren geht, und gibt dann balance zurück.

Tipp

Starte die nächste Einzahlung erst, wenn die vorige fertig ist.

Nächster Tipp

Eine Schleife for (final amount in amounts) mit await deposit(amount); darin.

Locks im Code

Die Überweisungen aus dem Widget sehen in Java so aus. synchronized (from) { … } nimmt den Lock des Objekts from und gibt ihn am Ende des Blocks wieder frei; jedes Objekt in Java hat so einen Lock. run lässt zwei Threads je times-mal überweisen, einen von x nach y, einen zurück:

class Account {
    final int id;
    int balance;

    Account(int id, int balance) {
        this.id = id;
        this.balance = balance;
    }
}

public class Bank {
    static void transfer(Account from, Account to, int amount) {
        synchronized (from) {
            synchronized (to) {
                from.balance -= amount;
                to.balance += amount;
            }
        }
    }

    static int run(int times) throws InterruptedException {
        Account x = new Account(1, 100);
        Account y = new Account(2, 100);
        Thread a = new Thread(() -> { for (int i = 0; i < times; i++) transfer(x, y, 1); });
        Thread b = new Thread(() -> { for (int i = 0; i < times; i++) transfer(y, x, 1); });
        a.start();
        b.start();
        a.join();
        b.join();
        return x.balance + y.balance;
    }

    static void main() throws InterruptedException {
        IO.println(run(100000));
    }
}
Ausprobieren: Leg Bank.java in /workspace/nebenlaeufigkeit und führe es mit java Bank.java aus. Achte darauf, ob am Ende 200 erscheint, und wie oft du es versuchen musst. Ein Programm, das hängt, beendest du mit StrgC.

Für async-Code hat Dart keinen Lock, aber einen kann man aus Futures bauen. Diese Klasse merkt sich in _last, wann die zuletzt angemeldete Aktion fertig ist. synchronized hängt die nächste Aktion mit then dahinter: Sie beginnt erst, wenn die vorige fertig ist, auch wenn sie mit einem Fehler endet.

class Lock {
  Future<void> _last = Future.value();

  Future<T> synchronized<T>(Future<T> Function() action) {
    final result = _last.then((_) => action());
    _last = result.then((_) {}, onError: (_) {});
    return result;
  }
}

lock.synchronized(() => deposit(5)) meldet eine Einzahlung beim Lock an. Startest du mehrere so zugleich, kommt trotzdem eine nach der anderen an die Reihe.

Aufgabe 6Locks in fester Reihenfolge

Ändere transfer in Bank.java so, dass run nie hängen bleibt: Beide Threads sollen die Locks der zwei Konten in derselben Reihenfolge nehmen, erst das Konto mit der kleineren id, dann das andere. Am Ende muss weiter 200 auf beiden Konten zusammen stehen.

Tipp

Bestimme zuerst, welches der beiden Konten from und to die kleinere id hat. Dieses Konto ist first, das andere second; dann synchronized (first) außen und synchronized (second) innen.

Nächster Tipp

Account first = from.id < to.id ? from : to; und Account second = first == from ? to : from;. Am Rechnen innen ändert sich nichts.

Aufgabe 6Locks in fester Reihenfolge

Schreib in bank.dart, unter deposit und der Klasse Lock, eine Funktion Future<int> depositAllAtOnce(List<int> amounts): Sie setzt balance auf 0, startet alle Einzahlungen zugleich mit Future.wait, aber jede über denselben Lock, damit keine verloren geht, und gibt dann balance zurück.

Tipp

Ein Lock für alle Einzahlungen: final lock = Lock();. Jede Einzahlung meldest du mit lock.synchronized(() => deposit(amount)) an.

Nächster Tipp

await Future.wait([for (final amount in amounts) lock.synchronized(() => deposit(amount))]);

Arbeit weitergeben

Oft macht ein Thread etwas, das ein anderer weiterverarbeitet: Ein Thread liest Anfragen, ein anderer beantwortet sie. Dazwischen liegt ein Puffer mit Platz für ein paar Werte. Ist er voll, muss der eine warten; ist er leer, der andere. Eine Klasse, deren Methoden alle synchronized sind und die Threads so warten lässt, heißt Monitor.

Warten geht in Java mit wait(): Der Thread gibt den Lock des Objekts ab und schläft, bis ein anderer notifyAll() aufruft. Danach bekommt er den Lock zurück und prüft seine Bedingung noch einmal, darum steht wait() in einer while-Schleife:

public synchronized void put(int x) throws InterruptedException {
    while (count == items.length) {
        wait();
    }
    // … x hineinlegen …
    notifyAll();
}

Ein Isolate ist in Dart ein eigener Arbeiter mit eigenem Speicher. Isolates teilen keine Variablen; sie schicken einander nur Nachrichten. Darum kann zwischen ihnen auch keine Erhöhung verloren gehen. Und anders als async-Code laufen mehrere Isolates wirklich gleichzeitig, auf mehreren Kernen.

Isolate.run startet eine Funktion in einem neuen Isolate und gibt ihr Ergebnis als Future zurück. Diese Funktion zählt die Primzahlen von from bis vor to:

import 'dart:isolate';

int countPrimes(int from, int to) {
  var count = 0;
  for (var n = from; n < to; n++) {
    if (n < 2) continue;
    var prime = true;
    for (var d = 2; d * d <= n; d++) {
      if (n % d == 0) {
        prime = false;
        break;
      }
    }
    if (prime) count++;
  }
  return count;
}

await Isolate.run(() => countPrimes(0, 1000)) zählt dasselbe in einem eigenen Isolate.

Aufgabe 7Arbeit weitergeben

Schreib in /workspace/nebenlaeufigkeit/Buffer.java einen Monitor Buffer: new Buffer(capacity) hat Platz für capacity Zahlen, put(int x) legt eine hinein und wartet, solange er voll ist, take() nimmt die älteste heraus und wartet, solange er leer ist. Dazu public static int transfer(int n, int capacity): Ein Thread legt die Zahlen 1 bis n hinein, ein zweiter nimmt n Zahlen heraus und zählt sie zusammen; die Methode wartet auf beide und gibt die Summe zurück.

Tipp

Ein Array items mit capacity Plätzen, dazu count (wie viele drin sind) und first (wo die älteste liegt). Die nächste freie Stelle ist (first + count) % items.length.

Nächster Tipp

put und take sind synchronized, warten mit while (…) wait(); und rufen am Ende notifyAll() auf. In transfer zählt der zweite Thread in ein int[] sum = { 0 }, denn eine lokale Variable kann ein Lambda nicht ändern.

Aufgabe 7Arbeit weitergeben

Schreib in /workspace/nebenlaeufigkeit/primes.dart, unter countPrimes, eine Funktion Future<int> countPrimesParallel(int limit, int parts): Sie teilt die Zahlen von 0 bis vor limit in parts Stücke, zählt jedes Stück mit countPrimes in einem eigenen Isolate und gibt die Summe zurück.

Tipp

Ein Stück ist size = (limit + parts - 1) ~/ parts Zahlen lang; Stück p geht von p * size bis vor (p + 1) * size, das letzte nur bis limit.

Nächster Tipp

await Future.wait([for (var p = 0; p < parts; p++) Isolate.run(() => countPrimes(…, …))]) gibt die Zahlen der Stücke als Liste; die Summe bekommst du mit fold.

Java hat so einen Puffer schon: ArrayBlockingQueue. Seine Methoden put und take warten genauso, wenn er voll oder leer ist. Im Alltag nimmst du ihn statt eines eigenen Monitors.

Tony Hoare 2011: ein älterer Mann mit weißem Bart und Brille, freundlich in die Kamera blickend.

Tony Hoare (1934–2026), britischer Informatiker. Quick Sort erfand er 1959 als Austauschstudent in Moskau: Er sollte ein Programm schreiben, das Russisch übersetzt, und musste dafür die Wörter eines Satzes sortieren.

1965 baute er in eine Programmiersprache den Wert null ein, »weil es so einfach war«. Später nannte er ihn seinen »Milliarden-Dollar-Fehler«: Meldungen wie undefined method … for nil in Ruby stammen von dieser Idee.

Anfang der 1970er Jahre entwickelte er mit Per Brinch Hansen den Monitor, der verhindert, dass Threads zugleich an die Daten eines gemeinsamen Objekts gehen.

Foto: Rama, 2011, CC BY-SA 2.0 fr, zugeschnitten (Wikimedia Commons)

Ausprobieren: Miss mit einer Stopwatch, wie lange countPrimesParallel(5000000, 1) braucht und wie lange dasselbe mit 2 und mit 4 Teilen. Dein Workspace hat vier Kerne. Achte darauf, ob vier Teile auch viermal so schnell sind, und überleg, welcher Teil am längsten rechnet.