Zum Inhalt
wannacode

Threads

Kurz gesagt: Ein Thread ist ein eigener Ausführungsstrang im Programm; mehrere Threads laufen gleichzeitig und teilen sich den Speicher, deshalb wird ein gemeinsamer Zähler mit lock oder Interlocked geschützt.

Problem

Drei Berichte, einer nach dem anderen

Das Programm lädt drei Monatsberichte vom Server herunter. Der Download ist hier eine Pause: Thread.Sleep(ms) hält das Programm für so viele Millisekunden an. Thread liegt in System.Threading, das standardmäßig eingebunden ist.
Program.cs
static void Download(string file, int ms)
{
Thread.Sleep(ms);
Console.WriteLine($"Heruntergeladen: {file}");
}
static void Main()
{
Download("maerz.pdf", 600);
Download("april.pdf", 200);
Download("mai.pdf", 400);
Console.WriteLine("Fertig");
}
Ausgabe
Heruntergeladen: maerz.pdf
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Fertig
Die Pausen sind 0,6, 0,2 und 0,4 Sekunden, zusammen 1,2 Sekunden. Die ganze Zeit wartet das Programm nur auf das Netzwerk. Gäbe es einen Button „Abbrechen“, würde er nicht reagieren. Und die drei Berichte hängen nicht voneinander ab, sie könnten also gut gleichzeitig heruntergeladen werden.

Die Idee

Prozess und Thread

Wenn du das Programm startest, gibt ihm das Betriebssystem einen Prozess: eigenen Speicher, eigene geöffnete Dateien. Im Prozess laufen ein oder mehrere Threads. Ein Thread ist eine Folge von Befehlen, die nacheinander ausgeführt werden. Bisher hatte jedes deiner Programme einen Thread: den, der Main ausführt.

Die Threads eines Prozesses teilen sich ihren Speicher: Sie sehen dieselben Objekte. Das macht sie praktisch (sie können einander leicht Daten geben) und gefährlich (sie können einander durcheinanderbringen, wie du gleich sehen wirst).

  • Parallel heißt wirklich gleichzeitig, auf verschiedenen Kernen des Prozessors.
  • Nebenläufig (concurrent) heißt auf einem Kern, abwechselnd und so schnell, dass es gleichzeitig aussieht.

Für dich als Programmierer spielt der Unterschied selten eine Rolle: In beiden Fällen weißt du nicht, welcher Thread wann weitermacht.

Genauer, für Neugierige
  • Der Browser Chrome startet für jeden Tab einen eigenen Prozess. Deshalb hält eine hängende Seite die anderen nicht auf, und deshalb braucht der Browser auch viel Speicher.
  • Threads benutzt jedes Programm, das mehrere Dinge auf einmal tut: Ein Spiel spielt den Ton in einem Thread ab und führt die Logik in einem anderen aus; ein Webserver bedient jeden Client in seinem eigenen Thread.
  • Der Preis: Jeder Thread hat seinen eigenen Stack, und der Speicher dafür wird extra reserviert. Tausende Threads kosten viel. Deshalb nehmen echte Programme einen Thread-Pool (die letzte Idee der Lektion).

Sieh es dir an

Der erste Thread

Die Klasse Thread ist ein Thread. Ihrem Konstruktor gibst du mit, was er tun soll: ein Delegate ThreadStart, also eine Methode ohne Parameter, die nichts zurückgibt. Am kürzesten geht es mit einem Lambda:

Thread worker = new Thread(() => Download("april.pdf", 200));
worker.Start();
Console.WriteLine("Main wartet nicht");
worker.Join();
Console.WriteLine("Der Bericht ist heruntergeladen");
Ausgabe
Main wartet nicht
Heruntergeladen: april.pdf
Der Bericht ist heruntergeladen
  • Start() startet den neuen Thread und kehrt sofort zurück. Main macht weiter, ohne zu warten, deshalb steht „Main wartet nicht“ zuerst da.
  • Join() lässt Main warten, bis der Thread fertig ist.

Download ist die Methode aus der ersten Karte. Exceptions sind in C# nicht geprüft, deshalb gibt es hier kein throws, und nichts muss gefangen werden: Main bleibt static void Main().

Genauer, für Neugierige
Das Lambda ist der kürzeste Weg, aber nicht der einzige. In älterem Code begegnen dir die anderen drei:
class ReportJob
{
public void Run()
{
Console.WriteLine("2. eine Methode eines Objekts");
}
}
static void PrintReport()
{
Console.WriteLine("1. eine statische Methode über ihren Namen");
}
static void Main()
{
Thread a = new Thread(PrintReport);
Thread b = new Thread(new ReportJob().Run);
Thread c = new Thread(delegate ()
{
Console.WriteLine("3. eine anonyme Methode");
});
Thread d = new Thread(() => Console.WriteLine("4. ein Lambda"));
foreach (Thread t in new Thread[] { a, b, c, d })
{
t.Start();
t.Join();
}
}
Ausgabe
1. eine statische Methode über ihren Namen
2. eine Methode eines Objekts
3. eine anonyme Methode
4. ein Lambda
In C# kann Thread keine Unterklasse haben: Die Klasse ist sealed (versiegelt). Anonyme Klassen gibt es auch nicht; ihr altes Gegenstück ist die anonyme Methode delegate () { ... }. Die Aufgabe ist also immer vom Thread getrennt: ein Delegate, das auch an einen Thread-Pool gehen kann. Braucht der Thread ein Argument, gibt es noch ParameterizedThreadStart: new Thread(file => ...) und Start("maerz.pdf"). Das Argument ist dort ein object, deshalb ist ein Lambda, das eine Variable einfängt, praktischer.

Sieh es dir an

Drei auf einmal

Jetzt die drei Berichte, jeder in seinem eigenen Thread:
long start = Environment.TickCount64;
Thread march = new Thread(() => Download("maerz.pdf", 600));
Thread april = new Thread(() => Download("april.pdf", 200));
Thread may = new Thread(() => Download("mai.pdf", 400));
march.Start();
april.Start();
may.Start();
march.Join();
april.Join();
may.Join();
long ms = Environment.TickCount64 - start;
Console.WriteLine($"Fertig in unter einer Sekunde: {ms < 1000}");
Ausgabe
Heruntergeladen: april.pdf
Heruntergeladen: mai.pdf
Heruntergeladen: maerz.pdf
Fertig in unter einer Sekunde: True

Die Threads starten in der Reihenfolge März, April, Mai, werden aber in der Reihenfolge der Pausen fertig: April (0,2 s), Mai (0,4 s), März (0,6 s). Das ganze Warten dauert so lange wie das längste, 0,6 Sekunden statt 1,2.

In welcher Reihenfolge die Threads bei WriteLine ankommen, entscheidet das Betriebssystem. Hier sind die Pausen unterschiedlich genug, also ist die Reihenfolge immer gleich. Wären sie gleich lang, könnte sie bei jedem Lauf anders sein.Environment.TickCount64 ist die Zahl der Millisekunden, seit der Computer gestartet wurde: Die Differenz zweier Messungen zeigt, wie viel Zeit vergangen ist.

Probier es aus

Start oder ein Aufruf

Was gibt das Programm aus? (Name ist der Name des Threads)

Thread.CurrentThread.Name = "Main";
ThreadStart job = () => Console.WriteLine($"Läuft in: {Thread.CurrentThread.Name}");
Thread t = new Thread(job);
t.Name = "downloader";
job();
t.Start();
t.Join();
Antwort

Die Idee

Das Leben eines Threads

Ein Thread durchläuft mehrere Zustände, und ThreadState sagt dir, in welchem er ist:
Thread t = new Thread(() => Download("mai.pdf", 500));
Console.WriteLine(t.ThreadState);
t.Start();
Thread.Sleep(100);
Console.WriteLine(t.ThreadState);
t.Join();
Console.WriteLine(t.ThreadState);
try
{
t.Start();
}
catch (ThreadStateException)
{
Console.WriteLine("Zweiter Start: ThreadStateException");
}
Ausgabe
Unstarted
WaitSleepJoin
Heruntergeladen: mai.pdf
Stopped
Zweiter Start: ThreadStateException
Zustand Wann
Unstarted erzeugt, noch nicht mit Start() gestartet
Running läuft oder ist bereit zu laufen
WaitSleepJoin wartet: auf einen Lock, auf einen anderen Thread (Join) oder eine bestimmte Zeit (Sleep)
Stopped seine Methode ist fertig

Ein Thread kann nur einmal gestartet werden. Ein zweites Start() auf demselben Thread wirft ThreadStateException. Die kürzere Antwort, nur „läuft er?“, gibt IsAlive.

Genauer, für Neugierige

Mehr über Thread:

  • Interrupt() bittet den Thread, aufzuhören. Schläft oder wartet er, wirft Sleep/Join eine ThreadInterruptedException. In C# ist sie nicht geprüft: Fängt der Thread sie nicht, bricht das Programm mit einem Fehler ab. Einen Thread von außen mit Abort() anzuhalten, funktioniert im heutigen .NET nicht.
  • Name, ManagedThreadId: Name und Nummer des Threads. Thread.CurrentThread gibt dir den Thread, in dem der Code gerade läuft.
  • Thread.Yield() deutet an, dass der Thread den Prozessor einem anderen überlassen kann. Das brauchst du selten.

Problem

Die verlorenen Besuche

Die Website zählt ihre Besuche. Zwei Threads bedienen Besucher, und jeder ruft 100 000-mal Increment() auf:
Program.cs
class Counter
{
private int count = 0;
public void Increment()
{
count++;
}
public int Count => count;
}
static void Main()
{
Counter visits = new Counter();
ThreadStart work = () =>
{
for (int i = 0; i < 100_000; i++)
{
visits.Increment();
}
};
Thread first = new Thread(work);
Thread second = new Thread(work);
first.Start();
second.Start();
first.Join();
second.Join();
Console.WriteLine($"Besuche: {visits.Count}");
}

Du erwartest 200 000. Fünf Läufe:

Ausgabe (jedes Mal anders)
Besuche: 146528
Besuche: 120201
Besuche: 178089
Besuche: 152070
Besuche: 153205

Jedes Mal weniger, und jedes Mal anders. Der Grund ist, dass count++ kein einzelner Schritt ist, sondern drei:

int value = count; // 1. lesen
value = value + 1; // 2. 1 addieren
count = value; // 3. schreiben

class Counter
{
private int count = 5;
private readonly object sync = new object();
public void Increment()
{
count++; // lesen, 1 addieren, schreiben
}
public void SafeIncrement()
{
lock (sync)
{
count++;
}
}
public int Count => count;
}
Schritt für Schritt, in Worten
  1. count ist 5. Die Threads A und B rufen gleichzeitig Increment() auf.
  2. A liest count: 5.
  3. B liest count, bevor A geschrieben hat: auch 5.
  4. A addiert 1 in seiner eigenen Kopie und schreibt 6.
  5. B schreibt auch 6. Zwei Aufrufe, und count ist 6, nicht 7: Eine der Additionen geht verloren.
  6. Noch einmal, jetzt mit lock: A nimmt den Lock, liest 5 und schreibt 6.
  7. B erreicht lock (sync) und wartet, bis A den Lock freigibt.
  8. B nimmt den Lock, liest 6 und schreibt 7: Nichts geht verloren.

Wenn beide Threads denselben Wert lesen, bevor einer von ihnen geschrieben hat, geht eine der Additionen verloren. Das nennt man eine Race Condition (Wettlaufsituation). Ob sie eintritt, hängt davon ab, wann das Betriebssystem die Threads wechselt, deshalb ist das Ergebnis bei jedem Lauf anders.

Die Idee

lock: einer nach dem anderen

Die Lösung ist, dass immer nur ein Thread diese drei Schritte macht. Der Block lock tut genau das:
class Counter
{
private int count = 0;
private readonly object sync = new object();
public void Increment()
{
lock (sync)
{
count++;
}
}
public int Count
{
get
{
lock (sync)
{
return count;
}
}
}
}
Ausgabe
Besuche: 200000

sync ist ein gewöhnliches Objekt, das nur als Schlüssel dient: Jedes Objekt in C# kann ein Lock sein. Der Thread nimmt ihn, wenn er lock (sync) betritt, und gibt ihn frei, wenn er den Block verlässt, auch bei einer Exception. Der andere Thread, der denselben Schlüssel will, wartet am Eingang. Jetzt ist das Ergebnis bei jedem Lauf 200 000. Auch das Lesen von Count steht in einem lock: So sieht der Thread, der liest, den zuletzt geschriebenen Wert.

Der Schlüssel ist ein privates Feld, nicht this: So kann kein Code von außen dasselbe Objekt sperren und dich warten lassen. Ein Wort synchronized vor der Methode wie in Java gibt es in C# nicht. Am nächsten kommt das Attribut [MethodImpl(MethodImplOptions.Synchronized)], aber es sperrt das ganze Objekt (this), deshalb wird es nicht empfohlen:

using System.Runtime.CompilerServices;
class Counter
{
private int count = 0;
[MethodImpl(MethodImplOptions.Synchronized)]
public void Increment()
{
count++;
}
public int Count => count;
}

Für einen Zähler gibt es auch eine fertige Klasse: Interlocked. Interlocked.Increment(ref count) macht die drei Schritte auf einmal, ohne Lock. ref übergibt das Feld selbst, nicht eine Kopie seines Werts:

class Counter
{
private int count = 0;
public void Increment()
{
Interlocked.Increment(ref count);
}
public int Count => count;
}
Ausgabe
Besuche: 200000

Hier liest Count ohne Schutz, weil Main es erst nach Join liest. Join garantiert, dass es alles sieht, was der Thread geschrieben hat.

Genauer, für Neugierige
  • Monitor.Wait und Monitor.PulseAll: Ein Thread in einem lock-Block kann den Lock freigeben und schlafen, bis ein anderer Thread ihn weckt. So wartet die Küche auf eine Bestellung, und die Kasse wartet, bis die Küche die vorige genommen hat (das Problem „Producer und Consumer“):
class OrderBox
{
private string order;
private readonly object sync = new object();
public void Put(string newOrder)
{
lock (sync)
{
while (order != null)
{
Monitor.Wait(sync);
}
order = newOrder;
Monitor.PulseAll(sync);
}
}
public string Take()
{
lock (sync)
{
while (order == null)
{
Monitor.Wait(sync);
}
string result = order;
order = null;
Monitor.PulseAll(sync);
return result;
}
}
}
static void Main()
{
OrderBox box = new OrderBox();
Thread kitchen = new Thread(() =>
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"Die Küche nimmt {box.Take()}");
}
});
kitchen.Start();
box.Put("Pizza");
box.Put("Salat");
box.Put("Suppe");
kitchen.Join();
}
Ausgabe
Die Küche nimmt Pizza
Die Küche nimmt Salat
Die Küche nimmt Suppe

Monitor.Wait steht in einem while, nicht in einem if: Der Thread kann auch ohne Grund aufwachen, deshalb prüft er die Bedingung noch einmal. Dasselbe macht BlockingCollection<string> mit boundedCapacity: 1 fertig mit: Add wartet, wenn sie voll ist, und Take wartet, wenn sie leer ist.

  • Deadlock (Verklemmung): Zwei Threads warten für immer aufeinander. Das Büro hält den Drucker und wartet auf den Scanner, und der Empfang hält den Scanner und wartet auf den Drucker:
object printer = new object();
object scanner = new object();
Thread office = new Thread(() =>
{
lock (printer)
{
Thread.Sleep(100);
lock (scanner) { Console.WriteLine("Das Büro kopiert"); }
}
});
Thread reception = new Thread(() =>
{
lock (scanner)
{
Thread.Sleep(100);
lock (printer) { Console.WriteLine("Der Empfang kopiert"); }
}
});

Das Programm hängt und gibt nie etwas aus. Vermeiden lässt sich das, indem alle Threads die Locks in derselben Reihenfolge nehmen.

  • Prioritäten und Hintergrund-Threads: Priority deutet an, wie wichtig der Thread ist, aber das Betriebssystem kann es ignorieren, also verlass dich nicht darauf, damit das Programm richtig ist. IsBackground = true macht den Thread zu einem Hintergrund-Thread: Das Programm endet, ohne auf ihn zu warten:
Thread report = new Thread(() => Download("jahresbericht.pdf", 100));
report.Priority = ThreadPriority.Lowest; // Lowest, BelowNormal, Normal, AboveNormal, Highest
report.IsBackground = true; // auf Hintergrund-Threads wartet das Programm nicht
Console.WriteLine($"{report.Priority}, Hintergrund: {report.IsBackground}");
Ausgabe
Lowest, Hintergrund: True
  • Fertige Collections für viele Threads liegen in System.Collections.Concurrent: ConcurrentDictionary, ConcurrentQueue, BlockingCollection. Ihre Methoden machen die Prüfung und die Änderung auf einmal, ohne dein eigenes lock.

Probier es aus

Zwei Geldautomaten

Zwei Personen heben im selben Moment je 80 € von einem Konto mit 100 € ab. Manchmal bekommen beide das Geld, und der Kontostand wird -60 €. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Thread-Pool und Task

Für jede Aufgabe einen Thread zu erzeugen ist teuer, und bei tausend Aufgaben auch gefährlich. Ein Thread-Pool besteht aus ein paar Threads, die sich Aufgaben aus einer Warteschlange nehmen. In .NET gibt es einen für das ganze Programm, ThreadPool, und er entscheidet selbst, wie viele Threads er hat. Task.Run (aus System.Threading.Tasks) gibt ihm eine Aufgabe:
static int SizeOf(string file, int ms)
{
Thread.Sleep(ms);
return file.Length * 100;
}
string[] files = { "maerz.pdf", "april.pdf", "mai.pdf", "juni.pdf" };
List<Task<int>> sizes = new List<Task<int>>();
foreach (string file in files)
{
sizes.Add(Task.Run(() => SizeOf(file, 300)));
}
for (int i = 0; i < files.Length; i++)
{
Console.WriteLine($"{files[i]}: {sizes[i].Result} KB");
}
Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
  • Task.Run nimmt eine Aufgabe, die ein Ergebnis zurückgibt, gibt sie dem Pool und gibt sofort einen Task<int> zurück: das Versprechen, dass das Ergebnis kommt (in Java ist das ein Future).
  • .Result wartet, bis der Task fertig ist, und gibt das Ergebnis zurück. Hat der Task eine Exception geworfen, wirft .Result sie, verpackt in eine AggregateException.
  • Der Pool wird am Ende nicht beendet: Seine Threads sind Hintergrund-Threads, also endet das Programm, wenn Main endet. Ein Task, der dann noch läuft, wird einfach abgeschnitten.

Wie viele Tasks auf einmal laufen, entscheidet der Pool. Willst du sie begrenzen, wie bei einem Pool aus 2 Threads, führt Parallel.For den Rumpf für jedes i im Pool aus, höchstens MaxDegreeOfParallelism auf einmal, und wartet auf alle:

string[] files = { "maerz.pdf", "april.pdf", "mai.pdf", "juni.pdf" };
int[] sizes = new int[files.Length];
ParallelOptions options = new ParallelOptions();
options.MaxDegreeOfParallelism = 2;
Parallel.For(0, files.Length, options, i =>
{
sizes[i] = SizeOf(files[i], 300);
});
for (int i = 0; i < files.Length; i++)
{
Console.WriteLine($"{files[i]}: {sizes[i]} KB");
}
Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB

.Result wartet, indem es den Thread beschäftigt hält. Im Modul „Web und Desktop“ siehst du await, das auf denselben Task wartet, ohne einen Thread zu belegen.

Genauer, für Neugierige
Gibt die Aufgabe nichts zurück, gibt dir Task.Run einen Task ohne Typ. Dann wartest du mit Task.WaitAll auf alle:
List<Task> jobs = new List<Task>();
for (int i = 1; i <= 5; i++)
{
string file = $"bericht-{i}.pdf";
jobs.Add(Task.Run(() => Download(file, 100)));
}
Task.WaitAll(jobs.ToArray());
Console.WriteLine("Alles fertig.");
Ausgabe eines Laufs
Heruntergeladen: bericht-2.pdf
Heruntergeladen: bericht-1.pdf
Heruntergeladen: bericht-4.pdf
Heruntergeladen: bericht-5.pdf
Heruntergeladen: bericht-3.pdf
Alles fertig.
Die fünf Berichte laufen gleichzeitig (der Pool hat genug Threads), deshalb ändert sich ihre Reihenfolge von Lauf zu Lauf. Der ältere Weg ist ThreadPool.QueueUserWorkItem, aber er gibt dir keinen Task, auf den du warten kannst.

Probier es aus

Welches Ergebnis kommt zuerst?

„archiv.zip“ braucht 0,6 s zum Herunterladen, „notiz.txt“ 0,1 s. Was gibt das Programm aus?

Task<int> big = Task.Run(() => SizeOf("archiv.zip", 600));
Task<int> small = Task.Run(() => SizeOf("notiz.txt", 100));
Console.WriteLine($"Groß: {big.Result}");
Console.WriteLine($"Klein: {small.Result}");
Antwort

Übung

Programme mit Threads

Löse die Aufgaben in deinem Editor.

1. Parallele Summe. Das Array sales enthält 1 000 000 Verkäufe, sales[i] = i % 100. Teile es in 4 gleiche Teile und summiere jeden in seinem eigenen Thread. Jeder Thread schreibt an seinen eigenen Platz im Array long[] partial, deshalb brauchst du keine Synchronisierung. Ein Lambda in einem for fängt die Schleifenvariable selbst ein, nicht ihren Wert (die Lektion „Lambdas“): Kopiere die Nummer des Teils vor dem Lambda in eine neue Variable. Warte mit Join auf sie und addiere die Teile:

Ausgabe
Teile: [12375000, 12375000, 12375000, 12375000]
Gesamt: 49500000

2. Herunterladen mit einem Pool. Lade sechs Berichte (maerz.pdf, april.pdf, mai.pdf, juni.pdf, juli.pdf, august.pdf) mit Parallel.For, je 3 auf einmal herunter. Benutze SizeOf(file, 300) aus der Karte zum Pool. Gib die Größen in der Reihenfolge der Dateien aus, die Summe und ob alles in unter 1,5 Sekunden fertig war (nacheinander wären es 1,8 s):

Ausgabe
maerz.pdf: 900 KB
april.pdf: 900 KB
mai.pdf: 700 KB
juni.pdf: 800 KB
juli.pdf: 800 KB
august.pdf: 1000 KB
Gesamt: 5100 KB
Unter 1,5 Sekunden: True

3. Ein Zähler ohne Verluste. Vier Threads mit je 50 000 Besuchen erhöhen einen gemeinsamen Zähler. Schreib den Zähler so, dass das Ergebnis immer 200 000 ist. Probier es zuerst ohne Schutz und sieh, wie viel verloren geht, dann korrigiere es einmal mit lock und einmal mit Interlocked.

Ausgabe
Besuche: 200000

Weitere Aufgaben

  • Ein Test für den Zähler. Schreib einen xUnit-Test (die Lektion „Tests mit xUnit“), der 8 Threads mit je 100 000 Erhöhungen startet und mit Assert.Equal auf 800 000 prüft. Lass ihn ein paarmal gegen den ungeschützten Zähler laufen, dann gegen den Zähler mit lock. Ein Test, der nur manchmal fehlschlägt, ist das typische Zeichen einer Race Condition. Mit 10 000 pro Thread ist in .NET ein Thread oft fertig, bevor der nächste gestartet ist, und der Test läuft durch: Die Race Condition ist da, aber du siehst sie nicht.
  • Der Deadlock. Führe das Beispiel mit dem Drucker und dem Scanner aus „Genauer“ aus (mit Start und Join) und sieh zu, wie es hängt. Korrigiere es so, dass beide Threads zuerst den Drucker nehmen.

Test

Teste dich

1. Was ist der Unterschied zwischen t.Start() und job(), wenn job das Delegate des Threads ist?
2. Warum ruft Main Join() auf, bevor es das Ergebnis ausgibt?
3. Zwei Threads führen auf einem gemeinsamen Feld ohne Synchronisierung je 100 000-mal count++ aus. Welchen Wert hat count am Ende?
4. Was garantiert lock (sync) { ... }?
5. Du startest drei Aufgaben mit Task.Run und behältst für jede einen Task<int>. Was macht task.Result?

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