Im letzten Blog haben wir gesehen, dass die bewährte Thread Klasse in gewisse Fälle immer noch die beste Lösung ist. Für kurze Aufgaben wird aber der Threadpool verwendet weil so der Overhead des Threaderstellens gespart wird.
Dieser Teil erklärt den Threadpool im Detail und zeigt mit Beispielen wie man Aufgaben direkt den Threadpool delegieren kann.
Beim Erstellen eines neuen Threads gehen ein paar hundert Mikrosekunden verloren um den Thread vorzubereiten (z.B. organisieren eines lokalen variablen Stacks). Dazu braucht jeder Thread ungefähr 1 MB Speicher.
Der Threadpool reduziert diesen Overhead dadurch, dass Threads mehrfach verwendet werden und auch nach dem Abarbeiten aller Aufgaben nicht zerstört werden. Ausserdem kann der Threadpool die Anzahl paralleler Threads limitieren umso zu verhindern, dass das System „überfordert“ wird. Neue Aufgaben kommen dann in einer Warteschlange bis ein Thread frei wird.
Es gibt folgende Wege eine Aufgabe auf einem Threadpool Threads auszuführen:
Die TPL wird ausführlich in späteren Blogs behandelt. Der BackgroundWorker kann durch TPL ersetzt werden und wird deshalb nicht mehr betrachtet.
Folgende Klassen und Frameworks verwenden den Threadpool indirekt:
Der erste Code Teil vor dem await Statement wird ausgeführt auf dem aufrufenden Thread (kann also auch ein Threadpool Thread sein).
Das Warten selber auf keinem Thread.
Der Code Teil nach dem await wird nur auf einem Threadpool Thread ausgeführt, wenn kein SynchronisationContext vorhanden ist oder ConfigureAwait(false) gesetzt ist.
Dazu mehr in einem späteren Blog.
Folgendes Beispiel zeigt den direktesten Weg eine Aufgabe auf einem Threadpool-Thread auszuführen:
public void Execute(int decimals)
{
System.Threading.ThreadPool.QueueUserWorkItem(FullBlownThreadPoolMethod, decimals);
}
public void FullBlownThreadPoolMethod(object state)
{
int decimals = (int)state;
string pi = CalculatePi.Execute(decimals);
Console.WriteLine(pi);
}
Eventuelle Parameter können als „state“-Objekt an den ausführenden Thread übergeben werden. Im Beispiel gibt es keinen kontrollierten Weg den Thread abzubrechen und der Haupt-Thread weiss nicht wann der Threadpool-Thread fertig ist. Rückgabewerte und Exception Handling sind nur indirekt möglich.
Diese Probleme sind mit asynchronen Delegates gelöst.
Ein Delegate wird mit BeginInvoke asynchron auf dem Threadpool gestartet und gibt ein IAsyncResult Objekt zurück.
private void CallDoWorkOnThreadpool()
{
Func<string, int> method = DoWork;
IAsyncResult doWorkAsyncResult = method.BeginInvoke("Text", null, null);
//
// ... parallele Aufgaben auf dem main Thread...
//
int result = method.EndInvoke(doWorkAsyncResult);
Console.WriteLine("String length is: " + result);
}
private int DoWork(string s) { return s.Length; }
Mit EndInvoke werden folgende Ziele erreicht:
Ein Threadpool Thread kann- oder darf nicht:
Man darf aber die Priorität des unterliegenden Threads ändern. Der Threadpool Manager setzt die Priorität zurück auf Normal wenn der Thread recycled wird.
Die andere Anforderungen werden durch die direkte Thread Klasse abgedeckt, siehe Blog die bewährte Thread Klasse. In diesem Blog wird auch die STA- und MTA-Threading Modelle erklärt und den Unterschied zwischen fore- und background Threads.
Der Threadpool-Manager ist für das Erzeugen von Threads für den Threadpool verantwortlich. Bei CPU-bound Aufgaben macht es Sinn, nicht mehr als einen Thread per CPU-Kern zu belegen, da sonst Zeit und Resourcen verschwendet werden mit dem Kontext-Switch für Multithreading (= interleaved concurrency). Interleaved concurrency existiert in zwei Variationen, cooperative Multitasking und preemptive Multitasking.
Cooperative Multitasking: der Task gibt selber die Kontrolle zurück am Prozessor. Die zugeteilten Zeiten sind variable und werden durch die Tasks selber bestimmt.
Preemptive Multitasking: der Scheduler teilt jedem Task die verfügbare Zeit zu.
Der Threadpool Manager kennt 2 Typen von Threads: worker Threads und IO completion Port Threads. Die IO completion Port Threads werden eingesetzt für asynchrone Aufgaben und werden in diesem Blog nicht weiter behandelt.
Werden mehr als die aktuelle Anzahl worker Threads im Threadpool benötigt, dann wartet der Threadpool Manager eine halbe Sekunde bis einen belegter Thread frei kommt. Erst dann wird ein zusätzlicher Thread kreiert, bis die maximale Anzahl Threads erreicht ist.
Die Standardwerte für die minimale Anzahl worker Threads ist gleich Anzahl Prozessorkerne.
Die Standardwerte für die maximale Anzahl worker Threads hängen vom System ab und sind:
Der Threadpool-Manager wartet eine halbe Sekunde um zu verhindern, dass es eine Lawine von kurzen Arbeiten das System überfordert. Werden z.B. 40 CPU-bound Aufgaben von 10 ms auf einem 4 CPU Kern System ausgeführt, so würde die gesamte Arbeit 100 ms dauern mit maximal 4 Threads parallel (4 CPU Kerne). Die Zeitlimitierung sorgt also dafür, dass nicht mehr als 4 Thread parallel kreiert werden.
Bei 40 IO-bound Aufgaben sieht es aber anders aus. Wenn jede Aufgabe eine halbe Sekunde warten muss auf eine externe Antwort (vom Netz oder Filesystem), würde die gesamte Ausführzeit enorm unter der Zeitlimitierung leiden. Für IO-bound Aufgaben, z.B. bei einen Webserver, wäre es vom Vorteil mehr Threads parallel in Einsatz zu haben.
Für asynchrone Arbeiten soll aber kein Thread eingesetzt werden sondern das async\await Konstrukt. Dieses sorgt dafür, dass während des Wartens kein Thread benötigt wird. Ist aber hinter dem await ConfigureAwait(false) spezifiziert, so wird der Code nach dem await auf einem Threadpool Thread ausgeführt und tritt das Problem der Verzögerung ebenfalls auf.
Die minimal Anzal Threads kann man mit ThreadPool.SetMinThreads erhöhen auf z.B. 50. Mit ThreadPool.SetMaxThreads kann man übrigens die maximale Anzahl parallele Thread steuern, was allerdings selten von Nöten ist.
Nächstes Beispiel gibt uns die Gelegenheit mit dem Verhalten zu spielen. Mit Thread.Sleep wird eine IO-bound Aufgabe (asynchron) simuliert.
const int MaxParallelThreads = 80;
static void Main(string[] args)
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main(): started");
ManageAvailableWorkerThreads();
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main(): hit any key...");
Console.ReadKey();
}
public static void ManageAvailableWorkerThreads()
{
int workerThreads;
int completionThreads;
// Minimum worker threads are already created by the thread pool, ready to be used.
ThreadPool.GetMinThreads(out workerThreads, out completionThreads);
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] ManageAvailableWorkerThreads(): minimum workerThreads: {workerThreads}, minimum completionThreads: {completionThreads}");
ThreadPool.GetMaxThreads(out workerThreads, out completionThreads);
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] ManageAvailableWorkerThreads(): maximum workerThreads: {workerThreads}, maximum completionThreads: {completionThreads}");
ThreadPool.GetAvailableThreads(out workerThreads, out completionThreads);
// By setting the minimum threads, the thread pool already creates the supplied amount of threads before usage.
ThreadPool.SetMinThreads(8, 8);
ThreadPool.SetMaxThreads(8, 8);
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] ManageAvailableWorkerThreads(): available workerThreads: {workerThreads}, available completionThreads: {completionThreads}");
Task[] tasks = new Task[MaxParallelThreads];
for (int i = 0; i < MaxParallelThreads; i++)
{
tasks[i] = new Task(threadIndex =>
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Delegate index: {threadIndex}");
// CalculatePi.Execute(20000); // This is real CPU-bound work
Thread.Sleep(5000); // Simulate IO-bound work, e.g. waiting for an answer
}, i);
tasks[i].Start();
}
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] ManageAvailableWorkerThreads(): all threads created and started...");
Thread.Sleep(1000);
ThreadPool.GetAvailableThreads(out workerThreads, out completionThreads);
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] Main(): available workerThreads: {workerThreads}, available completionThreads: {completionThreads}");
Task.WaitAll(tasks);
}
}
Der Rechner hat 8 CPU Kerne.
Es stehen nur 8 Threads zur Verfügung die auf 8 Kerne verteilt werden. Die 80 Aufgaben werden also verteilt auf 8 Kerne. So werden seriell 10 Aufgaben pro Kern ausgeführt.
Die Gesamtzeit beträgt etwas mehr als 50 Sekunden.
Es werden am Anfang 8 Threads parallel kreiert und erst nach einer halben Sekunde warten wird ein neuer Thread kreiert. Diese neue Threads werden dann eingesetzt und die Anzahl parallele Threads steigt. Werden die neu kreierte Threads eine Zeit lang nicht mehr gebraucht, so werden sie vernichtet durch den Threadpool Manager.
Die gesamte Ausführungszeit beträgt nun rund 25 Sekunden.

Thread 12 wird zum Beispiel erst zugewiesen an Delegate index 1. Nach 5 Sekunden kommt er frei und wird erneut zugewiesen, dieses Mal an Delegate index 16.
Es werden 80 Threads parallel sofort kreiert ohne zu warten bis ein belegter Thread frei wird. Die 80 Aufgaben werden alle parallel ausgeführt, belasten die Kerne aber nicht.
Die Gesamtzeit beträgt jetzt etwas mehr als 5 Sekunden.
Gesamtzeit beträgt fast 33 Sekunden.
Es werden wieder nach einer halben Sekunde neue Threads kreiert und es kommt zu mehr als 8 parallele Threads, also zu mehr Kontexwechseln und Resourcenverbrauch.
Die Gesamtzeit beträgt jetzt 35 Sekunden.
Der Threadpool könnte 80 Threads parallel kreieren. Das System ist aber voll ausgelastet und es kommen nach und nach mehr Threads hinzu.
Die Gesamtzeit beträgt jetzt ebenfalls 35 Sekunden.
Nur bei asynchrone (IO-bound) Arbeiten lohnt es sich Thread.SetMinThreads anzuwenden.
Asynchrone Arbeiten sollten jedoch mit async\await ausgeführt werden wodurch kein Thread verschwendet wird während des wartens.
Nächste Mal schauen wir uns das Problem der Cross Thread Aufrufe und mögliche Lösungen an.
[…] nächsten Teil schauen wir den Threadpool an. Es wird gezeigt wie man selber eine Aufgabe ausführen kann auf […]
[…] ← Vorige Post […]