Seit bereits mehreren Jahren ist der Trend erkennbar - CPUs skalieren nicht mehr über ihre reine Rechenleistung sondern vor allem mit der Menge an physikalischen (oder heißt es physisch?) CPU Kernen. Will man dies in einer .NET Applikation nutzen macht man einfach weitere Threads auf die dann im idealfall parallel auf mehreren Cores abgearbeitet werden. Im .NET Ökosystem hat Microsoft in den letzten Jahren mehrere Extensions zur Unterstützung von Multithreading Entwickelt und zur Verfügung gestellt. Mit .NET 4.0 wurde nun voher seperat verfügbare Extensions in das Core-Framework installiert, höchste Zeit also einen Blick darauf zu werfen was es dort neues gibt. Die Komponenten sind im wesentlichen:
- TPL (Task Parallel Library)
- CCR (Concurrency and Coordination Runtime)
- PLinq (Parallel Linq)
Wieso nicht weiter wie bisher?
Zunächst einmal muss folgendes festgestellt werden. Paralelisierung ist nicht gleichzusetzen mit Multithreading! Nur weil eine Anwendung ( oder ein Service ) zwei Thread habs bedeutet das nicht dass diese auch parallel abgearbeitet werden. Dies hängt unter anderem vom Betriebbsystem, der Menge der CPU Kerne und zahlreichen weiteren Faktoren ab.
Die Menge an Thread muss sich folglich an der Mege der verfügbaren CPUs orientieren um einen Performance-Vorteil zu erzielen, aber auch dies allein ist nur eine grobe Lösung. Wenn andere Applikationen ebenfalls mit ihren Threads CPU-Kerne belegen sollte dies berücksichtigt werden, und was wenn die eigene Applikation in mehreren Instanzen läuft? Generell kostet die Erzeugung eines Threads Ressourcen und ist auch häufig nicht der schnellste Weg zur Lösung einer Aufgabe.
Was ist neu?
Die neuen parallelisierungsfunktionen setzen in einer Zusatzschicht auf dem Threadpool auf und werden über einen TaskScheduler (der auch selbst implementiert werden kann) verwaltet. Einzelne Tasks werden in eine Queue eingestellt und über den TaskScheduler auf physikalische Threads verteilt. Die Menge der Threads wird dabei permanent dynamisch angepasst. Der TaskScheduler hat eine recht ausgefuchste Routine zur Lastermittlung und kann so z.B. auch I/O Last, Netzwerklast usw. zur Laufzeit ermitteln, ebenso wird aktuele CPU Last, Art der Threads und weitere Informationen berücksichtig. Alle 0.5 Sekunden findet diese Überprüfung statt und passt anschließend ggf. die Anzahl der physikalischen Threads dem Ergebnis der Auswertung an.
Trotz der neuen Automatisierungs-Features wird eine Anwendung durch Parallelisierung immer noch komplexer und schwerer zu debuggen. (Stichworte Deadlocks, Race Conditions, etc.)
Bevor man sich an die parallelisierung einer bestehenden Anwendung begibt sollte Code immer erst optimiert und Refactored werden - häufig ist hier noch deutlich mehr Performance zu finden als durch stumpfe Teilung in Threads. Für den geschätzten Performance-Gewinn einer parallelisierung kann man sich an http://de.wikipedia.org/wiki/Amdahlsches_Gesetz orientieren. Ideal geeignet ist Code der langwierige Berechnungen ausführt, lange dauernde Service-Requests durchführt und leicht "zerlegbar" ist.
Was ist mit Code?
Hier ein paar Codeschnipsel die zeigen wie die neuen Features verwendet werden können. Für alle Beispiele ist zunächst der Namespace System.Threading.Tasks zu importieren.
Manuelle Tasks erzeugen
Task newTask = Task.Factory.StartNew(() => Console.WriteLine("hello from task1!"));
(Extension Method .ContinueWidth ermöglicht eine Reihenfolge der Tasks festzulegen)
Synchronisierung:
Sicherstellen das alle Tasks fertig sind
Task.WaitAll( task1, task2 );
Sicherstellen das Task 1 beendet ist...
Task.Wait(task1);
Warten bis irgendeiner der Task beendet ist:
Task.WaitAny(....);
(Dann wird auch die IsCompleted-Property des Tasks true)
Abfrage von Rückgabewerten:
Task<string> task1 = new Task<string>(() => GetResult());
task1.Start();
string result = task1.Result; // Das funktioniert weil der Result-Getter Code erst ausgeführt wird wenn der Task beendet ist.
Die zwei einfachsten Möglichkeiten:
Parallel.For() und ForEach()
Parallel.ForEach(numbers, number =>
{
work.Process(number);
});
Achtung! Hier ist nicht garantiert in welcher Reihenfolge die Liste abgearbeitet wird! Ist die Liste z.B. mit 1,2,3....50 gefüllt und sind 2 CPUs vorhanden beginnt der erste Thread mit der 1, der zweite mit der 25. Das muss aber nicht immer so sein - siehe Erklärung des TaskSchedulers.
ParallelOptions options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
Parallel.For(0, numbers.Count, options, number =>
{
work.Process(number);
});
Viel Vergnügen beim ausprobieren!