Informatik

Nebenläufigkeit

Nur Leistungskurs. Dieses Kapitel gehört zu den zusätzlichen Anforderungen des Leistungskurses. Im Grundkurs kannst du es überspringen.

Warum überhaupt?

Alle Programme dieses Lernpfads liefen sequenziell: eine Anweisung nach der anderen. Das entspricht nicht mehr der Wirklichkeit heutiger Rechner.

Ein moderner Prozessor hat mehrere Kerne. Ein sequenzielles Programm benutzt davon genau einen – der Rest steht still.

Außerdem gibt es Wartezeiten: auf eine Netzwerkantwort, auf die Festplatte, auf eine Eingabe. Während das Programm wartet, könnte es längst etwas anderes tun.

Nebenläufig heißen Abläufe, die unabhängig voneinander stattfinden können – deren Reihenfolge also nicht festgelegt ist.

Ein Thread ist ein Ausführungsstrang innerhalb eines Programms. Mehrere Threads teilen sich denselben Speicher, haben aber je einen eigenen Aufrufstapel.

Parallel heißen Abläufe, die tatsächlich gleichzeitig ausgeführt werden – dafür braucht man mehrere Kerne. Nebenläufigkeit ist die Voraussetzung für Parallelität, aber nicht dasselbe: Auf einem Kern wechselt das Betriebssystem zwischen den Threads hin und her.

Zwei Threads

a) Führe das Programm mehrmals aus. Ist die Ausgabe jedes Mal gleich?

b) Was bedeutet das für das Testen nebenläufiger Programme?

Auflösung

a) Die Ausgaben von A und B mischen sich, und die genaue Verschränkung kann sich von Lauf zu Lauf unterscheiden. Wann welcher Thread zum Zug kommt, entscheidet das Betriebssystem.

b) Nebenläufige Programme sind nicht deterministisch: Derselbe Eingang kann verschiedene Abläufe erzeugen. Ein Test, der einmal durchläuft, beweist damit deutlich weniger als bei einem sequenziellen Programm.

Fehler in nebenläufigen Programmen zeigen sich oft erst nach Tagen – und lassen sich dann nicht mehr nachstellen. Das macht sie zu den unangenehmsten Fehlern überhaupt.

Der Ablauf in Java:

  1. Eine Klasse implementiert die Schnittstelle Runnable und damit die Methode run().
  2. new Thread(objekt) erzeugt einen Ausführungsstrang dafür.
  3. start() startet ihn. Das Hauptprogramm läuft sofort weiter – es wartet nicht.

Ein häufiger Anfängerfehler: run() direkt aufzurufen statt start(). Dann läuft die Methode einfach im aufrufenden Thread, und es passiert gar nichts Nebenläufiges.

Das Kernproblem: gemeinsame Daten

Zwei Threads erhöhen dieselbe Zählvariable je 10 000 Mal. Am Ende sollten 20 000 dastehen.

Sage voraus, was herauskommt. Führe das Programm dann mehrmals aus.

Auflösung

Häufig kommt weniger als 20 000 heraus – und bei jedem Lauf etwas anderes.

Der Grund steckt in einer einzigen Zeile: stand = stand + 1;

Das sieht aus wie ein Schritt, sind aber drei:

  1. den aktuellen Wert von stand lesen,
  2. eins dazuzählen,
  3. das Ergebnis zurückschreiben.

Passiert nun Folgendes:

Zeit Thread 1 Thread 2 stand
1 liest 100 100
2 liest 100 100
3 rechnet 101 100
4 rechnet 101 100
5 schreibt 101 101
6 schreibt 101 101

Zwei Einzahlungen, aber nur eine ist angekommen. Eine ist verloren gegangen.

Eine solche Situation heißt Wettlaufsituation (englisch race condition): Das Ergebnis hängt davon ab, welcher Thread zufällig zuerst fertig wird.

Der Abschnitt, in dem auf gemeinsame Daten zugegriffen wird, heißt kritischer Abschnitt. In ihm darf immer nur ein Thread gleichzeitig sein – diese Eigenschaft nennt man wechselseitigen Ausschluss.

Die Lösung

Ein Semaphor ist ein Zähler mit zwei Operationen:

  • acquire() – „ich betrete den Abschnitt“. Ist er belegt, wartet der Thread.
  • release() – „ich bin fertig“. Ein wartender Thread darf hinein.

new Semaphore(1) erlaubt genau einen Thread gleichzeitig. Solche Semaphore heißen auch Schloss oder mutex.

Wichtig ist die Regel: Was zwischen acquire und release steht, sollte so kurz wie möglich sein. Je länger der kritische Abschnitt, desto mehr Zeit verbringen die anderen Threads mit Warten – und desto weniger bringt die Nebenläufigkeit.

Der Preis

a) Miss, wie lange die gesicherte und die ungesicherte Fassung brauchen. Was fällt auf?

b) Angenommen, du sicherst nicht nur die eine Zeile, sondern die ganze Schleife in run ab. Was wäre das Ergebnis, und was der Nachteil?

c) Nenne einen Fall, in dem zwei Threads sich gegenseitig blockieren.

Auflösung

a) Die gesicherte Fassung ist deutlich langsamer. Jedes acquire und release kostet Zeit, und die Threads warten regelmäßig aufeinander.

Das ist der Grundkonflikt der Nebenläufigkeit: Korrektheit kostet Geschwindigkeit. Wer alles absichert, ist am Ende langsamer als ein sequenzielles Programm.

b) Das Ergebnis wäre korrekt – aber die Threads liefen faktisch nacheinander. Der zweite wartete, bis der erste seine 10 000 Durchläufe fertig hat. Man hätte den ganzen Aufwand betrieben, ohne etwas zu gewinnen.

c) Ein Verklemmung (englisch deadlock): Thread 1 hält Schloss A und wartet auf B, Thread 2 hält B und wartet auf A. Beide warten für immer.

Ein Alltagsbild: Zwei Autos an einer schmalen Stelle, jedes wartet darauf, dass das andere zurücksetzt.

Nebenläufigkeit, die du längst benutzt hast

Jedes Scratch-for-Java-Programm dieses Lernpfads war nebenläufig – nur hat die Bibliothek dir die Arbeit abgenommen.

Die Methode run() jedes Sprites wird etwa 60-mal pro Sekunde aufgerufen. Aus Sicht der Programmierung laufen alle Figuren gleichzeitig: Der Spieler bewegt sich, die Gegner bewegen sich, die Münzen drehen sich.

Die Bibliothek sorgt dafür, dass diese Aufrufe sich nicht in die Quere kommen. Deshalb konntest du run() schreiben, ohne je über Semaphore nachzudenken.

Das ist ein verbreitetes Muster: Nebenläufigkeit wird in eine Bibliothek oder einen Rahmen verpackt, und die Anwendung bleibt einfach.

Aufgabe: Beurteilen

Entscheide für jede Situation, ob Nebenläufigkeit sinnvoll ist. Begründe.

a) Ein Programm sortiert ein Feld mit 20 Werten.

b) Eine Anwendung lädt beim Start Daten aus dem Netz und soll dabei bedienbar bleiben.

c) Ein Programm berechnet für 10 000 Bilder unabhängig voneinander die Helligkeit.

d) Ein Programm berechnet die Fibonacci-Folge, bei der jeder Wert von den beiden vorherigen abhängt.

e) Mehrere Kassen buchen auf dasselbe Lagerbestandskonto.

Auflösung

a) Nein. Bei 20 Werten kostet die Verwaltung der Threads mehr als die Sortierung.

b) Ja. Das Laden gehört in einen eigenen Thread, damit die Oberfläche nicht einfriert. Das ist der häufigste Einsatzzweck überhaupt.

c) Ja, ideal. Die 10 000 Berechnungen sind vollständig unabhängig – es gibt keine gemeinsamen Daten und damit kein Sicherungsproblem. Auf acht Kernen wird es fast achtmal so schnell.

d) Nein. Jeder Wert braucht die beiden vorherigen. Die Abhängigkeit erzwingt die Reihenfolge – hier ist nichts nebenläufig.

e) Ja, aber mit Absicherung. Genau der Fall aus dem Beispiel oben. Ohne wechselseitigen Ausschluss gehen Buchungen verloren.

Die Faustregel: Nebenläufigkeit lohnt sich, wenn die Teilaufgaben unabhängig sind. Sobald sie sich gemeinsame Daten teilen, kommt der Aufwand für die Absicherung dazu – und mit ihm eine ganze Klasse schwer zu findender Fehler.

Zusatzaufgabe

Das Erzeuger-Verbraucher-Problem: Ein Thread erzeugt Daten und legt sie in einen Puffer, ein anderer nimmt sie heraus und verarbeitet sie.

a) Welche zwei Situationen müssen abgesichert werden? Denk an einen vollen und an einen leeren Puffer.

b) Setze es mit deiner Schlange aus dem Kapitel über lineare Datenstrukturen um.

c) Was passiert ohne Absicherung? Baue es absichtlich falsch und beobachte.

d) Beurteile: Ab welcher Puffergröße bringt die Nebenläufigkeit einen messbaren Gewinn?


Selbsttest

Nebenläufigkeit

Teilbare URL erstellen

Abschnitte auswählen