Die Tiefen von Windows-I/O (Teil 3) — I/O-Completion-Ports (IOCP) und der .NET-Thread-Pool: Der Keller unter async/await

· · Windows, Win32, I/O, IOCP, Asynchron, Thread-Pool, .NET, CSharp

Letztes Mal (Teil 2) haben wir uns angesehen, wie asynchrones I/O ausgegeben wird und über welche vier Wege der Abschluss empfangen wird. Damals nannten wir nur den Namen – als „die eigentliche Lösung, um sehr viele gleichzeitige I/O-Vorgänge mit wenigen Threads abzufangen“ – den I/O-Completion-Port (IOCP).

Warum kann ein Webserver Tausende gleichzeitiger Verbindungen mit einem Dutzend Threads bewältigen? Warum lässt sich mit Bestimmtheit sagen, dass ein I/O-Wartevorgang mit async/await „keinen Thread verbraucht“? Warum läuft die Fortsetzung von await manchmal auf dem UI-Thread und manchmal im Thread-Pool weiter? – Die Antworten auf alle drei Fragen führen auf ein einziges Design zurück: IOCP. Das ist die Folge dieser Reihe, die am direktesten mit .NET-Entwicklern zu tun hat.

Dies ist Teil 3 der Reihe „Die Tiefen von Windows-I/O“. Der Gesamtaufbau steht am Anfang von Teil 1.

Da es ein langer Artikel ist, hier vorab eine Übersicht, wo welche Frage beantwortet wird.

  • Frage 1: Warum lassen sich Tausende gleichzeitiger Verbindungen mit einem Dutzend Threads bewältigen → wird in Kapitel 3 beantwortet (das IOCP-Design, das Completion-Queue und Thread-Anzahl-Steuerung vereint).
  • Frage 2: Warum lässt sich sagen, dass ein I/O-Wartevorgang „keinen Thread verbraucht“ → wird in Abschnitt 5.1–5.2 beantwortet (das vollständige Bild eines await-Umlaufs und die genaue Bedeutung davon, dass während der Wartezeit kein Thread existiert).
  • Frage 3: Was entscheidet, auf welchem Thread die Fortsetzung von await läuft → wird in Abschnitt 5.3 beantwortet (der erfasste Kontext und die wahre Bedeutung von ConfigureAwait(false)).

1. Zunächst das Fazit

  • IOCP vereint eine „Warteschlange für Abschlussbenachrichtigungen“ mit „Steuerung der Thread-Anzahl“. Completion-Pakete werden FIFO in die Warteschlange gelegt, und Worker-Threads holen sie mit GetQueuedCompletionStatus heraus (Kapitel 3).1
  • Threads werden LIFO geweckt. Der Thread, der zuletzt gearbeitet hat und noch „warm“ ist, ist derjenige, der das nächste Paket abholt – solange die Warteschlange gefüllt bleibt, kommt es also kaum zu Kontextwechseln (Abschnitt 3.3).1
  • Der Concurrency-Wert ist die Obergrenze für die Anzahl ausführbarer Threads. Empfohlener Ausgangspunkt ist die CPU-Anzahl (bei Angabe von 0 die Prozessoranzahl). Blockiert ein laufender Thread, wird ein wartender Thread geweckt, um die Lücke zu füllen (Abschnitt 3.4).12
  • Der Port lässt sich auch für eigene Benachrichtigungen nutzen. Mit PostQueuedCompletionStatus lassen sich Pakete ablegen, die nichts mit I/O zu tun haben, sodass sich Arbeitsaufträge an Worker und Shutdown-Anweisungen über dieselbe Warteschlange leiten lassen (Kapitel 4).3
  • Für neue Serverimplementierungen wird statt des rohen IOCP die Windows-Thread-Pool-API (CreateThreadpoolIo) empfohlen. Intern bleibt es IOCP, aber sie nimmt Ihnen die Thread-Verwaltung ab (Kapitel 4).1
  • Der .NET-Thread-Pool besteht aus zwei Etagen – Worker-Threads und I/O-Completion-Threads –, und asynchrone I/O-Handles werden an das (eigene) IOCP des Pools gebunden. Für die I/O-Wartezeit von await existiert kein Thread; nur die Fortsetzung nach Abschluss läuft auf einem Thread (Kapitel 5).456
  • Wohin die Fortsetzung geht, entscheidet der „erfasste Kontext“. Wird auf dem UI-Thread awaitet, geht die Fortsetzung zum UI-Thread zurück; ist nichts erfasst, läuft sie im Thread-Pool (oder auf dem Thread, der den Abschluss herbeigeführt hat) weiter. ConfigureAwait(false) ist eine Anweisung, diese Erfassung zu unterlassen, keine Garantie für einen Wechsel in den Thread-Pool (Abschnitt 5.3).6

2. Wo das Design „mit Threads erschlagen“ scheitert

Zunächst prüfen wir das Problem, das IOCP lösen sollte. Ein naiver Server lässt sich als „ein Thread pro Verbindung“ schreiben. Synchron lesen, verarbeiten, schreiben. Ein leicht verständliches Design, das aber bei wachsender Verbindungszahl an zwei Wände stößt.

Das IOCP-Modell (asynchrones I/O)Completion-Queue(alle Abschlussbenachrichtigungen aller Verbindungen laufen hier zusammen)Worker 1Worker 2Worker sind wenige – etwa in Höhe der CPU-AnzahlEin Thread pro Verbindung (synchrones I/O)Thread 1: wartet auf read für Verbindung 1Thread 2: wartet auf read für Verbindung 2Thread 3: wartet auf read für Verbindung 3…die Anzahl der Threads wächst mit der Verbindungszahldie meisten schlafen nur und warten auf I/O

Abbildung 1: Links wächst die Anzahl der Threads proportional zur Verbindungszahl. Rechts werden nur „eingetretene Ereignisse“ von wenigen Threads verarbeitet.

  1. Threads sind nicht umsonst. Jeder einzelne verbraucht einen Stack (standardmäßig 1 MB reserviert) und ein Kernel-Objekt, und mit wachsender Anzahl summiert sich die Last für Scheduler und Kontextwechsel. Tausende Verbindungen bedeuten tausende Threads – teuer, selbst wenn die meisten davon nur „schlafend auf lesbare Daten warten“.
  2. „Wie viele man gerne ausführen würde“ verliert jenseits der CPU-Anzahl seine Bedeutung. Physisch können höchstens so viele Threads gleichzeitig laufen, wie es CPUs gibt. Mehr Threads ausführbar zu machen, erhöht nur die Wechselkosten.

Bis Teil 2 haben wir Wege gesehen, Abschlussbenachrichtigungen über „Events“ oder „APCs“ zu empfangen. Der Event-Ansatz stößt jedoch an die 64-Objekt-Grenze von WaitForMultipleObjects und wird beim Design der Wartelogik umständlich, und APCs sind an den ausgebenden Thread gebunden. IOCP ist das Design, das von Grund auf auf die Form viele I/O-Vorgänge × wenige Threads zugeschnitten wurde.1

3. Das Design von IOCP — Warteschlange und Thread-Steuerung in einem

3.1. Die zwei Gesichter von CreateIoCompletionPort

CreateIoCompletionPort erledigt entgegen seinem Namen zwei Aufgaben: das Neuerstellen eines Ports und das Verknüpfen eines Handles mit einem bestehenden Port.2

Worker-ThreadsI/O-Completion-PortVerknüpfte Handles (beliebig viele)steuertWartet mit GetQueuedCompletionStatusWartet mit GetQueuedCompletionStatusWarteschlange der Completion-Pakete (FIFO)Paket = übertragene Bytes +CompletionKey + OVERLAPPED-ZeigerConcurrency-Steuerungausführbare Thread-Anzahl ≤ ObergrenzeDateiSocketNamed Pipe

Abbildung 2: Der Aufbau von IOCP. Die Abschlüsse vieler Handles laufen in einer Warteschlange zusammen, und selbst die Anzahl der abholenden Threads wird gesteuert.

Der bei der Verknüpfung übergebene CompletionKey ist ein frei wählbarer Wert, mit dem dem Worker mitgeteilt wird, „dies ist ein Abschluss von diesem Handle“ (üblich ist, einen Zeiger auf das Verbindungsobjekt hineinzulegen). Ein Completion-Paket kommt mit dem CompletionKey, dem OVERLAPPED-Zeiger dieses Vorgangs und der Anzahl übertragener Bytes an. Welche Verbindung (CompletionKey), welcher Vorgang (OVERLAPPED), wie weit er fortgeschritten ist (Byte-Anzahl) – der „Beleg des Vorgangs“ aus Teil 2 wird hier wieder aufgegriffen.27

Das Ziel ist nicht auf „Dateien“ beschränkt. Sockets, Named Pipes, Mailslots und mehr – jedes Handle, das Overlapped-I/O beherrscht, lässt sich verknüpfen.1 Das in Teil 1 gesehene Design „alles sieht wie eine Datei aus“ wirkt auch hier.

3.2. Die Reise eines Completion-Pakets

Worker-ThreadWarteschlange des Ports (FIFO)Kernel (IRP-Abschluss)Worker-ThreadWarteschlange des Ports (FIFO)Kernel (IRP-Abschluss)Wartet mit GetQueuedCompletionStatusBetrachtet das Paket und führt die Abschlussverarbeitung aus(Ausführung der Fortsetzung, Ausgeben des nächsten I/O usw.)Steht noch ein Paket in der Warteschlangewird es ohne Warten übergebenLegt Completion-Paket ab(Byte-Anzahl / CompletionKey / OVERLAPPED)Weckt einen wartenden Thread und übergibt esBeendet die Verarbeitung und ruft erneut GetQueuedCompletionStatus auf

Abbildung 3: Completion-Pakete werden FIFO abgelegt, und ein Worker durchläuft die Schleife aus Herausholen und Verarbeiten.

Ist ein asynchrones I/O abgeschlossen, wird ein Completion-Paket in FIFO-Reihenfolge in die Warteschlange des Ports gelegt. Ein Worker ruft GetQueuedCompletionStatus auf, um ein Paket zu erhalten, und ruft es nach Abschluss der Verarbeitung erneut auf – diese Schleife ist das Grundgerüst der IOCP-Programmierung.17 Es gibt auch GetQueuedCompletionStatusEx, das mehrere Pakete auf einmal herausholt und bei hochfrequentem I/O die Anzahl der Aufrufe verringert.8

An dieser Stelle lohnt es sich, den klassischen Bug in der Worker-Schleife gleich auszumerzen. Liefert GetQueuedCompletionStatus FALSE zurück, aber der OVERLAPPED-Zeiger kommt als nicht NULL zurück, bedeutet das: „Das Completion-Paket eines fehlgeschlagenen I/O-Vorgangs konnte erfolgreich abgeholt werden.“7 Ein fehlgeschlagener Vorgang benötigt weiterhin Aufräumarbeiten (Fehlerbehandlung sowie das Freigeben von Beleg und Puffer aus Teil 2), sodass dieses Paket verarbeitet werden muss. Nur wenn OVERLAPPED NULL zurückkommt, lässt sich sagen, dass „das Paket selbst nicht abgeholt werden konnte“ (Timeout, Schließen des Ports und Ähnliches). Schreibt man nachlässig if (!GetQueuedCompletionStatus(...)) break;, gehen alle fehlgeschlagenen I/O-Vorgänge unbemerkt verloren.

Hier das Grundgerüst zum Abtippen. Die Reihenfolge der Prüfungen entspricht direkt der obigen Erklärung.

/* Grundgerüst einer IOCP-Worker-Schleife (C / Win32) */
for (;;) {
    DWORD        bytes = 0;
    ULONG_PTR    key   = 0;
    OVERLAPPED  *ov    = NULL;

    BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);

    if (!ok && ov == NULL) {
        /* Kein Paket abgeholt (z. B. weil der Port geschlossen wurde). Nur hier darf mit break ausgestiegen werden */
        break;
    }
    if (!ok) {
        /* ov != NULL -> Completion-Paket eines "fehlgeschlagenen I/O" abgeholt.
           Aufräumarbeiten (Fehlerbehandlung, Freigabe von Beleg und Puffer) sind weiterhin nötig, also verarbeiten statt abbrechen */
        DWORD err = GetLastError();
        handle_failed_io(key, ov, err);
        continue;
    }
    if (key == SHUTDOWN_KEY) {
        /* Ein mit PostQueuedCompletionStatus abgelegtes Shutdown-Paket (Kapitel 4) */
        break;
    }
    handle_completed_io(key, ov, bytes);   /* Normale Abschlussverarbeitung. Kurz halten (Abschnitt 3.4) */
}

Entscheidend ist, ok == FALSE nicht mit einer einzigen Verzweigung abzuhandeln, sondern danach zu unterscheiden, ob ov NULL ist. Auch bei gesetztem Timeout (etwas anderes als INFINITE) bleibt die Prüfung gleich – ein Timeout zeigt sich als ok == FALSE mit ov == NULL.

Ruft ein Thread GetQueuedCompletionStatus zum ersten Mal auf, wird dieser Thread mit diesem Port verknüpft (ein Thread kann gleichzeitig nur mit einem Port verknüpft sein).1 Man merkt sich das genau mit dem Bild „ein festes Worker-Team ist dem Port zugeteilt“.

3.3. Threads werden LIFO geweckt

Hier zeigt sich die Raffinesse im Design von IOCP. Pakete werden FIFO abgelegt, aber wartende Threads werden LIFO geweckt. Das heißt: Der Thread, der zuletzt gearbeitet hat, holt sich auch das nächste Paket.1

Wartende Threads (LIFO-Stack)P1, P2 und P3 gehen allezuerst an Thread A, wenn er frei istNur wenn A schon belegt istWacht nur selten aufThread A (lief bis eben, warm)Thread B (schläft schon eine Weile)Thread C (schläft schon lange)Warteschlange: P1 → P2 → P3 (FIFO)

Abbildung 4: LIFO-Freigabe. Je stärker die Auslastung, desto mehr dreht sich derselbe Thread weiter, während untätige Threads schlafen bleiben dürfen.

Dieses Design bringt zwei Vorteile.

  • Kontextwechsel finden nicht statt. Solange Pakete in der Warteschlange verbleiben, erhält ein Thread, der die Verarbeitung beendet und GetQueuedCompletionStatus aufruft, sofort und ohne Warten das nächste Paket und läuft weiter. Die Dokumentation hält für ein Szenario mit Concurrency-Wert 1 ausdrücklich fest, dass „kein Thread-Wechsel stattfindet“.1
  • Der Cache bleibt warm. Weil derselbe Thread weiterläuft, verbleiben Stack und schedulingrelevanter Zustand mit hoher Wahrscheinlichkeit im CPU-Cache. Schlafende Threads werden günstig als Reserve für Lastspitzen vorgehalten.

3.4. Der Concurrency-Wert — „ausführbar“ wird gezählt

Der bei der Porterstellung übergebene NumberOfConcurrentThreads ist der Concurrency-Wert. Er ist die Obergrenze für die Anzahl der mit diesem Port verknüpften ausführbaren (runnable) Threads, und solange diese Obergrenze erreicht ist, kann kein weiterer Thread ein Paket entgegennehmen.1 Übergibt man 0, wird die Anzahl der Prozessoren im System verwendet, und auch die Dokumentation nennt die CPU-Anzahl als insgesamt bestes Maximum.21

Clever ist, dass dieser Wert nicht „wache Threads“ zählt, sondern „ausführbare“ Threads.

darunteran der ObergrenzeEin Paket trifft in der Warteschlange einLiegt die Anzahl ausführbarer Threadsunter dem Concurrency-Wert?Einen wartenden Thread wecken und verarbeiten lassenNiemanden wecken, im Warteschlange belassen(ein laufender Thread holt es sich)Ein laufender Thread istaus einem anderen Grund in einen Wartezustand eingetretenSo viele wartende Threads wecken,wie die ausführbare Anzahl gesunken ist

Abbildung 5: Concurrency-Steuerung. Weil die Obergrenze für die „ausführbare Anzahl“ gilt, wird automatisch aufgefüllt, sobald jemand blockiert.

Gerät ein laufender Worker in irgendeine Art von Wartezustand (Lock, Seitenfehler, versehentlich synchrones I/O), sinkt die ausführbare Anzahl, sodass das System einen wartenden Thread weckt, um die Lücke zu füllen.1 Deshalb ist es üblich, nicht nur genau „so viele Worker wie CPUs“ anzulegen, sondern mehr Threads als den Concurrency-Wert im Wartezustand vorzuhalten. Mischen sich lange Berechnungen in die Arbeit, kann man auch den Concurrency-Wert selbst erhöhen – letztlich, so die Position der Dokumentation, sollte man ihn mittels Profiling anpassen.1

Das Auffüllen ist allerdings nicht allmächtig. Wacht ein blockierter Thread später auf, übersteigt die ausführbare Anzahl in diesem Moment die Obergrenze (auch die Dokumentation erwähnt dieses Überschreiten).1 Die Completion-Verarbeitung kurz zu halten ist der oberste Grundsatz, und das wirkt in Kapitel 6 bei .NET in genau derselben Form.

4. Der Werkzeugkasten — die APIs rund um den Port

  • PostQueuedCompletionStatus — legt, ohne ein I/O auszugeben, ein eigenes Completion-Paket in die Warteschlange.3 Arbeitsaufträge an Worker, Shutdown-Anweisungen (so viele Beendigungspakete – umgangssprachlich „Poison-Pill“-Pakete – wie es Worker gibt, ablegen), Benachrichtigungen von anderen Threads – dass sich I/O-Abschlüsse und eigene Nachrichten in derselben Warteschlange, derselben Schleife verarbeiten lassen, vereinfacht das Design erheblich.
  • GetQueuedCompletionStatusEx — holt mehrere Completion-Pakete auf einmal ab. Wirksam bei hochfrequentem I/O, wo der Overhead von einem Aufruf pro Paket ins Gewicht fällt.8
  • SetFileCompletionNotificationModes — für den in Kapitel 5 von Teil 2 gesehenen Fall „asynchron ausgegeben, aber synchron abgeschlossen“ lässt sich ein Modus wählen, der kein Paket am Port ablegt (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Steht das Ergebnis eines synchronen Abschlusses ohnehin sofort fest, ist der Umweg über die Warteschlange reine Verschwendung – das ist diese Beschleunigung.9
  • Die Windows-Thread-Pool-APICreateThreadpoolIo / StartThreadpoolIo verwenden intern IOCP, nehmen Ihnen aber Erstellung und Verwaltung der Threads ab. Microsoft empfiehlt, für neue Serveranwendungen zunächst diese in Betracht zu ziehen und nur dann zum rohen IOCP zu greifen, wenn Sie Concurrency-Wert und Thread-Verwaltung explizit steuern möchten.1 Und der .NET-Thread-Pool ist genau diese „IOCP plus automatisierte Thread-Verwaltung“, implementiert als Funktion der .NET-Runtime.

Nur drei Fallstricke seien noch genannt. (1) Nicht lange innerhalb eines Workers blockieren (das Auffüllen aus Abschnitt 3.4 mildert die Verschlechterung nur ab). (2) Die Identifikation eines Completion-Pakets erfolgt zweistufig über CompletionKey (pro Handle) und OVERLAPPED (pro Vorgang) – die Lebensdauerverwaltung des „Belegs“ aus Teil 2 (nicht vor Abschluss freigeben) ist auch hier lebenswichtig. (3) Kein Handle schließen, solange noch ein unvollständiges I/O aussteht – das Cleanup-Verhalten (Kapitel 6 von Teil 1) und die Etikette beim Abbrechen (Kapitel 6 von Teil 2) gelten unverändert.

5. Der .NET-Thread-Pool — zwei Etagen auf IOCP

Ab hier geht es um das eigentliche Thema: „den Keller unter async/await“.

Der .NET-Thread-Pool kennt zwei Arten von Threads: Worker-Threads, die Task.Run und Fortsetzungen ausführen, und I/O-Completion-Threads, die den Abschluss asynchroner I/O entgegennehmen. Dass ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) zwei getrennte Zahlen zurückgibt, liegt genau daran, dass die Internas tatsächlich zwei Etagen haben.4

Und unter Windows besitzt der Thread-Pool einen eigenen I/O-Completion-Port. Die aktuelle Low-Level-API, um ein OS-Handle mit diesem Port zu verknüpfen, ist ThreadPoolBoundHandle.BindHandle; asynchrones I/O auf einem gebundenen Handle wird zusammen mit NativeOverlapped behandelt (genau das .NET-seitige Gesicht des OVERLAPPED aus Teil 2). Öffnet ein FileStream oder Socket ein Handle im asynchronen Modus, geschieht diese Art der Bindung intern (das ältere ThreadPool.BindHandle erfüllt weiterhin dieselbe Aufgabe, aber für neuen Code ist dies die richtige Wahl).5 Zusammengefasst:

  • „Handle im asynchronen Modus + OVERLAPPED“ aus Teil 2 ist der Mechanismus zum Ausgeben
  • Das IOCP aus diesem Artikel ist der Mechanismus zum Empfangen des Abschlusses
  • Die I/O-Completion-Threads des .NET-Thread-Pools sind das Worker-Team, das die GetQueuedCompletionStatus-Schleife durchläuft

— und mit dieser Zuordnung wird aus dem Win32-Bild unverändert das .NET-Bild.

5.1. await ReadAsync – der vollständige Umlauf

Abbildung 7 aus Teil 2 hat den Inhalt der Box „echtes asynchrones I/O“ nur benannt, nicht geöffnet. Diesmal öffnen wir sie ganz.

Ort der FortsetzungI/O-Completion-ThreadIOCP des Thread-PoolsKernel(IRP-Ausgabe bis Abschluss)Aufrufender Thread(z. B. UI-Thread)Ort der FortsetzungI/O-Completion-ThreadIOCP des Thread-PoolsKernel(IRP-Ausgabe bis Abschluss)Aufrufender Thread(z. B. UI-Thread)await registriert eine Fortsetzung auf dem unvollständigen Taskund gibt den Thread ab (bei UI: zurück zur Nachrichtenschleife)Das Gerät arbeitetwährenddessen wartet nirgends ein ThreadBestimmt das Ergebnis (Byte-Anzahl, Status)schließt den Task ab und plant die Fortsetzung einDer Code nach dem await läuftReadAsync gibt einen asynchronen Lesevorgang aus(mit dem Äquivalent von OVERLAPPED)ERROR_IO_PENDING (kehrt sofort zurück)Legt ein Completion-Paket abWeckt LIFO einen Thread und übergibt esWirft es zum erfassten Kontext(zum UI-Thread / ohne Erfassung in den Thread-Pool)

Abbildung 6: Der vollständige Umlauf eines await. Threads arbeiten nur beim „Ausgeben“ und „nach Abschluss“ – die Wartezeit selbst läuft mit null Threads ab.

5.2. Die genaue Bedeutung von „ein I/O-Wartevorgang verbraucht keinen Thread“

Was dieses Diagramm verdeutlichen soll: Zwischen dem Ausgeben und dem Abschluss existiert nirgends – weder im User-Mode noch im Kernel – ein Thread, der nur dazu da ist, auf diesen Abschluss zu warten. Auch Microsofts eigener Async-Leitfaden (Async in Depth) erklärt für I/O-gebundene Tasks – bis hinunter zu Gerätetreibern und Interrupts – dass „nirgends ein Thread existiert, der nur zum Warten auf den Abschluss da ist“.6 Es gibt zwar Momente innerhalb des Kernels, in denen ein Treiber einen Teil der Arbeit an einen System-Worker-Thread abgibt. Das ist jedoch kurze Arbeit, um die Anfrage voranzubringen, kein Thread, der blockiert und weiter wartet, bis der Abschluss eintritt – das ist der Umfang dessen, was hier garantiert wird.

Aufbauend auf Teil 1 ausgedrückt: Ein IRP hält sich nicht als Thread, sondern als Datenstruktur im Gerätestack auf (Teil 1); das Ausgeben kehrt sofort mit ERROR_IO_PENDING zurück (Teil 2); und der Abschluss trifft als Kette von Ereignissen ein – Interrupt, dann Completion-Paket (dieser Artikel). Das Design schafft es, den Zustand „Warten“ aufrechtzuerhalten, ohne die teure Ressource Thread zu benötigen.

Deshalb kann eine Anwendung, die async/await richtig einsetzt, den Zustand „gleichzeitig sind 10.000 I/O-Vorgänge unterwegs“ mit einem Dutzend Threads aufrechterhalten. Umgekehrt gilt aber: Diese Eigenschaft gehört ausschließlich zu I/O-gebundenen Tasks. CPU-Arbeit, die mit Task.Run umhüllt wird, belegt selbstverständlich durchgängig einen Worker-Thread, und auch die „scheinbare Asynchronität“ aus Kapitel 7 von Teil 2 lässt im Hintergrund einen Thread schlafen.

5.3. Wo läuft die Fortsetzung?

Der letzte Pfeil in Abbildung 6 – „wohin die Fortsetzung geworfen wird“ – folgt einer klaren Regel.6

janeinja (z. B. UI-Thread von WPF/WinForms)nein (z. B. Konsole, ASP.NET Core)Der Task ist abgeschlossen, die Fortsetzung soll ausgeführt werdenWurde zum Zeitpunkt des awaitein SynchronizationContext oderein nicht-standardmäßiger TaskScheduler erfasst?War ConfigureAwait(false)angehängt?Zurückwerfen zum erfassten Zielz. B. Ausführung in der Nachrichtenschleifedes UI-Threads oder auf diesem TaskSchedulerKeine Pflicht, an einen bestimmten Ort zurückzukehrenläuft synchron auf dem abschließenden Thread weiteroder auf einem Thread-Pool-Thread

Abbildung 7: Wohin die Fortsetzung geht. Dass Sie nach einem await direkt die UI anfassen können, liegt daran, dass zum erfassten Kontext zurückgeworfen wird.

  • Wenn Sie auf dem UI-Thread von WPF oder WinForms awaiten, wird der SynchronizationContext erfasst, und der Rest kehrt zum UI-Thread zurück. Deshalb löst das Anfassen eines Steuerelements direkt nach einem await keine Thread-Verletzung aus. Die praktische Seite dieses Designs behandelt „WPF/WinForms-async und der UI-Thread auf einem Blatt“.
  • Erfasst wird nicht nur der SynchronizationContext – awaitet man auf einem nicht-standardmäßigen TaskScheduler, wird auch dieser erfasst. Wo keins von beidem existiert (Konsole, ASP.NET Core, Code, der bereits im Thread-Pool läuft), besteht keine Pflicht, an einen bestimmten Ort zurückzukehren, sodass die Fortsetzung entweder im Thread-Pool läuft oder einfach synchron auf dem Thread weiterläuft, der den Task abgeschlossen hat.
  • ConfigureAwait(false) bedeutet ausdrücklich „Sie müssen nicht zurückkehren“, ist aber keine Garantie dafür, dass „es immer in den Thread-Pool wechselt“. Wird ein bereits abgeschlossener Task awaitet (dazu zählt auch der synchrone Abschluss aus Teil 2), tritt gar keine Wartezeit auf, und die Ausführung läuft einfach auf dem aktuellen Thread weiter. Wie man in Bibliothekscode zwischen beidem unterscheidet, ist in „C# async/await-Praxis-Entscheidungstabelle“ nachzulesen.

Dieser letzte Punkt wird häufig missverstanden, deshalb legen wir Code nebeneinander. Zunächst Code, der unter der irrigen Annahme geschrieben wurde, ConfigureAwait(false) sei eine Anweisung, in den Thread-Pool zu wechseln.

// Schlechtes Beispiel: Geschrieben unter dem Irrtum "durch ConfigureAwait(false) läuft alles Weitere im Thread-Pool"
private async void OnLoadClick(object sender, EventArgs e)
{
    string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);

    // Erwartung: hier sind wir im Thread-Pool, daher friert die UI nicht ein
    // Realität: War der Task zum Zeitpunkt des await bereits abgeschlossen, tritt keine Wartezeit ein,
    //           und es läuft auf dem UI-Thread weiter -> diese schwere Verarbeitung friert die UI ein
    var rows = ParseHeavy(csv);

    // Und selbst bei asynchronem Abschluss ist die Fortsetzung nicht zwingend der UI-Thread
    resultLabel.Text = $"{rows.Count} Zeilen";   // -> kann eine Thread-Verletzung als Ausnahme auslösen
}

ConfigureAwait(false) sagt lediglich: „Sie müssen nicht zum erfassten Kontext zurückkehren.“ Wo es läuft, ist damit nicht festgelegt, daher kann die Ausführung auf dem UI-Thread bleiben oder auf einem I/O-Completion-Thread oder Thread-Pool-Thread weiterlaufen. Dass beides möglich ist, ist der Grund, warum dieser Code kaputt ist.

Trennt man die Absicht auf, sieht das so aus:

// Gutes Beispiel: "Zur UI zurückkehren oder nicht" und "wo die schwere Arbeit läuft" getrennt anweisen
private async void OnLoadClick(object sender, EventArgs e)
{
    // In UI-Code die Erfassung beibehalten (die Fortsetzung kehrt zum UI-Thread zurück)
    string csv = await File.ReadAllTextAsync(path);

    // Soll CPU-lastige Arbeit in den Thread-Pool, wird das mit Task.Run explizit gemacht
    var rows = await Task.Run(() => ParseHeavy(csv));

    // Hier ist es garantiert der UI-Thread. Steuerelemente lassen sich gefahrlos anfassen
    resultLabel.Text = $"{rows.Count} Zeilen";
}

// Auf der Bibliotheksseite (Code ohne UI) wird umgekehrt erklärt, dass "keine Rückkehr nötig ist"
public async Task<string> ReadConfigAsync(string path)
{
    string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
    return text.Trim();   // hängt nicht vom Kontext des Aufrufers ab
}

Das Kriterium ist einfach. Code, der die UI anfasst, behält die Erfassung bei, und wenn sich das Ziel ändern soll, wird das mit Task.Run explizit gemacht. Betrachten Sie ConfigureAwait(false) als Werkzeug der Bibliotheksseite, um zu erklären: „Das funktioniert sicher, egal in welchem Kontext der Aufrufer sich befindet.“

5.4. Das eigentliche Problem — Thread-Pool-Starvation

Zum Schluss noch ein Muster, mit dem dieser Keller verstopft. Wartet man synchron (synchrones I/O, Task.Result/Wait(), ein langes Warten auf einen Lock) innerhalb einer Fortsetzung oder eines Workers, bleibt dieser Thread belegt. IOCPs eigenes Auffüllen (Abschnitt 3.4) greift sofort, solange noch wartende Reserve-Threads vorhanden sind. Sind die Reserven aber erschöpft, betritt man den Bereich, in dem der Thread-Pool neue Threads nur langsam nachlegt. Sobald Last auftritt, kommt es zu Starvation (Aushungerung) – „es gibt eine Fortsetzung, die laufen soll, aber keinen Thread, der sie ausführt“ – und die gesamte Anwendung wird träge.

Es gibt zwei Ansatzpunkte für die Untersuchung. Mit ThreadPool.GetAvailableThreads die Verfügbarkeit von Worker- und I/O-Completion-Threads betrachten.4 Und mit Event-Tracing den tatsächlichen Zustand von Thread-Pool und Blockierungen erfassen – das Vorgehen ist in „Mit PerfView und dotnet-trace „langsam“ eingrenzen” zusammengefasst. Die Vorbeugung ist einfach: Den async-Weg bis zum Ende async lassen (kein Vermischen mit sync-over-async) – mehr braucht es nicht.

6. Zusammenfassung

  • IOCP ist ein Mechanismus, der Completion-Queue (FIFO) und Steuerung der Thread-Anzahl vereint. Die Abschlüsse vieler Handles laufen in einem Port zusammen und werden in einer GetQueuedCompletionStatus-Schleife verarbeitet.17
  • Threads werden LIFO freigegeben, sodass sich bei zunehmender Auslastung immer stärker derselbe Thread weiterdreht und Kontextwechsel sowie Cache-Misses minimiert werden.1
  • Der Concurrency-Wert ist die Obergrenze für die Anzahl ausführbarer Threads, Ausgangspunkt ist die CPU-Anzahl (Angabe 0). Blockiert ein laufender Thread, füllen wartende Threads auf, doch Completion-Verarbeitung kurz zu halten bleibt der oberste Grundsatz.12
  • Mit PostQueuedCompletionStatus lassen sich auch eigene Pakete durchleiten. Für neue Implementierungen ist die Windows-Thread-Pool-API (intern IOCP) die erste Wahl.31
  • Der .NET-Thread-Pool hat zwei Etagen – Worker-Threads plus I/O-Completion-Threads –, und asynchrone Handles binden sich an das eigene IOCP des Pools. Für die I/O-Wartezeit von await existiert kein Thread; nur die Fortsetzung nach Abschluss läuft auf einem Thread.456
  • Wohin die Fortsetzung geht, hängt vom erfassten Kontext ab (zurück zum UI-Thread oder Weiterlaufen im Thread-Pool). ConfigureAwait(false) ist eine Anweisung, diese Erfassung zu unterlassen.6
  • Das eigentliche Problem ist fast immer Thread-Pool-Starvation durch eingeschlichenes sync-over-async. Lassen Sie den async-Weg bis zum Ende async.

Weiter geht es mit Teil 4, „Der Cache-Manager — Wann erreicht Ihr WriteFile tatsächlich die Festplatte?“. In Teil 2 hieß es, „liegt es bereits im Cache, erfolgt der Abschluss synchron“, und auch in diesem Artikel ist der Schatten des Caches mehrfach aufgetaucht. Als Nächstes stellen wir uns diesem Cache selbst direkt – verzögertes Schreiben, Read-Ahead, FILE_FLAG_NO_BUFFERING, und den Bedingungen, unter denen „Daten, die man für geschrieben hielt, bei einem Stromausfall verschwinden“.

Verwandte Artikel

Verwandte Beratungsleistungen

Die KomuraSoft LLC übernimmt das Design von Windows-Anwendungen und -Servern, die viele gleichzeitige Verbindungen und gleichzeitiges I/O verarbeiten, sowie die Ursachenuntersuchung von Performance-Problemen wie „der Thread-Pool verstopft“ oder „wir sind asynchron geworden, aber es ist nicht schneller“.

  1. Microsoft Learn, I/O Completion Ports. Dazu, dass I/O-Completion-Ports ein effizientes Threading-Modell zur Verarbeitung großer Mengen asynchroner I/O-Anfragen auf Multiprozessorsystemen bieten; dazu, dass Completion-Pakete beim Abschluss eines asynchronen I/O in FIFO-Reihenfolge in die Warteschlange des Ports gelegt werden; dazu, dass sich das Ziel nicht auf Dateien auf der Festplatte beschränkt, sondern jedes Handle einschließt, das Overlapped-I/O unterstützt, etwa Sockets, Named Pipes und Mailslots; dazu, dass am Port wartende Threads in LIFO-Reihenfolge freigegeben werden und bei einem Concurrency-Wert von 1 kein Thread-Wechsel stattfindet, solange die Warteschlange gefüllt ist; dazu, dass ein Thread beim ersten Aufruf von GetQueuedCompletionStatus mit diesem Port verknüpft wird und gleichzeitig nur mit einem Port verknüpft sein kann; dazu, dass der Concurrency-Wert die Anzahl ausführbarer Threads begrenzt, wobei das insgesamt beste Maximum die CPU-Anzahl ist; dazu, dass ein wartender Thread ein Completion-Paket verarbeiten kann, wenn ein laufender Thread aus einem anderen Grund in einen Wartezustand eintritt (und dass ein aufwachender blockierter Thread die Obergrenze vorübergehend überschreiten kann); sowie dazu, dass neue Serveranwendungen zunächst die Windows-Thread-Pool-API (CreateThreadpoolIo usw., die intern IOCP verwendet) in Betracht ziehen sollten.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

  2. Microsoft Learn, CreateIoCompletionPort function. Dazu, dass CreateIoCompletionPort sowohl das Neuerstellen eines I/O-Completion-Ports als auch das Verknüpfen eines Handles mit einem bestehenden Port erledigt; dazu, dass bei der Verknüpfung ein CompletionKey (ein benutzerdefinierter Wert) angegeben werden kann und im Completion-Paket enthalten ist; sowie dazu, dass NumberOfConcurrentThreads die Anzahl der Threads begrenzt, die Completion-Pakete gleichzeitig verarbeiten können, wobei bei Angabe von 0 die Anzahl der Prozessoren im System verwendet wird.  2 3 4 5

  3. Microsoft Learn, PostQueuedCompletionStatus function. Dazu, dass PostQueuedCompletionStatus ein anwendungsdefiniertes Completion-Paket in die Warteschlange eines I/O-Completion-Ports legen kann, ohne ein asynchrones I/O zu starten, und dass ein Port dadurch zusätzlich zum Empfang von I/O-Abschlüssen auch als Kanal für die Kommunikation von anderen Threads im Prozess dienen kann.  2 3

  4. Microsoft Learn, The managed thread pool. Dazu, dass der .NET-Thread-Pool Worker-Threads sowie Threads für den Abschluss asynchronen I/O bereitstellt; dazu, dass sich mit ThreadPool.GetAvailableThreads die verfügbaren Zahlen von Worker-Threads und I/O-Completion-Threads getrennt abrufen lassen; sowie dazu, dass Thread-Pool-Threads nicht für lange Zeit blockiert werden sollten.  2 3 4

  5. Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. Dazu, dass ThreadPoolBoundHandle.BindHandle ein ThreadPoolBoundHandle zurückgibt, das ein Betriebssystem-Handle an den System-Thread-Pool (und dessen I/O-Completion-Port) bindet; dazu, dass Low-Level-asynchrones I/O auf einem gebundenen Handle zusammen mit NativeOverlapped ausgeführt wird; sowie dazu, dass der Abschluss asynchronen I/O anschließend vom Thread-Pool behandelt wird.  2 3

  6. Microsoft Learn, Async in depth (.NET). Dazu, dass bei einem I/O-gebundenen Task, sobald der Aufruf an das Betriebssystem übergeben wurde, kein dedizierter Thread existiert, der auf dessen Abschluss wartet (die Idee „there is no thread“); dazu, dass der Abschluss über Gerätetreiber und Interrupts signalisiert wird und die registrierte Fortsetzung ausgeführt wird; dazu, dass await standardmäßig den aktuellen Kontext (etwa einen SynchronizationContext) erfasst und die Fortsetzung dort ausführt, wobei sie im Thread-Pool läuft, wenn kein erfassenswerter Kontext vorhanden ist; sowie dazu, dass ConfigureAwait(false) diese Erfassung deaktiviert.  2 3 4 5 6

  7. Microsoft Learn, GetQueuedCompletionStatus function. Dazu, dass GetQueuedCompletionStatus ein Completion-Paket aus der Warteschlange des Completion-Ports abholt (und wartet, wenn keins vorhanden ist); dazu, dass das abgeholte Ergebnis die übertragenen Bytes, den CompletionKey und den OVERLAPPED-Zeiger liefert; sowie dazu, dass ein Rückgabewert von FALSE zusammen mit einem nicht-NULL-OVERLAPPED-Zeiger bedeutet, dass „das Completion-Paket eines fehlgeschlagenen I/O-Vorgangs abgeholt wurde“, während ein NULL-OVERLAPPED-Zeiger allein bedeutet, dass das Paket selbst nicht abgeholt werden konnte (etwa wegen eines Timeouts).  2 3 4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. Dazu, dass GetQueuedCompletionStatusEx mehrere Completion-Pakete auf einmal abholen kann und die Anzahl der abgeholten Einträge zurückgegeben wird.  2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. Dazu, dass FILE_SKIP_COMPLETION_PORT_ON_SUCCESS erlaubt, kein Paket am Completion-Port abzulegen, wenn ein I/O sofort erfolgreich ist und das Ergebnis bereits auf der Stelle feststeht. 

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist ein I/O-Completion-Port (IOCP)?
Es ist ein Mechanismus des Windows-Kernels, der die Abschlussbenachrichtigungen vieler asynchroner I/O-Vorgänge in einer einzigen Warteschlange bündelt und in einem Zug auch steuert, wie viele Threads gleichzeitig laufen dürfen, um diese Warteschlange abzuarbeiten. Mit CreateIoCompletionPort erstellen Sie einen Port, verknüpfen damit Handles wie Dateien oder Sockets, und jedes Mal, wenn ein asynchrones I/O abgeschlossen wird, wird ein Completion-Paket in die FIFO-Warteschlange des Ports gelegt. Worker-Threads holen Pakete mit GetQueuedCompletionStatus aus der Warteschlange und verarbeiten sie. Der entscheidende Punkt ist, dass dies keine reine Benachrichtigungswarteschlange ist – es ist zugleich ein Scheduling-Mechanismus, der die Anzahl ausführbarer Threads auf oder unter einem Concurrency-Wert hält. Dadurch lassen sich sehr viele gleichzeitige I/O-Vorgänge effizient mit wenigen Threads abarbeiten, und es ist das Fundament sowohl von Windows-Serverimplementierungen als auch des .NET-Thread-Pools.
Auf welchen Wert sollte der Concurrency-Wert (die Anzahl gleichzeitiger Ausführungen) von IOCP gesetzt werden?
Die Microsoft-Dokumentation besagt, dass das insgesamt beste Maximum die Anzahl der CPUs des Computers ist. Übergeben Sie 0 für NumberOfConcurrentThreads bei CreateIoCompletionPort, wird die Anzahl der Prozessoren im System verwendet – im Zweifel ist 0 also ein vernünftiger Ausgangspunkt. Dieser Wert begrenzt die Anzahl ausführbarer Threads, nicht die Anzahl wartender Threads. Tritt ein laufender Thread aus irgendeinem Grund in einen Wartezustand ein, weckt das System einen anderen wartenden Thread, um die Lücke zu füllen. Mischen sich in die Verarbeitung also lange Berechnungen oder Blockierungen, können Sie auch einen größeren Concurrency-Wert wählen, um mehr Pakete gleichzeitig zu verarbeiten. Letztlich wird empfohlen, den Wert in Kombination mit Profiling anzupassen.
Warum kann man sagen, dass ein async/await-I/O-Wartevorgang keinen Thread verbraucht?
Weil es zwischen dem Ausgeben des I/O und seinem Abschluss nirgends einen dedizierten Thread gibt, der sich um diesen Vorgang kümmert. Wie wir in Teil 1 und Teil 2 gesehen haben, fließt eine ausgegebene Anfrage als IRP durch den Gerätestack, und der Aufruf kehrt sofort mit ERROR_IO_PENDING zurück. await registriert an dieser Stelle nur eine Fortsetzung auf dem noch nicht abgeschlossenen Task und gibt den Thread ab. Während das Gerät als Hardware arbeitet, gibt es weder im User-Mode noch im Kernel einen Thread, der einfach nur wartet. Nach Abschluss wird ein Completion-Paket in das IOCP des Thread-Pools gelegt, und erst dann läuft kurz ein I/O-Completion-Thread, um die registrierte Fortsetzung einzuplanen. Mit anderen Worten: Ein Thread wird nur im Moment des Ausgebens und bei der Nachbearbeitung nach Abschluss verwendet – die eigentliche Wartezeit selbst läuft mit null Threads ab.
Auf welchem Thread läuft die Fortsetzung von await weiter?
Standardmäßig wird der zum Zeitpunkt des await geltende SynchronizationContext (oder TaskScheduler) erfasst, und die Fortsetzung wird dorthin zurückgeworfen. Wenn Sie auf dem UI-Thread von WPF oder WinForms awaiten, läuft der Rest auf dem UI-Thread weiter – deshalb können Sie direkt nach dem await Steuerelemente anfassen. Gibt es keinen erfassenswerten Kontext (Konsolenanwendung, ASP.NET Core, Code, der bereits im Thread-Pool läuft, und Ähnliches), läuft die Fortsetzung entweder auf einem Thread-Pool-Thread oder einfach auf dem Thread weiter, der den Task abgeschlossen hat. ConfigureAwait(false) stoppt diese Erfassung, ist aber keine Garantie dafür, dass „es immer in den Thread-Pool wechselt“ – es ist eine Anweisung, dass „es nicht an einen bestimmten Ort zurückkehren muss“. Wird ein bereits abgeschlossener Task awaitet, tritt gar keine Wartezeit auf, und die Ausführung läuft synchron auf dem aktuellen Thread weiter. In Bibliothekscode wird ConfigureAwait(false) empfohlen, um unnötige Umwege zum UI-Thread zu vermeiden und den Keim von Kontextabhängigkeiten und Deadlocks im Ansatz zu ersticken.
Was passiert, wenn man in einem IOCP-Worker-Thread oder einem .NET-I/O-Completion-Thread lange blockiert?
Es geht nicht sofort kaputt, aber die Leistung verschlechtert sich, weil man sich außerhalb dessen bewegt, wofür der Mechanismus ausgelegt ist. IOCP füllt auf, indem es einen wartenden Thread weckt, sobald ein laufender Thread in einen Wartezustand eintritt, aber jede Auffüllung lässt den Concurrency-Grad anschwellen und erhöht die Kontextwechsel. Wird das Blockieren zum Normalfall, stauen sich Pakete in der Warteschlange, und die gesamte Completion-Verarbeitung verzögert sich. Bei .NET gilt dasselbe: Wartet man synchron – etwa mit synchronem I/O oder Task.Result – innerhalb eines I/O-Completion-Threads oder einer Fortsetzung, führt das zu Thread-Pool-Starvation (Aushungerung). Die Grundregel lautet, Completion-Verarbeitung und Fortsetzungen kurz zu halten und schwere Arbeit auszulagern. Mit ThreadPool.GetAvailableThreads lässt sich die Verfügbarkeit von Worker-Threads und I/O-Completion-Threads beobachten, was bei der Untersuchung von Engpässen hilfreich ist.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog