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
Thread.Sleep(ms) hält das Programm für so viele Millisekunden an. Thread liegt in System.Threading, das standardmäßig eingebunden ist.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");}Heruntergeladen: maerz.pdfHeruntergeladen: april.pdfHeruntergeladen: mai.pdfFertigDie 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");Main wartet nichtHeruntergeladen: april.pdfDer Bericht ist heruntergeladenStart()startet den neuen Thread und kehrt sofort zurück.Mainmacht weiter, ohne zu warten, deshalb steht „Main wartet nicht“ zuerst da.Join()lässtMainwarten, 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
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(); }}1. eine statische Methode über ihren Namen2. eine Methode eines Objekts3. eine anonyme Methode4. ein LambdaThread 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
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}");Heruntergeladen: april.pdfHeruntergeladen: mai.pdfHeruntergeladen: maerz.pdfFertig in unter einer Sekunde: TrueDie 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 beiWriteLine 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();job() ist ein gewöhnlicher Aufruf des Delegates: Das Lambda läuft sofort, im Thread von Main. Einen neuen Thread gibt es nicht. Erst t.Start() startet den Thread „downloader“, der dasselbe Lambda ausführt. Der Hauptthread hat in .NET keinen Namen, deshalb gibt ihm die erste Zeile den Namen „Main“. Eine Methode Run(), die man mit Start() verwechseln könnte, hat Thread in C# nicht, aber derselbe Fehler ist, die Methode selbst aufzurufen, statt sie einem Thread zu geben: Das Programm funktioniert, nur läuft nichts parallel.Die Idee
Das Leben eines Threads
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");}UnstartedWaitSleepJoinHeruntergeladen: mai.pdfStoppedZweiter 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, wirftSleep/JoineineThreadInterruptedException. 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 mitAbort()anzuhalten, funktioniert im heutigen .NET nicht.Name,ManagedThreadId: Name und Nummer des Threads.Thread.CurrentThreadgibt 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
Increment() auf: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:
Besuche: 146528Besuche: 120201Besuche: 178089Besuche: 152070Besuche: 153205Jedes Mal weniger, und jedes Mal anders. Der Grund ist, dass count++ kein einzelner Schritt ist, sondern drei:
int value = count; // 1. lesenvalue = value + 1; // 2. 1 addierencount = value; // 3. schreibenclass 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
- count ist 5. Die Threads A und B rufen gleichzeitig Increment() auf.
- A liest count: 5.
- B liest count, bevor A geschrieben hat: auch 5.
- A addiert 1 in seiner eigenen Kopie und schreibt 6.
- B schreibt auch 6. Zwei Aufrufe, und count ist 6, nicht 7: Eine der Additionen geht verloren.
- Noch einmal, jetzt mit lock: A nimmt den Lock, liest 5 und schreibt 6.
- B erreicht lock (sync) und wartet, bis A den Lock freigibt.
- 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
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; } } }}Besuche: 200000sync 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;}Besuche: 200000Hier 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.WaitundMonitor.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();}Die Küche nimmt PizzaDie Küche nimmt SalatDie Küche nimmt SuppeMonitor.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:
Prioritydeutet an, wie wichtig der Thread ist, aber das Betriebssystem kann es ignorieren, also verlass dich nicht darauf, damit das Programm richtig ist.IsBackground = truemacht 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, Highestreport.IsBackground = true; // auf Hintergrund-Threads wartet das Programm nichtConsole.WriteLine($"{report.Priority}, Hintergrund: {report.IsBackground}");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 eigeneslock.
Probier es aus
Zwei Geldautomaten
Wähle die Zeile mit dem Fehler.
Die Prüfung balance >= amount und die Abhebung sind kein Ganzes. Die zwei Threads prüfen gleichzeitig, beide sehen 100, und beide heben ab. In diesem kleinen Beispiel passiert das selten, und genau das macht den Fehler tückisch: Er läuft tausendmal durch und schlägt dann beim Kunden zu. Die Korrektur ist ein lock um die Prüfung und die Abhebung (und um das Lesen von Balance):
class Account{ private int balance = 100; private readonly object sync = new object();
public void Withdraw(int amount) { lock (sync) { if (balance >= amount) { balance -= amount; } } }
public int Balance { get { lock (sync) { return balance; } } }}Dann wartet der zweite Geldautomat auf den ersten, sieht 20 € und lehnt ab. Der Kontostand bleibt 20 €.
Die Idee
Thread-Pool und Task
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");}maerz.pdf: 900 KBapril.pdf: 900 KBmai.pdf: 700 KBjuni.pdf: 800 KBTask.Runnimmt eine Aufgabe, die ein Ergebnis zurückgibt, gibt sie dem Pool und gibt sofort einenTask<int>zurück: das Versprechen, dass das Ergebnis kommt (in Java ist das einFuture)..Resultwartet, bis der Task fertig ist, und gibt das Ergebnis zurück. Hat der Task eine Exception geworfen, wirft.Resultsie, verpackt in eineAggregateException.- Der Pool wird am Ende nicht beendet: Seine Threads sind Hintergrund-Threads, also endet das Programm, wenn
Mainendet. 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");}maerz.pdf: 900 KBapril.pdf: 900 KBmai.pdf: 700 KBjuni.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
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.");Heruntergeladen: bericht-2.pdfHeruntergeladen: bericht-1.pdfHeruntergeladen: bericht-4.pdfHeruntergeladen: bericht-5.pdfHeruntergeladen: bericht-3.pdfAlles fertig.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}");Die kleine Datei ist wirklich zuerst fertig. Aber big.Result steht in der ersten Zeile und wartet auf die große. Wenn die da ist, gibt small.Result sofort zurück, weil das Ergebnis schon da ist. Die Reihenfolge der Ausgabe ist die Reihenfolge der .Result-Zugriffe, deshalb ist sie bei jedem Lauf gleich.
Ü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:
Teile: [12375000, 12375000, 12375000, 12375000]Gesamt: 495000002. 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):
maerz.pdf: 900 KBapril.pdf: 900 KBmai.pdf: 700 KBjuni.pdf: 800 KBjuli.pdf: 800 KBaugust.pdf: 1000 KBGesamt: 5100 KBUnter 1,5 Sekunden: True3. 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.
Besuche: 200000Weitere 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.Equalauf 800 000 prüft. Lass ihn ein paarmal gegen den ungeschützten Zähler laufen, dann gegen den Zähler mitlock. 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
StartundJoin) und sieh zu, wie es hängt. Korrigiere es so, dass beide Threads zuerst den Drucker nehmen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: try, catch und finally · Listen · Lambdas und Delegates · Tests mit xUnit
- Geht weiter in: Netzwerkprogrammierung · async und await · WebAPI: Daten für andere Programme · Quicksort und der schlechte Pivot
- Aufgaben zum Thema: Programme in der echten Welt