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
awaitläuft → wird in Abschnitt 5.3 beantwortet (der erfasste Kontext und die wahre Bedeutung vonConfigureAwait(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
GetQueuedCompletionStatusheraus (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
PostQueuedCompletionStatuslassen 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
awaitexistiert 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.
flowchart TB
subgraph A["Ein Thread pro Verbindung (synchrones I/O)"]
T1["Thread 1: wartet auf read für Verbindung 1"]
T2["Thread 2: wartet auf read für Verbindung 2"]
T3["Thread 3: wartet auf read für Verbindung 3"]
TN["…die Anzahl der Threads wächst mit der Verbindungszahl<br/>die meisten schlafen nur und warten auf I/O"]
end
subgraph B["Das IOCP-Modell (asynchrones I/O)"]
Q["Completion-Queue<br/>(alle Abschlussbenachrichtigungen aller Verbindungen laufen hier zusammen)"]
W1["Worker 1"]
W2["Worker 2"]
WN["Worker sind wenige – etwa in Höhe der CPU-Anzahl"]
Q --> W1
Q --> W2
Q --> WN
end
Abbildung 1: Links wächst die Anzahl der Threads proportional zur Verbindungszahl. Rechts werden nur „eingetretene Ereignisse“ von wenigen Threads verarbeitet.
- 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“.
- „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
flowchart LR
subgraph SRC["Verknüpfte Handles (beliebig viele)"]
H1["Datei"]
H2["Socket"]
H3["Named Pipe"]
end
subgraph PORT["I/O-Completion-Port"]
Q["Warteschlange der Completion-Pakete (FIFO)<br/>Paket = übertragene Bytes +<br/>CompletionKey + OVERLAPPED-Zeiger"]
C["Concurrency-Steuerung<br/>ausführbare Thread-Anzahl ≤ Obergrenze"]
end
subgraph W["Worker-Threads"]
G1["Wartet mit GetQueuedCompletionStatus"]
G2["Wartet mit GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.steuert.-> W
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
sequenceDiagram
participant DRV as Kernel (IRP-Abschluss)
participant Q as Warteschlange des Ports (FIFO)
participant W as Worker-Thread
Note over W: Wartet mit GetQueuedCompletionStatus
DRV->>Q: Legt Completion-Paket ab<br/>(Byte-Anzahl / CompletionKey / OVERLAPPED)
Q->>W: Weckt einen wartenden Thread und übergibt es
Note over W: Betrachtet das Paket und führt die Abschlussverarbeitung aus<br/>(Ausführung der Fortsetzung, Ausgeben des nächsten I/O usw.)
W->>Q: Beendet die Verarbeitung und ruft erneut GetQueuedCompletionStatus auf
Note over Q: Steht noch ein Paket in der Warteschlange<br/>wird es ohne Warten übergeben
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
flowchart TB
Q["Warteschlange: P1 → P2 → P3 (FIFO)"]
subgraph TH["Wartende Threads (LIFO-Stack)"]
A["Thread A (lief bis eben, warm)"]
B["Thread B (schläft schon eine Weile)"]
C["Thread C (schläft schon lange)"]
end
Q -->|"P1, P2 und P3 gehen alle<br/>zuerst an Thread A, wenn er frei ist"| A
B -.->|"Nur wenn A schon belegt ist"| Q
C -.->|"Wacht nur selten auf"| Q
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
GetQueuedCompletionStatusaufruft, 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.
flowchart TB
P["Ein Paket trifft in der Warteschlange ein"]
Q{"Liegt die Anzahl ausführbarer Threads<br/>unter dem Concurrency-Wert?"}
RUN["Einen wartenden Thread wecken und verarbeiten lassen"]
HOLD["Niemanden wecken, im Warteschlange belassen<br/>(ein laufender Thread holt es sich)"]
BLK["Ein laufender Thread ist<br/>aus einem anderen Grund in einen Wartezustand eingetreten"]
COMP["So viele wartende Threads wecken,<br/>wie die ausführbare Anzahl gesunken ist"]
P --> Q
Q -->|"darunter"| RUN
Q -->|"an der Obergrenze"| HOLD
BLK --> COMP
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.8SetFileCompletionNotificationModes— 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-API —
CreateThreadpoolIo/StartThreadpoolIoverwenden 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.
sequenceDiagram
participant U as Aufrufender Thread<br/>(z. B. UI-Thread)
participant K as Kernel<br/>(IRP-Ausgabe bis Abschluss)
participant Q as IOCP des Thread-Pools
participant IO as I/O-Completion-Thread
participant C as Ort der Fortsetzung
U->>K: ReadAsync gibt einen asynchronen Lesevorgang aus<br/>(mit dem Äquivalent von OVERLAPPED)
K-->>U: ERROR_IO_PENDING (kehrt sofort zurück)
Note over U: await registriert eine Fortsetzung auf dem unvollständigen Task<br/>und gibt den Thread ab (bei UI: zurück zur Nachrichtenschleife)
Note over K: Das Gerät arbeitet<br/>währenddessen wartet nirgends ein Thread
K->>Q: Legt ein Completion-Paket ab
Q->>IO: Weckt LIFO einen Thread und übergibt es
Note over IO: Bestimmt das Ergebnis (Byte-Anzahl, Status)<br/>schließt den Task ab und plant die Fortsetzung ein
IO->>C: Wirft es zum erfassten Kontext<br/>(zum UI-Thread / ohne Erfassung in den Thread-Pool)
Note over C: Der Code nach dem await läuft
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
flowchart TB
A["Der Task ist abgeschlossen, die Fortsetzung soll ausgeführt werden"]
Q1{"Wurde zum Zeitpunkt des await<br/>ein SynchronizationContext oder<br/>ein nicht-standardmäßiger TaskScheduler erfasst?"}
Q2{"War ConfigureAwait(false)<br/>angehängt?"}
UI["Zurückwerfen zum erfassten Ziel<br/>z. B. Ausführung in der Nachrichtenschleife<br/>des UI-Threads oder auf diesem TaskScheduler"]
TP["Keine Pflicht, an einen bestimmten Ort zurückzukehren<br/>läuft synchron auf dem abschließenden Thread weiter<br/>oder auf einem Thread-Pool-Thread"]
A --> Q2
Q2 -->|"ja"| TP
Q2 -->|"nein"| Q1
Q1 -->|"ja (z. B. UI-Thread von WPF/WinForms)"| UI
Q1 -->|"nein (z. B. Konsole, ASP.NET Core)"| TP
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 derSynchronizationContexterfasst, und der Rest kehrt zum UI-Thread zurück. Deshalb löst das Anfassen eines Steuerelements direkt nach einemawaitkeine 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äßigenTaskScheduler, 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
PostQueuedCompletionStatuslassen 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
awaitexistiert 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
- Die Tiefen von Windows-I/O (Teil 1) — Jedes Lesen und Schreiben wird zum IRP: Das Gesamtbild des I/O-Systems
- Die Tiefen von Windows-I/O (Teil 2) — Synchrones und asynchrones I/O: Was OVERLAPPED wirklich bedeutet
- C# async/await-Praxis-Entscheidungstabelle – Task.Run und ConfigureAwait
- WPF/WinForms-async und der UI-Thread auf einem Blatt
- Der Irrtum, dass man bei TCP in denselben Einheiten empfängt, in denen man mit Send gesendet hat — Empfangsdesign für einen Byte-Stream
- Mit PerfView und dotnet-trace „langsam“ eingrenzen — Praktische Einführung in die .NET-Performance-Untersuchung
- Praktischer Leitfaden, um auf gewöhnlichem Windows so nah wie möglich an Soft-Realtime heranzukommen
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“.
- Windows-Anwendungsentwicklung
- Fehleruntersuchung und Ursachenanalyse
- Soft-Realtime-Windows-Anwendungsentwicklung
- Kontakt
Referenzlinks
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Die Tiefen von Windows-I/O (Teil 4) — Cache-Manager: Wann erreicht Ihr WriteFile tatsächlich die Festplatte?
Teil 4 einer bebilderten Reihe über den Windows-Cache-Manager. Behandelt den als Dateizuordnung implementierten Cache, Read-Ahead und ver...
Die Tiefen von Windows I/O (Teil 2) — Synchrones und asynchrones I/O: Was OVERLAPPED wirklich bedeutet
Teil 2 einer Serie, die Windows' synchrones und asynchrones I/O (Overlapped I/O) anhand von Diagrammen erklärt. Wir stellen die Bedeutung...
Die Tiefen von Windows I/O (Teil 1) — Jedes Lesen und Schreiben wird zu einem IRP: Das Gesamtbild des I/O-Systems
Teil 1 einer Serie, die das Windows-I/O-System von Grund auf erklärt. Wir stellen den Namensraum des Object Managers, die drei Objektarte...
Windows-Identitätswechseltoken richtig handhaben ── Rechte pro Thread ausleihen und sicher zurückgeben
Ein praxisorientierter Überblick über Windows-Identitätswechseltoken: Zugriffstoken, primäre Token, Thread-Token, Identitätswechselebenen...
Die Tiefen von Windows I/O (Teil 6, Finale) ── Filtertreiber und Minifilter: Warum Procmon und Virenscanner sich in die I/O einklinken können
Der Abschluss einer bebilderten Artikelserie über Windows-Filtertreiber und Minifilter. Er behandelt den Filter-Manager und Altitudes, Pr...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
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.