en / de
AI
Expertisen
Methoden
Dienstleistungen
Referenzen
Jobs & Karriere
Firma
Technologie-Trends TechCast WebCast TechBlog News Events Academy

C# Concurrency Teil 4: Der Threadpool

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.

Gründe für den Threadpool

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.

Aufgabe dem Threadpool zuweisen

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.

Beispiel mit ThreadPool.QueueUserWorkItem

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.

Beispiel mit asynchronen Delegates

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:

Threadpool Thread Limitationen

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

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.

Beispiel

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.

Testlauf 1

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.

Testlauf 2

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.

Concurrency Blog4 Threadpool Manager

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.

Testlauf 3

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.

Testlauf 4

Gesamtzeit beträgt fast 33 Sekunden.

Testlauf 5

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.

Testlauf 6

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.

Fazit

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.

Follow up

Nächste Mal schauen wir uns das Problem der Cross Thread Aufrufe und mögliche Lösungen an.

← Vorige Post
Nächster Post →
Kommentare

2 Antworten zu “C# Concurrency Teil 4: Der Threadpool”

  1. […] nächsten Teil schauen wir den Threadpool an. Es wird gezeigt wie man selber eine Aufgabe ausführen kann auf […]

Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Newsletter - aktuelle Angebote, exklusive Tipps und spannende Neuigkeiten

 Jetzt anmelden

Copyright © 2025 Noser Engineering AG – Alle Rechte vorbehalten.

NACH OBEN
Privacy Policy Cookie Policy
Zur Webcast Übersicht