Multithreading in C# (.NET)
Datum: Juni 2022
Lesedauer: 11 Minuten
In diesem Blogpost beschreibe ich das Konzept des Multitasking. Nach der Erklärung des Multithreading gehe ich auf die Thread- und TPL Klassen von C# ein.
Prozess
Vorerst muss man verstehen, was ein Prozess ist.
Ein Prozess ist nicht weiteres als ein Ablauf eines Programms. Ein Programm erhält in der Regel eine Eingabe, führt eine Berechnung durch und liefert
eine Ausgabe. Programme laufen immer auf Betriebssystemen und sind unter Windows im Taskmanager ersichtlich.
Thread
Ein Prozess besteht jeweils aus mindestens einem Thread.
Ein Thread ist die ausführbare Einheit eines Prozesses. Threads arbeiten, unabhängig von anderen Prozessteilen, eine Aufgabe ab.
Threads sind priorisierbar, werden gescheduled und können scheinbar parallel ausgeführt werden.
Taskmanager
Im Taskmanager kann man Informationen über Prozesse, Threads, Prozessoren, Cores und weiteres einsehen.
Core: Die Cores sind die Hirne des Computers. Sie können Programme ausführen. Die meisten Laptops heutzutage haben vier, sechs oder acht Cores.
Multitasking
Multitasking bedeutet, dass mehrere Prozesse gleichzeitig abgearbeitet werden.
Dies entspricht aber nicht der Wahrheit, da ein Prozessor nur ein Programm auf einmal verarbeiten kann. Die CPU schaltet nämlich zwischen den Programmen hin und her, damit es so wirkt, als würden die Prozesse gleichzeitig abgearbeitet werden. Dabei wird von einem Mechanismus Gebrauch gemacht, der die Zeit zuteilt.
CPU: Ist ein primärer Bestandteil eines jeden Computers. Die CPU agiert als Kontrollzentrum.
Präemptives Multitasking
Diese Art des Multitaskings wird von neueren Betriebssystemen verwendet. Es basiert auf Prioritäten. Hierbei hat das Betriebssystem die volle Kontrolle über die Freigabe der Ressourcen. Das Kernel teilt jedem Anwendungsprogramm abwechselnd einen kurzen Zeitabschnitt zu.
Kernel: Das Kernel ist die Schnittstelle zwischen der Soft- und Hardware. Es überwacht Prozesse.
Kooperatives Multitasking
Hierbei handelt sich um eine Art, bei welcher die Prozesse die Ressourcen selbst steuern. Diese Art wurde abgelöst, da bösartige Software dieses Prinzip missbrauchen konnten.
Was ist Multithreading?
Stellen Sie sich vor, dass sie eine Bewerbung verfassen und diese per Mail versenden. Da eine solche E-Mail einige Anhänge hat, kann das Senden auch
eine Weile dauern.
Multithreading ermöglicht es, dass man während des Versendens nicht warten muss. So kann man direkt mit der Applikation weiterarbeiten, während im
Hintergrund "gleichzeitig" die Mail versendet wird.
Beim Multithreading werden die Prozesse in kleinere Stücke unterteilt. Diese Teilprozesse, sogenannte Threads, sorgen durch die "parallele" Ausführung
für eine effizientere Verarbeitung.
Wie bereits beschrieben, wirkt die Ausführung lediglich, durch das Multitasking, parallel.
Durch dieses Konzept kann die Verarbeitungsgeschwindigkeit gesteigert werden. Es ist aber Vorsicht geboten. Denn Threads können einander beeinflussen.
Viele der IT-Konzepte stammen aus dem realen Leben. Aus diesem Grund kann man auch Threads anhand eines einfachen Beispiels verständlich darstellen.
Die Schule, die Arbeit bei Merkle und auch die Freizeit könnte man als je einen Prozess ansehen.
Der Hauptthread in der Schule ist der Unterricht. Dabei arbeitete man einzelne Tasks (Schulfächer), der Reihe nach ab.
Ein Nebenthread ist das Lernen. Dies gehört auch zur Schule, ist aber vom Hauptthread getrennt. Beim Lernen gibt es wieder einzelne Tasks. In diesem
Fall sind dies Lernblöcke für anstehende Prüfungen. Diese Tasks werden priorisiert. Für Prüfungen, welche näher in der Zukunft liegen oder einem etwas
schwerer fallen, sollte man früher mit dem Lernen beginnen. Deshalb sind diese weiter vorne in der Queue (Warteschlange).
Beim Lernen arbeitet man asynchron. Denn man beginnt oft auf Physik zu lernen, bevor man PHP komplett abgeschlossen hat.
Lernt man früh genug, so kann man das Wissen im Langzeitgedächtnis (Harddrive) speichern. Beginnt man erst kurz davor, so sind die Dinge oft nur im
flüchtigen Kurzzeitgedächtnis (Memory).
Threads können auch unter sich etwas teilen. Denn was ich mir zu Hause Wissen aneigne, kann ich dieses auch in anderen Threads wieder verwenden.
Deadlocks
Zwei Köche in einer Küche erhalten den Auftrag, eine Speise zu kochen. Dafür benötigen sie eine Pfanne und eine Flasche Öl. Beide Köche eilen los, um
die Ressourcen zu holen.
Koch 1 konnte dabei die Pfanne holen. Koch 2 hält das Öl. Da nun jeder Koch eine Ressource hat, befinden sie sich in einer verzwickten Lage. Keiner der
Köche will seine Pfanne oder sein Öl abgeben. Sie benötigen aber auch beide die Ressource des anderen, um die Speise zuzubereiten.
Dies resultiert in einem Deadlock.
Deadlocks beschreibt also die Situation, in welcher Prozesse geblockt werden. Dabei halten beide Prozesse eine Ressource, welche sie nicht für andere freigeben wollen, während sie gleichzeitig auf eine andere Ressource (welche von einem anderen Prozess benötigt wird) warten. Die Situation kann aufgelöst werden, in dem bestimmt, wie das Programm vorzugehen hat.
Thread Klasse
.NET bot schon seit Beginn an einen Weg, multithreading-fähige Anwendungen zu entwickeln. Dazu stellten sie die Thread-Klasse zur Verfügung.
Jeder Thread kann sich in einem von drei möglichen Zuständen befinden:
- wartend (waiting)
- bereit (ready)
- laufend (running)
Starten
ThreadStart del = new ThreadStart(TestMethod);
Thread thread = new Thread(del);
thread.Start();
Ablauf
class Program {
static void Main(string[] args) {
ThreadStart del;
del = new ThreadStart(TestMethod);
Thread thread = new Thread(del);
// zweiten Thread starten
thread.Start();
for(int i = 0; i <= 100; i++) {
for(int k = 1; k <= 20; k++) {
Console.Write(".");
}
Console.WriteLine("Primär-Thread " + i);
}
Console.ReadLine();
}
// Methode wird in eigenem Thread ausgeführt
public static void TestMethod() {
for(int i = 0; i <= 100; i++) {
for(int k = 1; k <= 20; k++) {
Console.Write("X");
}
Console.WriteLine("Sekundär-Thread " + i);
}
}
}
Wie man in der Ausgabe erkennen kann, werden die Threads abwechselnd ausgeführt.
Beenden
Threads können auch wieder geschlossen werden.
thread.Abort();
Abhängigkeiten
Es kann vorkommen, dass Threads voneinander abhängig sind. Join
kann den aufgerufenen Thread blockieren, bis der Sekundärthread vollständig
terminiert ist.
Prioritäten
Wie schon erwähnt, kann man den Threads Prioritäten zuweisen.
class Program {
static void Main(string[] args) {
Demo obj = new Demo();
Thread thread1, thread2;
thread1 = new Thread(new ThreadStart(obj.Execution1));
thread2 = new Thread(new ThreadStart(obj.Execution2));
thread1.Priority = ThreadPriority.AboveNormal;
thread1.Start();
thread2.Start();
Console.ReadLine();
}
}
class Demo {
public void Execution1() {
for (int i = 0; i <= 500; i++) {
Console.Write(".");
}
}
public void Execution2() {
for (int number = 0; number <= 10; number++) {
Console.WriteLine("It's me, Thread2");
}
}
}
In diesem Beispiel wird die Priorität in Zeile neun auf AboveNormal
gesetzt.
Threadpools
Threadpools vereinfachen die Arbeit mit Threads. Hierbei wird beim Start eine bestimmte Anzahl von Threads erzeugt. Diese können dann genutzt
werden. Der Vorteil dabei ist, dass man keine eigenen Threads erzeugen muss.
Wird eine Threadmethode beendet, so wird der freie Thread in den Pool zurückgeführt uns steht somit für andere Aufgaben zur freien Verfügung.
class Program {
static void Main(string[] args) {
// den Threadpool erforschen
int maxThreads;
int asyncThreads;
ThreadPool.GetMaxThreads(out maxThreads, out asyncThreads);
Console.WriteLine("Max. Anzahl Threads: {0}", maxThreads);
Console.WriteLine("Max. Anzahl E/A-Threads: {0}", asyncThreads);
Console.WriteLine(new string('-', 40));
// Benachrichtigungsereignis, Zustand 'nicht signalisieren'
AutoResetEvent ready = new AutoResetEvent(false);
// Anfordern eines Threads aus dem Pool
ThreadPool.QueueUserWorkItem(new WaitCallback(Calculate), ready);
Console.WriteLine("Der Hauptthread wartet ...");
// Hauptthread in den Wartezustand setzen
ready.WaitOne();
Console.WriteLine("Sekundärthread ist fertig.");
Console.ReadLine();
}
public static void Calculate(object obj) {
Console.WriteLine("Im Sekundärthread");
Thread.Sleep(5000);
// Ereigniszustand auf 'signalisieren' festlegen
((AutoResetEvent)obj).Set();
}
}
Synchronisation
Es könnte zu einem ungültigen Zustand kommen, wenn das System einem Thread mitten in der Ausführung die Zeit entzieht und ein andrer Thread am
selben Objekt arbeitet.
Diese Applikation zeigt das Problem auf.
class Program {
static void Main(string[] args) {
Demo obj = new Demo();
Thread thread1, thread2;
thread1 = new Thread(new ThreadStart(obj.Worker));
thread2 = new Thread(new ThreadStart(obj.Worker));
thread1.Start();
thread2.Start();
Console.ReadLine();
}
}
class Demo {
private int value;
public void Worker() {
while(true) {
value++;
if (value > 100) break;
Console.WriteLine(value);
}
}
}
85
63
87
88
89
90
91
92
93
94
95
96
86
98
99
100
97
Threadsicherheit ermöglicht, dass mehrere Threads gleichzeitig dieselbe Methode aufrufen können, ohne dass Konflikte entstehen.
Dazu kann der Monitor verwendet werden. Dieser verhindert mit, dass mehrere Threads gleichzeitig einen bestimmten Teil im Programm durchlaufen. Die
Klasse bietet eine Enter
und Exit
Methode, mit welchen die kritischen Abschnitte definiert werden können.
private int value;
public void Worker() {
while(true) {
Monitor.Enter(this); // Sperre setzen
value++;
if (value > 100) break;
Console.WriteLine("Zahl = {0,5} Thread = {1,3}", value,
Thread.CurrentThread.GetHashCode().ToString());
Thread.Sleep(5);
Monitor.Exit(this); // Sperre aufheben
}
}
Um weitere spannende Dinge über dieses Thema zu erfahren, kann ich das Buch https://openbook.rheinwerk-verlag.de/visual_csharp_2012/1997_15_002.html#dodtp4d7cb142-5b23-44cf-8fa5-93084ae61382 (opens in a new tab) empfehlen. Es befasst sich auch noch mit Lock
, Wait & Pulse
und Mutext
.
Task Parallel Library (TPL)
Die Arbeit mit der komplexen Thread-Klasse kann einem teilweise etwas schwerfallen. Deshalb gibt es eine abstraktere Variante.
Die TPL ermöglicht, dass sich Entwickler nicht mehr um jeden einzelnen Thread kümmern müssen. Der Task liegt im Fokus. Threads werden dann zur
Laufzeit automatisch erstellt.
Zu den Vorteilen zählt auch, dass so die Nutzung von Multiprozessoren ermöglicht wird. Ein Computer kann mehrere Prozesse aufteilen.
Dabei gilt darauf zu achten, dass sich die Leistungskurve nicht proportional zur Anzahl der Prozessoren verhält. Die Rechenleistung ist auch abhängig von
der Intelligenz des Betriebssystems. Unter Umständen kann so die Applikation sogar langsamer werden, da die Verteilung über mehrere Prozessorkerne
mit Aufwand verbunden ist. Es gilt also ein Abwägen. Denn je mehr Prozessorkerne genutzt werden können, desto zeitintensiver sollten die Operationen
sein.
Man findet die Klasse unter folgendem Namespace:
System.Threading.Tasks (& System.Collections.Concurrent)
Parallel
Drei simple Methoden werden für die Parallelisierung von Code und Schleifen zur Verfügung gestellt.
Invoke
Invoke
blockiert den Programmablauf und definiert, welche Methoden parallel abgearbeitet werden sollen.
class Program {
static void Main(string[] args) {
Parallel.Invoke(Task1, Task2, Task3);
Console.ReadLine();
}
static void Task1() {
for (int i = 0; i < 10; i++) {
Thread.Sleep(50);
Console.Write(" #1 ");
}
}
static void Task2() {
for (int i = 0; i < 10; i++) {
Thread.Sleep(50);
Console.Write(" #2 ");
}
}
static void Task3() {
for (int i = 0; i < 10; i++) {
Thread.Sleep(50);
Console.Write(" #3 ");
}
}
}
For
Bei hoher Anzahl an Schleifendurchläufen kann durch die Parallelisierung mit For
die Performance verbessert werden.
class Program {
static void Main(string[] args) {
Stopwatch watch = new Stopwatch();
watch.Start();
ParallelTest();
watch.Stop();
Console.WriteLine(watch.ElapsedMilliseconds);
watch.Reset();
watch.Start();
SynchTest();
watch.Stop();
Console.WriteLine(watch.ElapsedMilliseconds);
Console.ReadLine();
}
static void SynchTest() {
double[] arr = new double[1000000];
for(int i = 0; i < 1000000; i++) {
arr[i] = Math.Pow(i, 0.333) * Math.Sqrt(Math.Sin(i));
}
}
static void ParallelTest() {
double[] arr = new double[1000000];
Parallel.For(0, 1000000, i => {
arr[i] = Math.Pow(i, 0.333) * Math.Sqrt(Math.Sin(i));
});
}
}
In diesem Beispiel mit einer Million Durchläufen läuft die Methode ca. 50% schneller. Je geringer diese Anzahl aber ist, desto geringer ist auch der Vorteil.
Unterbrechungen
Schleifen können auch jederzeit unterbrochen werden.
Parallel.For(0, 1000000, (i, option) => {
arr[i] = Math.Pow(i, 0.333) * Math.Sqrt(Math.Sin(i));
if (i > 1000) option.Stop();
});
Foreach
Auch diese Methode ist, meiner Meinung nach, wieder ziemlich verständlich.
string[] namen = { "Peter", "Uwe", "Udo", "Willi", "Pia", "Michael", "Conie" };
Parallel.ForEach(namen, name => {
Console.WriteLine(name);
});
Task
Die TPL bietet eine Task
-Klasse, welcher der Thread Klasse sehr ähnelt. Es gibt zwei Varianten, um einen Task zu definieren.
Task task1 = Task.Factory.StartNew(DoSomething);
Task task2 = new Task(Test);
task2.Start();
Lambda
Ein Task kann auch mithilfe der Lambda-Syntax definiert werden.
Task task1 = Task.Factory.StartNew(() => {
Console.WriteLine("Task wird ausgeführt...");
})
Wait
Es besteht die Möglichkeit, zu warten, bis ein Task fertig ist.
Task task = Task.Factory.StartNew(() => {
Console.WriteLine("Lange Operation...");
Thread.Sleep(5000);
Console.WriteLine("Ich bin fertig...");
});
task.Wait();
Console.WriteLine("Task hat Arbeit beendet...");
WaitAny
Mit WaitAny
kann man warten, bis ein Task aus einer Liste von Tasks ausgeführt wurde.
WaitAll
Dies wartet die Beendigung von allen Tasks ab.
Task task1 = Task.Factory.StartNew(() => {
Thread.Sleep(10000);
Console.WriteLine("Task #1: fertig...");
});
Task task2 = Task.Factory.StartNew(() => {
Thread.Sleep(3000);
Console.WriteLine("Task #2: fertig...");
});
Task task3 = Task.Factory.StartNew(() => {
Thread.Sleep(6000);
Console.WriteLine("Task #3: fertig...");
});
Task.WaitAll(task1, task2, task3);
Task
Auch Tasks können etwas zurückgeben. Um Rückgabewerte zu verwenden, muss man die Task<TResult>
Klasse verwenden.
int value = 12;
Task<long> task = Task<long>.Factory.StartNew((v) => {
int var = (int)v;
Thread.Sleep(3000);
return var * var;
}, value);
Console.WriteLine("Ich warte...");
Console.WriteLine("Resultat: {0}", task.Result);
CancellationTokenSource
Mithilfe eines Tokens kann eine Operation von aussen abgebrochen werden.
class Program {
static void Main(string[] args) {
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task task = Task.Factory.StartNew(() => {
Thread.Sleep(1000);
while(true) {
if (token.IsCancellationRequested) {
token.ThrowIfCancellationRequested();
}
}
}, cts.Token);
cts.Cancel();
Console.WriteLine("Abbruch der parallelen Operation...");
try {
task.Wait();
} catch (Exception ex) {
Console.WriteLine("In catch: " + ex.InnerException.Message);
}
Console.ReadLine();
}
}
Async & Await
Async
ermöglicht die Definition von Methoden, welche nicht blockieren. Mit Await
wartet man, bis der Task fertig ist.
Während der Abarbeitung solcher Methoden, kann der Aufrufer seine Arbeit fortsetzen.
Davon wird oft Gebrauch gemacht, wenn man API Calls tätigt, Dateien schreibt oder liest oder beim Laden von Medien.
class Program {
static void Main(string[] args) {
StartAsyncMethod();
for (int i = 0; i < 1000; i++) {
Console.Write(".");
Console.ReadLine();
}
}
static async void StartAsyncMethod() {
Console.Write("Start");
Console.Write(await AsyncMethod());
}
static async Task<string> AsyncMethod() {
await Task.Delay(20);
return "Fertig";
}
}
Producer Consumer
Hat man zwei Prozesse, so kann Prozess A nicht auf das Memory von B zugreifen. Deswegen wird ein drittes System oder eine Queue benötigt.
Eine Queue ist eine Warteschlange. Diese kann in Form von Memory, einer TXT Datei oder einer Datenbank bestehen.
Der Producer fügt nun dieser Queue einen Event hinzu. Währenddessen wartet ein Consumer auf Arbeiten aus der Queue und führt diese nach dem FIFO
(First In First Out) Prinzip aus.
Fazit
Hier nochmals das Wichtigste in Kürze.
Ein Prozess ist ein Programm, welches auf einem Betriebssystem ausgeführt werden kann. Ein Thread ist eine ausführbare Einheit in einem Prozess.
Multithreading ermöglicht es, dass man während der Bearbeitung einer Aufgabe nicht warten muss. In C# gibt es zwei Möglichkeiten, um ein
Multithreading einzubauen.
Die Thread-Klasse ist nahe am Programmcode. Dafür ist sie aber auch etwas komplexer. Die abstraktere Variante dazu ist Task Parallel Library (TPL).
Heutzutage wird meistens von diesem Gebrauch gemacht.
Das Producer/Consumer Pattern löst das Problem, dass zwei Prozesse nicht dieselben Daten verwenden können. Dies wird mithilfe eines dritten Systems
oder einer Queue bewältigt.
Ich persönlich kam zuvor erst in einem ÜK, der Betriebssysteme behandelte, mit dem Thema der Threads in Kontakt. Nun konnte ich noch einiges dazu
lernen.
Meiner Meinung nach ergibt es Sinn, sich mit dem Thema auseinanderzusetzen. Jetzt verstehe ich besser, was im Hintergrund eines Programmes
geschieht und wie ich dies bei Bedarf beeinflussen kann. Ich kann mir vorstellen, dass ich gerade die Async & Await Definitionen noch öfters benötigen
werde.
Manche Codebeispiele stammen aus dem Visual C# 2012 Buch (opens in a new tab).