Seit der ersten Version von .NET lassen sich mit der Klasse System.Threading.Thread multithreading-fähige Anwendungen erstellen. Allerdings stammt diese Klasse aus einer Zeit, in der Single-Core-CPUs der Stand der Dinge waren. Um Multi-Core-CPUs effektiv nutzen zu können, bedarf es neuer Werkzeuge. Mit dem .NET Framework 4.0 wurde die Task Parallel Library (TPL) eingeführt. Diese API vereinfacht vieles, doch ist auch ein Umdenken erforderlich.
Da das Thema Parallel Computing mit der TPL recht umfangreich ist, werde ich hierzu noch weitere Posts veröffentlichen. Dieser Artikel dient mehr als Einstieg in die Task Parallel Library und soll einen ersten Überblick vermitteln.
Allgemeines zur Task Parallel Library
Der Programmierer hat mit Hilfe der Klasse Thread die Möglichkeit, Threads zu starten und diese untereinander zu synchronisieren. Über die entsprechenden Methoden und Klassen muss dem Betriebssystem genau mitgeteilt werden, wie viele Threads erzeugt werden und wie diese abzuarbeiten sind.
Bei der TPL steht mehr das ‘Was’ im Vordergrund und nicht das ‘Wie’. Es wird definiert, was parallel abgearbeitet werden soll. Die TPL ermittelt an Hand der Hardwareausstattung, wie viele Threads für die anstehende Aufgabe optimal sind und sorgt für dessen Erzeugung. Eine Anwendung, die auf einer Dual-Core-CPU ausgeführt wird, erzeugt für eine bestimmte Aufgabe evtl. zwei Threads. Die gleiche Anwendung, auf einer Quad-Core-CPU unter Umständen aber vier. Dabei versucht die TPL die vorhandenen CPU-Cores optimal auf die Aufgaben zu verteilen.
Das klingt alles recht verführerisch. Doch will ich gleich am Anfang darauf hinweisen, dass Anwendungen durch die Verwendung der TPL nicht automatisch schneller werden. Hierzu gibt es weiter unten einige wichtige Hinweise.
Die wichtigsten Klassen
Die wichtigsten Klassen liegen im Namespace System.Threading.Task. Es gibt noch weitere, wie z.B. System.Collection.Concurrent, in der Collections enthalten sind, die die Vorteile der Parallelisierung ermöglichen.
Zwei Klassen aus dem Namespace System.Threading.Task sind von besonderer Bedeutung:
Klasse | Bedeutung |
Parallel | Der Schwerpunkt dieser Klasse liegt in der parallelen Verarbeitung von Schleifen. Hierfür stehen die statischen Methoden For() und ForEach() zur Verfügung. Mit der statischen Methode Invoke() können außerdem Codebereiche parallel abgearbeitet werden. |
Task | Für die asynchrone Ausführung von Codebereichen ist die Klasse Task sehr hilfreich. Immer da, wo bisher die Methode BeginInvoke() und EndInvoke() zum Einsatz kamen, kann jetzt die Klasse Task eingesetzt werden. Auch wird die Klasse Thread an vielen Stellen durch die Klasse Task überflüssig. |
Einführung in die Klasse Parallel
Die statische Klasse Parallel enthält drei Methoden, die natürlich auch alle statisch sind:
- For()
- ForEach()
- Invoke()
Jede Methode liegt in zahlreichen Überladungen vor. Zur ersten Betrachtung sollen die einfachen Varianten dienen.
Parallel.For()
Die Methode Parallel.For() ist eine konkurrenzlos einfache Möglichkeit, Schleifendurchläufe zu parallelisieren. Der grundsätzliche Aufbau ähnelt der einer for-Anweisung:
public static ParallelLoopResult For(int fromInclusive, int toExclusive, Action<int> body)
Der Startindex fromInclusive wird über den ersten und der Endindex toExclusive über den zweiten Parameter übergeben. Hierbei ist der Startindex der erste Wert mit dem die Schleife aufgerufen wird und der Endindex minus 1 der letzte Wert.
Der Code, der ausgeführt werden soll, wird per Delegate an den dritten Parameter übergeben. Der Delegate Action<int> erwartet in diesem Fall nur einen Parameter vom Typ int und hat keinen Rückgabewert. Der Delegate wird bei jeder Iteration mit dem aktuellen Index aufgerufen.
double[] arr = new double[100000]; // sequential for (int i = 0; i < 100000; i++) { arr[i] = Math.Sin(i) + Math.Sqrt(i) * Math.Pow(i, 3.1415); } // parallel Parallel.For(0, 100000, i => { arr[i] = Math.Sin(i) + Math.Sqrt(i) * Math.Pow(i, 3.1415); });
Der Delegate lässt sich auch sehr gut als Lambda-Ausdruck übergeben. Mehr über Lambda-Ausdrücke unter Lambda Expressions und Expression Trees – Teil 1.
Zum Vergleich habe ich im obigen Programmausschnitt auch die sequenzielle Variante angegeben.
Wie gut zu erkennen ist, unterscheiden sich beide Varianten nur sehr wenig. Das erhöht die Lesbarkeit enorm. Doch wie wird der Code parallel ausgeführt? Schlicht und einfach wird versucht, die anstehende Aufgabe gleichmäßig auf die CPU-Kerne zu verteilen. So könnte die Schleife auf einem Quad-Core in vier separate Schleifen aufgeteilt werden und jeder CPU-Kern wird mit der Abarbeitung beauftragt. Der erste CPU-Kern bearbeitet die Schleifen von 0 bis 24999, der zweite CPU-Kern von 25.000 bis 49.999, der dritte von 50.000 bis 74.999 und der vierte von 75.000 bis 99.999. Die notwendigen Berechnungen und das Aufteilen auf die CPU-Kerne übernimmt das .NET-Framework. Man darf jetzt allerdings nicht erwarten, dass unter allen Umständen die Schleife viermal schneller abgearbeitet wird. Einige wichtige Hinweise hierzu gebe ich noch weiter unten.
Wichtig: Die Methode wird synchron zum Aufrufer ausgeführt. Erst wenn die Schleife komplett abgearbeitet wurde, wird mit dem folgenden Befehl fortgefahren. Das ist auch sinnvoll, da es bei dieser Methode nur darum geht, eine Schleife parallel auszuführen (also zu beschleunigen). Weiteres manuelles Synchronisieren ist dadurch überflüssig.
Rückgabewert
Die Methode gibt die Struktur ParallelLoopResult zurück. Diese enthält zwei Eigenschaften, die darüber Auskunft geben, ob die Schleife komplett ausgeführt oder vorzeitig beendet wurde. Diese und noch andere Besonderheiten werde ich noch in einem speziellen Post zusammenfassen.
Schrittweite des Iterators
Eine Frage bleibt allerdings noch offen: Wie kann die Schrittweite des Iterators beeinflusst werden? Dieses ist leider nicht möglich. Der Index ist immer vom Typ long oder int und besitzt immer die Schrittweite +1. Der 2. Parameter (toExlusive) muss immer größer sein, als der erste (fromInclusive). Sind andere Schrittweiten notwendig, so muss eine Umrechnung des Iterators im Schleifencode erfolgen. Das folgende Beispiel führt die Schleife nur mit geraden Werte von 0 bis 98 aus (0, 2, 4, 6, … 98).
Parallel.For(0, 50, i => { int myIndex = i * 2; arr[myIndex] = Math.Sin(myIndex); });
Zugriff auf die GUI und gemeinsame Variablen
Der Grundsatz, dass nur der Thread, der ein GUI-Element erzeugt hat, auch auf dieses zugreifen darf, gilt auch für die TPL. Somit darf aus dem Delegate der Methode For() nicht auf die GUI zugegriffen werden. Ebenfalls muss beim Zugriff auf gemeinsame Variablen aufgepasst werden.
Performanz
Welche Performanz Steigerungen sind durch die Parallelisierung zu erwarten? Ich habe hierzu ein Testprogramm geschrieben, das eine For-Schleife 100mal, 10.000mal und 1.000.000mal durchläuft. Innerhalb dieser For-Schleifen werden immer die gleichen mathematischen Berechnungen durchgeführt. Zum Vergleich werden die Schleifen sequenziell und parallel ausgeführt. Außerdem habe ich das Programm auf drei unterschiedlichen Rechnern gestartet.
Intel Celeron 2 GHz (1 CPU-Kern)
Intel Core-Duo 1,8 GHz (2 CPU-Kerne)
Intel Core i7 3,4 GHz (8 CPU-Kerne)
Der Einsatz der TPL ist immer dann sinnvoll, wenn die Anzahl der Schleifendurchläufe groß genug ist, also ausreichend ‘Arbeit’ vorhanden ist, die auf die einzelnen CPU-Kerne verteilt werden kann. Ist das nicht der Fall, so erreicht man genau das Gegenteil; das Programm wird langsamer. Die TPL ermittelt zur Laufzeit den Bedarf an Rechenleistung und teilt die anstehenden Berechnungen den einzelnen CPU-Kernen zu. Gibt es wenig zu verteilen, so macht sich der Overhead der TPL bemerkbar.
Auch ist gut zu erkennen, dass eine CPU mit X Kernen nicht automatisch eine Berechnung um den Faktor X beschleunigt. Bei dem Rechner mit 2 CPU-Kernen wird der Faktor 2 schon mit 10.000 Durchläufen erreicht. Bei der 8-Core-CPU reichen selbst 1.000.000 Durchläufe nicht aus. Bei 10.000 Durchläufen liegt der Faktor bei ca. 4, bei 1.000.000 Durchläufen bei ca. 6. Scheinbar wird der Overhead der TPL mit der Anzahl der CPU-Kerne auch größer.
Wie zu erwarten, ist auf dem Rechner mit nur einem CPU-Kern kein deutlicher Unterschied zwischen der parallelen und der sequenziellen Ausführung festzustellen. Nur wenn die Schleife wenige Durchläufe hat, macht sich auch hier der Overhead der TPL negativ bemerkbar.
Testprogramm (Visual Studio 2010) auf GitHub
Parallel.ForEach()
Nach dem gleichen Muster kann mit der Klasse Parallel eine ForEach-Schleife programmiert werden.
// initialize array double[] arr = new double[100]; for (int i = 0; i < 100; i++) arr[i] = i; // sequential foreach (var item in arr) { Console.WriteLine(item); } // parallel Parallel.ForEach(arr, item => { Console.WriteLine(item); });
Alle Besonderheiten, die bei der Methode Parallel.For() genannt wurden, gelten auch für Parallel.ForEach().
Parallel.Invoke()
So wie die Methoden Parallel.For() und Parallel.ForEach() das Parallelisieren von Schleifen stark vereinfachen, so können mit der Methode Parallel.Invoke() sehr einfach mehrere Anweisungsblöcke parallel verarbeitet werden.
public static void Invoke(params Action[] actions);
Die statische Methode liegt in zwei Überladungen vor. Die einfachste Variante enthält ein Parameter-Array, das Delegates vom Typ Action() erwartet. Es können also keine Parameter übergeben oder zurückgeliefert werden.
Parallel.Invoke(() => { for (int i = 0; i < 10; i++) Thread.Sleep(1000); }, () => { for (int i = 0; i < 20; i++) Thread.Sleep(900); }, () => { for (int i = 0; i < 30; i++) Thread.Sleep(800); });
Auch diese Methode wird synchron zum Aufrufer ausgeführt. Erst wenn alle Delegates mit der Abarbeitung fertig sind, wird auch die Methode Invoke() verlassen.
Bei dieser einfachen Variante sollte man sich darüber bewusst sein, dass auch hier ein Zugriff auf das User-Interface nicht möglich ist. Ebenfalls muss der Zugriff auf gemeinsame Ressourcen entsprechend synchronisiert werden.
Einführung in die Klasse Task
Oben habe ich bereits erwähnt, dass die Klasse Task das Programmieren von asynchronen Abläufen vereinfacht und außerdem die Klasse Thread ersetzt. Hierzu direkt ein Beispiel:
// create Task 1 Task task1 = new Task(() => { Thread.Sleep(1000); Console.WriteLine("Task1 end"); }); task1.Start(); // create Task 2 Task task2 = Task.Factory.StartNew(() => { Thread.Sleep(500); Console.WriteLine("Task2 end"); }); Console.WriteLine("end"); Console.ReadLine();
Es werden zwei Instanzen der Klasse Task angelegt. Die erste Instanz über den new-Operator und die zweite über die Methode StartNew(). Als Parameter wird in beiden Fällen ein Delegate vom Typ Action() erwartet, genau wie Parallel.For(), Parallel.ForEach() und Parallel.Invoke(). Zur besseren Übersicht benutze ich bei meinem Beispiel Lambda-Ausdrücke.
Die erste Task wird erst durch den Aufruf der Methode Start() gestartet, während die zweite unmittelbar ausgeführt wird.
Anders als bei Parallel.Invoke() arbeitet die Klasse Task asynchron. Der Thread, der die beiden Instanzen erzeugt, läuft unmittelbar weiter. Somit sieht die Ausgabe wie folgt aus:
Synchronisieren
Zum einfachen Synchronisieren stehen die statischen Methoden WaitAll() und WaitAny() zur Verfügung. Beide Methoden erwarten ein Array von Task-Objekten.
Task.WaitAll() blockiert den aufrufenden Task so lange, bis alle Task-Objekte, die an die Methode übergeben wurden, beendet sind. Bei Task.WaitAny() ist es ausreichend, wenn eines der Task-Objekte seine Arbeit beendet hat.
// create Task 1 Task task1 = new Task(() => { Thread.Sleep(1000); Console.WriteLine("Task1 end"); }); task1.Start(); // create Task 2 Task task2 = Task.Factory.StartNew(() => { Thread.Sleep(500); Console.WriteLine("Task2 end"); }); Task.WaitAny(task1, task2); Console.WriteLine("end"); Console.ReadLine();
Die Ausgabe des Beispielprogramms:
Die Klasse Task hat noch deutlich mehr Methoden und Eigenschaften. So kann ein Task-Objekt beim Aufruf Parameter erhalten oder auch Parameter zurückliefern. Auch wurde das Auffangen von Ausnahmen, die in einem anderen Thread auftreten können, deutlich vereinfacht. Dieses werde ich aber noch in einem separaten Post genauer vorstellen.
Zusammenfassung
Die einfachen Beispiele zeigen schon, dass die TPL das Entwickeln von parallelem Code deutlich vereinfacht. Das Potential von Multi-Core-CPUs kann genutzt werden, ohne dass die Komplexität des Programmcodes zunimmt. Da das Thema Parallel Computing mit der TPL recht komplex ist, plane ich hierzu weitere Posts:
- Die Klasse Parallel
- Die Klasse Task
- Parallel Diagnostic Tools in Visual Studio
- WinForms und WPF
- Neuigkeiten mit .NET 4.5
- …
2 thoughts on “TPL Teil 1 – Einführung”