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.
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.
b beim Lesen bekommt und was am Ende in counter steht. Dann von vorn, diesmal abwechselnd: A, B, A, B, A, B.
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.
Wann liest B den Zähler, und was hat A bis dahin schon zurückgeschrieben?
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.
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?
Damit nichts verloren geht, muss ein Thread seinen Wert zurückgeschrieben haben, bevor der andere liest. Wie viele Reihenfolgen erlauben das?
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.
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.

Finde eine Reihenfolge, nach der keiner der beiden Threads mehr einen Schritt machen kann.
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 (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)
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);
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 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.
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.
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.
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.
Erst alle starten, dann auf alle warten: zwei Schleifen. Weil join() unterbrochen werden kann, braucht countWithThreads throws InterruptedException.
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.
Starte die nächste Einzahlung erst, wenn die vorige fertig ist.
Eine Schleife for (final amount in amounts) mit await deposit(amount); darin.
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)); } }
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.
Ä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.
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.
Account first = from.id < to.id ? from : to; und Account second = first == from ? to : from;. Am Rechnen innen ändert sich nichts.
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.
Ein Lock für alle Einzahlungen: final lock = Lock();. Jede Einzahlung meldest du mit lock.synchronized(() => deposit(amount)) an.
await Future.wait([for (final amount in amounts) lock.synchronized(() => deposit(amount))]);
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.
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.
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.
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.
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.
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.
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 (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)
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.