Änderungsverlauf (Erstfassung, veröffentlicht am 29. Jul 2026)
- Erstveröffentlichung
Diesen Artikel zitieren(DOI (registriertes Archiv): 10.5281/zenodo.22175290)
Die folgenden DOIs verweisen auf bereits archivierte Versionen, die vom aktuellen Text abweichen können. Verwenden Sie die URL dieser Seite, um auf den aktuellen Text zu verweisen.
Go Komura (2026). Die Tiefen von Windows I/O (Teil 3) — I/O-Completion-Ports (IOCP) und der .NET-Threadpool: Der Keller unter async/await. KomuraSoft LLC. https://comcomponent.com/de/blog/windows-iocp-dotnet-threadpool/
- DOI (registriertes Archiv)
- 10.5281/zenodo.22175290
- DOI (zuletzt registrierte Version)
- 10.5281/zenodo.22175291
Wie sammelt man I/O-Abschlüsse, wenn viele Verbindungen mit wenigen Threads laufen sollen? Wer arbeitet, während await wartet, und auf welchem Thread läuft der Code nach dem Abschluss weiter?
Diese Folge verfolgt den Weg von dem Mechanismus, mit dem ein I/O-Completion-Port (IOCP) Abschlussbenachrichtigungen sammelt, bis dahin, wie .NET diese Benachrichtigung entgegennimmt und die Fortsetzung von await ausführt. Trennt man „den Abschluss entgegennehmen“ von „den Ort der Fortsetzung festlegen“, fügen sich ConfigureAwait(false) und Threadpool-Starvation in denselben Ablauf.
Das letzte Mal (Teil 2) haben wir uns angesehen, wie asynchrones I/O ausgegeben wird und über welche vier Wege der Abschluss empfangen wird. Darunter ist IOCP der Mechanismus, sehr viele gleichzeitige I/O-Vorgänge mit wenigen Threads abzufangen.
Dies ist Teil 3 der Reihe „Die Tiefen von Windows I/O“. Der Gesamtaufbau steht am Anfang von Teil 1.
Vom konkreten Problem aus lesen
Wer der Reihe nach liest, prüft in Abschnitt 2 die Grenzen eines Entwurfs mit einem Thread je Verbindung, in den Abschnitten 3 und 4 Win32 und in Abschnitt 5 die Entsprechung in .NET. Wer mitten in einer Untersuchung steckt, geht von der folgenden Übersicht zur nötigen Erklärung.
| Was Sie wissen oder lösen wollen | Leseabschnitt |
|---|---|
| Warum Tausende gleichzeitiger Verbindungen nicht dieselbe Zahl Threads brauchen | Abschnitt 2: Verbindungszahl und Threadzahl, Abschnitt 3: Completion-Warteschlange und Threadsteuerung |
| Welches Handle und welches I/O abgeschlossen hat | Abschnitt 3.1: CompletionKey und OVERLAPPED |
| GetQueuedCompletionStatus hat FALSE zurückgegeben. Darf man aufräumen | Abschnitt 3.2: Fehlgeschlagenes I/O und fehlgeschlagene Entnahme |
| Das Verhältnis von FIFO, LIFO und Concurrency-Wert | Abschnitte 3.3–3.4: Wahl des wartenden Threads und Anzahl der ausführbaren |
| Ende-Benachrichtigung, gebündelte Entnahme, synchroner Abschluss | Abschnitt 4: APIs nach Zweck |
| Von der Ausgabe von await ReadAsync bis zum Wiederaufnehmen | Abschnitt 5.1: Ein Hin und Zurück von await |
| Die Reichweite von „I/O-Warten verbraucht keinen Thread“ | Abschnitt 5.2: Wartende Threads und verarbeitende Threads |
| Trotz ConfigureAwait(false) friert die UI ein oder lässt sich nicht anfassen | Abschnitt 5.3: Fortsetzungsziel und Auslagern von CPU-Arbeit |
| Steigt die Last, wird die gesamte asynchrone Verarbeitung langsam | Abschnitt 5.4: Synchrone Wartezeiten und Threadpool-Starvation |
1. Zuerst das Fazit
Was IOCP übernimmt
- IOCP vereint eine Warteschlange für Abschlussbenachrichtigungen mit der Steuerung der Threadanzahl. Completion-Pakete werden FIFO in die Warteschlange gelegt, Worker-Threads holen sie mit
GetQueuedCompletionStatusheraus (Abschnitt 3).1 - Threads werden LIFO geweckt. Der Thread, der zuletzt gearbeitet hat und noch warm ist, holt auch das nächste Paket — solange die Warteschlange gefüllt bleibt, kommt es 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
Worauf es bei der Wahl der API ankommt
- Den Port kann man 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 (Abschnitt 4).3 - Für neue Serverimplementierungen wird statt des rohen IOCP die Windows-Threadpool-API (
CreateThreadpoolIo) empfohlen. Intern bleibt es IOCP, aber sie nimmt einem die Threadverwaltung ab (Abschnitt 4).1
Was man in .NET und await unterscheiden muss
- Der .NET-Threadpool 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 (Abschnitt 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 Threadpool (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 Threadpool (Abschnitt 5.3).6
In der Abbildung kennzeichnet eine durchgezogene Linie eine stets geltende Beziehung und eine gestrichelte Linie eine bedingte (die Bedingungen stehen bei jeder Beziehung auf der Detailseite). Die vollständige Liste der Beziehungen (32 insgesamt, mit Beleg und Sicherheitsgrad) und die Definitionen der wichtigsten Konzepte sind auf der Detailseite der Wissenskarte (auf Japanisch) zusammengestellt. Daten: JSON-LD / Turtle
2. Wo das Design „mit Threads erschlagen“ scheitert
Zuerst 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 Problem entsteht, wenn die Verbindungszahl wächst. Vergleichen Sie in Abbildung 1 einen Entwurf, der Threads mit der Zahl wartender Verbindungen wachsen lässt, mit einem, der nur abgeschlossene Arbeit mit wenigen Threads verarbeitet.
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-Warteschlange<br/>(alle Abschlussbenachrichtigungen aller Verbindungen laufen hier zusammen)"]
W1["Worker 1"]
W2["Worker 2"]
WN["Worker sind wenige, etwa in CPU-Anzahl"]
Q --> W1
Q --> W2
Q --> WN
end
Abbildung 1: Links wachsen Threads proportional zur Verbindungszahl. Rechts verarbeiten wenige Threads nur das, was geschehen ist
Auch ein Thread, der nur wartet, verbraucht Ressourcen
Jeder einzelne verbraucht einen Stack (standardmäßig 1 MB reserviert) und ein Kernelobjekt; je mehr es werden, desto höher die Last für Scheduler und Kontextwechsel. Tausende Verbindungen gleich Tausende Threads kommen teuer, auch wenn die meisten nur „warten, bis gelesen werden kann, und schlafen“.
Mehr ausführbare Threads vermehren die CPU nicht
Gleichzeitig laufen können physisch nur so viele Threads, wie es CPUs gibt. Weitere ausführbar zu machen vermehrt nur den Schaden durch Umschalten.
Bis Teil 2 haben wir gesehen, Abschlussbenachrichtigungen über Ereignisse oder APCs entgegenzunehmen. Der Ereignisweg wird durch die 64er-Grenze von WaitForMultipleObjects und umständliches Warten mühsam, APCs sind an den ausgebenden Thread gebunden. Viele I/O mal wenige Threads ist die Form, für die IOCP von Anfang an entworfen wurde.1
3. Das Design von IOCP — Warteschlange und Threadsteuerung in einem
3.1. Die zwei Gesichter von CreateIoCompletionPort
Trotz des Namens erledigt CreateIoCompletionPort zwei Arbeiten: einen Port neu anlegen und ein Handle an einen bestehenden Port binden.2
flowchart LR
subgraph SRC["Gebundene 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 Threads ≦ Obergrenze"]
end
subgraph W["Worker-Threads"]
G1["warten mit GetQueuedCompletionStatus"]
G2["warten mit GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.steuern.-> W
Abbildung 2: Der Aufbau von IOCP. Die Abschlüsse vieler Handles laufen in einer Warteschlange zusammen, und auch die Zahl der entnehmenden Threads wird gesteuert
Der CompletionKey, den man beim Binden übergibt, ist ein freier Wert, mit dem man dem Worker sagt: „Das ist der Abschluss von diesem Handle.“ Üblich ist ein Zeiger auf das Verbindungsobjekt.2
Im Completion-Paket identifiziert man den Vorgang über die folgenden drei Angaben.27
| Entnommene Information | Was sie identifiziert oder bestätigt |
|---|---|
| CompletionKey | Von welchem Handle bzw. welcher Verbindung der Abschluss kommt |
| OVERLAPPED-Zeiger | Welcher Vorgang dieser Verbindung abgeschlossen hat |
| Übertragene Bytes | Wie viel Daten übertragen wurden |
Das ist die Entsprechung dazu, den in Teil 2 gesehenen „Laufzettel des Vorgangs“ beim Abschluss wieder einzusammeln.
Das Ziel ist nicht auf „Dateien“ beschränkt. Sockets, Named Pipes, Mailslots — jedes Handle, das Overlapped I/O spricht, lässt sich binden.1 Der Entwurf aus Teil 1, „alles sieht wie eine Datei aus“, wirkt auch hier.
3.2. Die Reise des 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: Completion-Paket ablegen<br/>(Bytes / CompletionKey / OVERLAPPED)
Q->>W: Einen wartenden Thread wecken und übergeben
Note over W: Paket ansehen und Abschluss verarbeiten<br/>(Fortsetzung ausführen, nächstes I/O ausgeben usw.)
W->>Q: Nach der Verarbeitung erneut GetQueuedCompletionStatus
Note over Q: Liegen noch Pakete in der Warteschlange,<br/>kommt das nächste ohne Warten
Abbildung 3: Completion-Pakete werden FIFO abgelegt, der Worker dreht eine Schleife aus Entnehmen und Verarbeiten
Ist asynchrones I/O abgeschlossen, wird ein Completion-Paket in FIFO-Reihenfolge in die Warteschlange des Ports gelegt. Der Worker wiederholt die folgende Schleife.17
- Mit
GetQueuedCompletionStatusein Paket entgegennehmen. - Die Abschlussverarbeitung des entnommenen Vorgangs ausführen.
- Nach der Verarbeitung erneut
GetQueuedCompletionStatusaufrufen.
Es gibt auch GetQueuedCompletionStatusEx, das mehrere Pakete auf einmal entnimmt und bei hoher I/O-Frequenz die Aufrufzahl senkt.8
Nicht allein wegen FALSE aus der Schleife gehen
Auch wenn GetQueuedCompletionStatus FALSE zurückgibt, kann ein Completion-Paket eines fehlgeschlagenen I/O entnommen worden sein. Zusammen mit dem OVERLAPPED-Zeiger entscheiden.7
| Rückgabewert | OVERLAPPED | Bedeutung und Verarbeitung |
|---|---|---|
FALSE |
nicht NULL | Den Abschluss eines fehlgeschlagenen I/O entnommen. Fehlerbehandlung und Aufräumen von Laufzettel und Puffer sind nötig |
FALSE |
NULL | Kein Paket entnommen. Timeout, Schließen des Ports und Ähnliches |
Auch ein fehlgeschlagener Vorgang braucht Aufräumen. Wer nur mit if (!GetQueuedCompletionStatus(...)) break; arbeitet, lässt fehlgeschlagenes I/O fallen und leakt. Die Lebensdauer von Laufzettel und Puffer aus Teil 2 gilt nicht nur für den normalen Abschluss.
Die Reihenfolge der Prüfung in der Worker-Schleife ansehen
Der folgende Skelettcode prüft zuerst die zwei Fälle der Tabelle und verarbeitet danach das Ende-Paket und den normalen Abschluss.
/* Skelett 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 entnommen (Port geschlossen usw.). Nur das ist eine Bedingung zum Aussteigen */
break;
}
if (!ok) {
/* ov != NULL → ein Completion-Paket eines fehlgeschlagenen I/O wurde entnommen.
Aufräumen (Fehlerbehandlung, Freigabe von Laufzettel und Puffer) ist nötig, also nicht aussteigen */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* Mit PostQueuedCompletionStatus abgelegtes Ende-Paket (Abschnitt 4) */
break;
}
handle_completed_io(key, ov, bytes); /* gewöhnliche Abschlussverarbeitung. Kurz halten (3.4) */
}
Der Kern ist, ok == FALSE nicht in einem Zweig abzuhaken, sondern an ov == NULL in zwei Fälle zu teilen. Mit Timeout (nicht INFINITE) bleibt die Prüfung dieselbe; Zeitablauf erscheint als ok == FALSE und ov == NULL.
Ruft ein Thread GetQueuedCompletionStatus zum ersten Mal auf, wird er an diesen Port gebunden (ein Thread kann gleichzeitig nur an einen Port gebunden sein).1 Das Bild „ein festes Worker-Team hängt am Port“ merkt man sich genauer.
3.3. Threads werden LIFO geweckt
Hier trennt man die Reihenfolge, in der Pakete in die Warteschlange kommen, von der Reihenfolge, in der wartende Threads geweckt werden.1
| Gegenstand | Reihenfolge | Worauf zu achten ist |
|---|---|---|
| Completion-Paket | FIFO | Reihenfolge, in der Abschlussbenachrichtigungen in die Warteschlange gelegt werden |
| Wartender Thread | LIFO | Zuerst den Thread wecken, der zuletzt in die Warteposition gegangen ist |
Weil Pakete und Threads verschiedene Reihenfolgen haben, holt der Thread, der gerade noch gearbeitet hat, auch das nächste Paket.
flowchart TB
Q["Warteschlange: P1 → P2 → P3 (FIFO)"]
subgraph TH["Wartende Threads (LIFO-Stapel)"]
A["Thread A (gerade noch ausgeführt, warm)"]
B["Thread B (schläft schon eine Weile)"]
C["Thread C (schläft die ganze Zeit)"]
end
Q -->|"P1, P2 und P3 gehen, wenn frei,<br/>zuerst an Thread A"| A
B -.->|"nur wenn A belegt ist"| Q
C -.->|"wacht selten auf"| Q
Abbildung 4: LIFO-Freigabe. Je höher die Last, desto länger läuft derselbe Thread weiter, und unbeschäftigte Threads dürfen schlafen bleiben
Dieses Design hat zwei Gewinne.
- Es kommt zu keinem Kontextwechsel. Solange Pakete in der Warteschlange liegen, nimmt ein Thread, der
GetQueuedCompletionStatusnach getaner Arbeit aufruft, das nächste Paket ohne Warten und läuft weiter. Die Dokumentation hält für das Szenario Concurrency-Wert 1 fest, dass „kein Threadwechsel auftritt“.1 - Der Cache bleibt warm. Weil derselbe Thread weiterläuft, bleiben Stack und Scheduling-Zustand eher im CPU-Cache. Schlafende Threads werden billig als Reserve für Lastspitzen gehalten.
3.4. Der Concurrency-Wert — „ausführbar“ zählen
NumberOfConcurrentThreads beim Anlegen des Ports ist der Concurrency-Wert. Gezählt werden die an diesen Port gebundenen ausführbaren (runnable) Threads. Solange die Obergrenze erreicht ist, dürfen zusätzliche Threads keine Pakete entgegennehmen.1
Übergibt man 0, wird die Prozessoranzahl des Systems verwendet. Auch das von der Dokumentation als insgesamt bestes Maximum genannte ist die CPU-Anzahl, deshalb nimmt man das als Ausgangspunkt.21
Es ist keine Obergrenze für die Gesamtzahl einschließlich wartender Threads. Was geschieht, wenn jemand in einen Wartezustand eintritt, zeigt Abbildung 5.
flowchart TB
P["Ein Paket kommt in die Warteschlange"]
Q{"Ist die Zahl ausführbarer Threads<br/>kleiner als der Concurrency-Wert"}
RUN["Einen wartenden Thread wecken und verarbeiten lassen"]
HOLD["Niemanden wecken, in der Warteschlange liegen lassen<br/>(ein laufender Thread holt es)"]
BLK["Ein laufender Thread ist<br/>aus anderem Grund in einen Wartezustand eingetreten"]
COMP["Um so viel, wie die Zahl der Ausführbaren gesunken ist,<br/>wartende Threads wecken und auffüllen"]
P --> Q
Q -->|kleiner| RUN
Q -->|Obergrenze| HOLD
BLK --> COMP
Abbildung 5: Concurrency-Steuerung. Die Obergrenze ist die Zahl der Ausführbaren, deshalb wird automatisch aufgefüllt, wenn jemand blockiert
Ein Worker, der in Wartezustand geht, wird durch einen anderen wartenden Thread ersetzt
Tritt ein laufender Worker in irgendein Warten ein (Sperre, Page Fault, unbedacht geschriebenes synchrones I/O), sinkt die Zahl der Ausführbaren, und das System weckt einen wartenden Thread, um die Lücke zu füllen.1 Deshalb legt man nicht „genau CPU-Anzahl“ Worker an, sondern hält mehr Threads wartend als der Concurrency-Wert. Mischen sich lange Berechnungen in die Verarbeitung, kann man auch den Concurrency-Wert selbst anheben; am Ende soll man mit Profiling justieren, so die Dokumentation.1
Es gibt auch vorübergehende Überschreitung der Obergrenze, deshalb Abschlussverarbeitung kurz halten
Auffüllen ist jedoch nicht allmächtig. Wacht der blockierte Thread später auf, übersteigt die Zahl der Ausführbaren in diesem Moment die Obergrenze (die Dokumentation erwähnt diese Überschreitung).1 Abschlussverarbeitung kurz halten ist die große Regel, und dieselbe Form wirkt in Abschnitt 5.4 auch bei .NET.
4. Der Werkzeugkasten — APIs, die den Port tragen
4.1. Arbeitsaufträge und Ende-Anweisungen über dieselbe Warteschlange schicken
PostQueuedCompletionStatus — eigene Completion-Pakete in die Warteschlange legen, ohne I/O auszugeben.3 Arbeitsauftrag an Worker, Shutdown-Anweisung (so viele Ende-Pakete wie Worker — umgangssprachlich „Giftpillen“-Pakete), Benachrichtigung von einem anderen Thread — I/O-Abschlüsse und eigene Nachrichten in derselben Warteschlange, derselben Schleife zu verarbeiten vereinfacht den Entwurf stark.
4.2. Abschlüsse häufiger I/O gebündelt entgegennehmen
GetQueuedCompletionStatusEx — mehrere Completion-Pakete auf einmal entnehmen. Wirksam bei hoher I/O-Frequenz, bei der der Aufwand eines Aufrufs je Paket zählt.8
4.3. Die Benachrichtigung bei synchronem Abschluss weglassen
SetFileCompletionNotificationModes — für den in Abschnitt 5 von Teil 2 gesehenen Fall „asynchron ausgegeben, aber synchron abgeschlossen“ einen Modus wählen, der kein Paket in den Port legt (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Das Ergebnis des synchronen Abschlusses kennt man an Ort und Stelle, den Umweg über die Warteschlange zu sparen ist die Beschleunigung.9
4.4. Erzeugen und Verwalten von Threads abgeben
Windows-Threadpool-API — CreateThreadpoolIo / StartThreadpoolIo nutzen intern IOCP und übernehmen Erzeugen und Verwalten der Threads. Microsoft empfiehlt, für neue Serveranwendungen zuerst diese API zu prüfen und rohes IOCP nur zu verwenden, wenn man Concurrency-Wert und Threadverwaltung ausdrücklich steuern will.1 Und der Threadpool von .NET ist genau diese „Automatisierung von IOCP plus Threadverwaltung“, umgesetzt als .NET-Laufzeit.
4.5. Drei Lebensdauerregeln, die sich mit der Wahl der API nicht ändern
- In Workern nicht lange blockieren. Das Auffüllen aus 3.4 mildert nur die Verschlechterung.
- Handle-Ebene und Vorgangsebene getrennt identifizieren. CompletionKey ist je Handle,
OVERLAPPEDje Vorgang. Den Laufzettel aus Teil 2 darf man bis zum Abschluss nicht freigeben. - Ein Handle nicht schließen, solange unerledigtes I/O bleibt. Das Verhalten von cleanup (Abschnitt 6 in Teil 1) und die Praxis des Abbruchs (Abschnitt 6 in Teil 2) gelten unverändert.
5. Der .NET-Threadpool — zweistöckig auf IOCP
Von hier an setzt man Win32-Abschlussbenachrichtigungen und .NET-Code in Beziehung. Die Reihenfolge ist: Thread, der den Abschluss entgegennimmt → ein Hin und Zurück von await → Wartezeit → Ort, an dem die Fortsetzung läuft.
Der .NET-Threadpool stellt Threads mit den folgenden zwei Rollen bereit.4
| Art | Rolle in diesem Artikel |
|---|---|
| Worker-Thread | Task.Run und Fortsetzungen ausführen |
| I/O-Completion-Thread | Abschlüsse asynchroner I/O entgegennehmen |
Auch ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) gibt diese zwei als getrennte Zahlen zurück. Im Folgenden verfolgt man „die Seite, die den Abschluss entgegennimmt“ und „die Seite, die die Arbeit ausführt“ getrennt.4
Unter Windows hat der Threadpool ein eigenes I/O-Completion-Port. Die Low-Level-API, die ein OS-Handle an diesen Port bindet, ist ThreadPoolBoundHandle.BindHandle. Asynchrones I/O auf ein gebundenes Handle behandelt man zusammen mit NativeOverlapped. Das ist der .NET-seitige Mechanismus, der OVERLAPPED aus Teil 2 entspricht.5
Das ältere ThreadPool.BindHandle hat dieselbe Rolle, für neuen Code gilt ThreadPoolBoundHandle.BindHandle. Öffnen FileStream oder Socket ein Handle im asynchronen Modus, geschieht intern diese Art Bindung.5
Die Entsprechung zu Win32, getrennt in Ausgabe und Abschluss, sieht so aus.
- „Asynchrones Handle plus OVERLAPPED“ aus Teil 2 ist der Mechanismus der Ausgabe
- Das IOCP dieses Artikels ist der Mechanismus, den Abschluss entgegenzunehmen
- Die I/O-Completion-Threads des .NET-Threadpools sind das Worker-Team, das die
GetQueuedCompletionStatus-Schleife dreht
Mit dieser Entsprechung wird das Win32-Bild zum .NET-Bild.
5.1. Ein Hin und Zurück von await ReadAsync, vollständig
Den Inhalt der Kiste, die in Abbildung 7 von Teil 2 „echtes asynchrones I/O“ hieß, verfolgt man diesmal bis zur Fortsetzung nach dem Abschluss. In Abbildung 6 trennt man den Thread bei der Ausgabe, den Thread, der die Abschlussbenachrichtigung entgegennimmt, und den Ort, an dem der restliche Code läuft.
sequenceDiagram
participant U as Aufrufender Thread<br/>(UI-Thread und Ähnliches)
participant K as Kernel<br/>(IRP ausgeben bis Abschluss)
participant Q as IOCP des Threadpools
participant IO as I/O-Completion-Thread
participant C as Ort der Fortsetzung
U->>K: ReadAsync gibt asynchrones Lesen aus<br/>(mit OVERLAPPED-Äquivalent)
K-->>U: ERROR_IO_PENDING (kehrt sofort zurück)
Note over U: await registriert eine Fortsetzung auf dem unerledigten Task<br/>und gibt den Thread ab (UI geht zur nächsten Nachrichtenverarbeitung)
Note over K: Das Gerät arbeitet<br/>in dieser Zeit wartet nirgendwo ein Thread
K->>Q: Completion-Paket ablegen
Q->>IO: Einen Thread LIFO wecken und übergeben
Note over IO: Ergebnis (Bytes, Status) festschreiben,<br/>den Task abschließen und die Fortsetzung einplanen
IO->>C: An den erfassten Kontext werfen<br/>(zum UI-Thread / sonst im Threadpool ausführen)
Note over C: Der Code nach await läuft
Abbildung 6: Das gesamte Hin und Zurück von await. Threads arbeiten nur bei Ausgabe und nach Abschluss, die Wartezeit hat null Threads
5.2. Die genaue Bedeutung von „I/O-Warten verbraucht keinen Thread“
Für die Wartezeit allein keinen eigenen Thread bereithalten
Was diese Abbildung betonen soll: Zwischen Ausgabe und Abschluss existiert weder im Benutzermodus noch im Kernel ein Thread, der nur auf diesen Abschluss wartet. Auch die async-Erklärung von Microsoft (async in depth) steigt bei I/O-gebundenen Tasks bis zum Gerätetreiber und zum Interrupt hinab.6
Andererseits gibt es Situationen, in denen ein Treiber im Kernel einen Teil der Verarbeitung an Worker-Threads des Systems abgibt. Das ist Arbeit, um die Anforderung voranzubringen, und etwas anderes als ein Thread, der den Abschluss blockiert und weiter wartet. „Verbraucht keinen Thread“ erklärt diese Wartezeit.
Mit dem Aufbau seit Teil 1 umformuliert: Ein IRP bleibt als Datenstruktur, nicht als Thread, im Gerätestapel (Teil 1), die Ausgabe kehrt sofort mit ERROR_IO_PENDING zurück (Teil 2), der Abschluss kommt als Ereigniskette Interrupt → Completion-Paket (dieser Artikel). Den Zustand „warten“ aufrechtzuerhalten braucht keine teure Ressource Thread.
Von CPU-Verarbeitung und vorgetäuschter Asynchronität unterscheiden
Deshalb kann eine Anwendung, die async/await richtig nutzt, den Zustand „10.000 I/O gleichzeitig unterwegs“ mit einem Dutzend Threads halten. Umgekehrt gilt: Diese Eigenschaft gehört nur I/O-gebundenen Tasks. CPU-Verarbeitung, die man in Task.Run wickelt, belegt selbstverständlich einen Worker-Thread, und auch die „vorgetäuschte Asynchronität“ aus Abschnitt 7 von Teil 2 lässt hinter den Kulissen einen Thread schlafen.
5.3. Wo die Fortsetzung läuft
Der letzte Pfeil in Abbildung 6 ist: „Nachdem der Abschluss entgegengenommen wurde, wo wird die Fortsetzung ausgeführt?“ Man trennt ob man zum Kontext zurückkehrt von wohin man CPU-Arbeit gibt.6
flowchart TB
A["Der Task ist abgeschlossen, die Fortsetzung soll laufen"]
Q1{"War zum Zeitpunkt von await<br/>ein SynchronizationContext oder<br/>ein nicht standardmäßiger TaskScheduler erfasst"}
Q2{"War ConfigureAwait(false)<br/>angegeben"}
UI["An das erfasste Ziel zurückwerfen<br/>Beispiel: in der Nachrichtenschleife des UI-Threads oder<br/>auf diesem TaskScheduler ausführen"]
TP["Keine Pflicht, an einen bestimmten Ort zurückzukehren<br/>synchron auf dem Thread weiterlaufen, der abgeschlossen hat, oder<br/>auf einem Threadpool-Thread ausführen"]
A --> Q2
Q2 -->|"ja"| TP
Q2 -->|"nein"| Q1
Q1 -->|"ja (UI-Thread von WPF/WinForms und Ähnliches)"| UI
Q1 -->|"nein (Konsole/ASP.NET Core und Ähnliches)"| TP
Abbildung 7: Das Ziel der Fortsetzung. Dass man nach await die UI anfassen kann, liegt daran, dass an den erfassten Kontext zurückgeworfen wird
Erfasster Kontext und der Fall, in dem es synchron weitergeht
awaitauf dem UI-Thread von WPF/WinForms erfasst denSynchronizationContext, die Fortsetzung kehrt zum UI-Thread zurück. Deshalb wird direkt nachawaitdas Anfassen eines Steuerelements nicht zum Threadverstoß. Die Praxis dieses Entwurfs behandelt „WPF/WinForms: async und der UI-Thread auf einem Blatt“.- Erfasst wird nicht nur
SynchronizationContext;awaitauf einem nicht standardmäßigenTaskSchedulernimmt auch diesen Scheduler. Wo keines von beiden da ist (Konsole, ASP.NET Core, auf dem Threadpool), besteht keine Pflicht, an einen bestimmten Ort zurückzukehren, die Fortsetzung läuft also im Threadpool oder synchron weiter auf dem Thread, der den Task abgeschlossen hat. ConfigureAwait(false)macht ausdrücklich „nicht zurückkehren müssen“, nicht die Garantie „immer in den Threadpool“. Wird ein bereits abgeschlossener Task awaitet (einschließlich des synchronen Abschlusses aus Teil 2), entsteht kein Warten und es geht auf dem aktuellen Thread weiter. Zur Unterscheidung in Bibliothekscode siehe „C# async/await-Praxis-Entscheidungstabelle“.
Schlechtes Beispiel: glauben, ConfigureAwait(false) habe den Thread gewechselt
Den letzten Punkt prüft man am Code. Das folgende Beispiel ist so geschrieben, als wäre ConfigureAwait(false) eine Anweisung, in den Threadpool zu wechseln.
// Schlechtes Beispiel: das Missverständnis „ConfigureAwait(false) steht da, also läuft es ab hier im Threadpool“
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// Erwartung: hier ist der Threadpool, die UI friert nicht ein
// Tatsächlich: ist der Task zum Zeitpunkt von await schon abgeschlossen, entsteht kein Warten,
// und es geht auf dem UI-Thread weiter → diese schwere Verarbeitung friert die UI ein
var rows = ParseHeavy(csv);
// Außerdem läuft die Fortsetzung nach asynchronem Abschluss nicht auf dem UI-Thread
resultLabel.Text = $"{rows.Count} Zeilen"; // → kann zur Threadverstoß-Ausnahme werden
}
ConfigureAwait(false) sagt nur: „nicht zum erfassten Kontext zurückkehren müssen“. Es legt nicht fest, wo es läuft, deshalb kann es auf dem UI-Thread weitergehen oder auf einem I/O-Completion-Thread oder Threadpool-Thread. Beides ist möglich — das ist der Grund, warum dieser Code kaputt ist.
Gutes Beispiel: Rückkehr zur UI und Ort der schweren Verarbeitung getrennt angeben
UI-Code und Bibliothekscode trennt man und schreibt die Absicht getrennt. Das await, das zur UI zurückkehrt, und Task.Run, das CPU-Arbeit in den Threadpool gibt, haben verschiedene Rollen.
// Gutes Beispiel: „zur UI zurück / nicht zurück“ und „wo die schwere Verarbeitung läuft“ getrennt anweisen
private async void OnLoadClick(object sender, EventArgs e)
{
// Im UI-Code die Erfassung belassen (die Fortsetzung kehrt zum UI-Thread zurück)
string csv = await File.ReadAllTextAsync(path);
// CPU-Arbeit soll in den Threadpool: mit Task.Run ausdrücklich
var rows = await Task.Run(() => ParseHeavy(csv));
// Hier sicher der UI-Thread. Steuerelemente lassen sich gefahrlos anfassen
resultLabel.Text = $"{rows.Count} Zeilen";
}
// Auf der Bibliotheksseite (Code ohne UI) umgekehrt erklären, 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(); // unabhängig vom Kontext des Aufrufers
}
Die Entscheidung teilt sich in die folgenden drei.
| Zweck | Wahl in diesem Code |
|---|---|
| Nach await die UI anfassen | Im UI-Code den Kontext erfasst lassen |
| Schwere CPU-Verarbeitung in den Threadpool geben | Mit Task.Run ausdrücklich |
| In der Bibliothek nicht zum Kontext des Aufrufers zurückkehren müssen | Mit ConfigureAwait(false) die Erfassung unterlassen |
ConfigureAwait(false) legt den Ausführungsthread nicht fest. Als Werkzeug der Bibliotheksseite, unabhängig vom Kontext des Aufrufers zu laufen, trennt man es von der Anordnung von UI-Verarbeitung und CPU-Verarbeitung.
5.4. Die Identität der Verstopfung — Threadpool-Starvation
Synchrones Warten verstopft den Thread, der die Fortsetzung ausführen soll
Wartet man in einer Fortsetzung oder einem Worker synchron, bleibt dieser Thread verstopft. Synchrones I/O, Task.Result/Wait(), langes Warten auf eine Sperre sind dieses Muster.
Das Auffüllen von IOCP selbst (Abschnitt 3.4) wirkt sofort, solange Reserve-Threads warten. Ist die Reserve erschöpft, liegt man in dem Bereich, in dem der Threadpool neue Threads nur langsam nachlegt.
Im Moment der Last entsteht Starvation (Aushungerung): „eine Fortsetzung soll laufen, aber es gibt keinen Thread, der sie ausführt“, und die ganze Anwendung wird träge.
Freie Zahl und tatsächliche Blockierstellen zusammen untersuchen
Der Einstieg in die Untersuchung sind zwei.
- Mit
ThreadPool.GetAvailableThreadsdie freien Worker- und I/O-Completion-Threads ansehen.4 - Mit Ereignisablaufverfolgung Threadpool und Blockierungen in der Wirklichkeit fassen.
Die Schritte der Ablaufverfolgung stehen in „Mit PerfView und dotnet-trace die Ursache von „langsam“ finden“.
Die Regel der Vorbeugung: den async-Weg bis zum Ende async lassen und kein sync-over-async mischen. Auch wenn man den Thread während des I/O-Wartens abgibt, verstopft synchrones Warten in der Fortsetzung ihn dort erneut.
6. Zusammenfassung
- IOCP vereint Completion-Warteschlange (FIFO) und Steuerung der Threadanzahl. Die Abschlüsse vieler Handles laufen in einem Port zusammen und werden in der
GetQueuedCompletionStatus-Schleife verarbeitet.17 - Threads werden LIFO freigegeben, deshalb läuft bei hoher Last derselbe Thread weiter, Kontextwechsel und Cache-Misses werden minimiert.1
- Der Concurrency-Wert ist die Obergrenze der ausführbaren Threads, Ausgangspunkt die CPU-Anzahl (Angabe 0). Blockiert ein laufender Thread, wird mit wartenden Threads aufgefüllt; Abschlussverarbeitung kurz zu halten bleibt die große Regel.12
- Mit
PostQueuedCompletionStatuslassen sich auch eigene Pakete schicken. Für neue Implementierungen ist die Windows-Threadpool-API (intern IOCP) der erste Kandidat.31 - Der .NET-Threadpool ist zweistöckig, Worker-Threads plus I/O-Completion-Threads, asynchrone Handles werden an das IOCP des Pools gebunden. 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 / weiter im Threadpool).
ConfigureAwait(false)ist die Anweisung, diese Erfassung zu unterlassen.6 - Die Identität der Verstopfung ist fast immer Threadpool-Starvation durch eingeschleustes synchrones Warten. Den async-Weg bis zum Ende async durchziehen.
Weiter geht es in Teil 4, „Cache-Manager — Wann erreicht Ihr WriteFile den Datenträger?“. In Teil 2 stand „liegt es im Cache, schließt es synchron ab“, und auch diesmal huschte der Cache mehrmals vorbei. Als Nächstes stellt sich die Reihe dem Cache selbst — verzögertes Schreiben, Read-Ahead, FILE_FLAG_NO_BUFFERING und die Bedingung, unter der „geschriebene Daten beim Stromausfall verschwinden“.
Verwandte Artikel
- Die Tiefen von Windows I/O (Teil 1) — Jedes Lesen und Schreiben wird zu einem 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 Irrglaube, man könne über TCP pro Send genau eine Einheit empfangen ── Empfangslogik entwerfen, die es als Bytestrom behandelt
- Mit PerfView und dotnet-trace die Ursache von „langsam“ finden — Praxis-Einstieg in die .NET-Performanceanalyse
- Praktischer Leitfaden, um auf gewöhnlichem Windows so nah wie möglich an Soft Real-Time heranzukommen
Verwandte Beratungsleistungen
Die KomuraSoft LLC übernimmt Entwurf von Windows-Anwendungen und Servern mit vielen gleichzeitigen Verbindungen und gleichzeitiger I/O sowie die Ursachenuntersuchung von Leistungsproblemen wie „der Threadpool verstopft“ oder „asynchron gemacht, und trotzdem nicht schneller“.
- Windows-App-Entwicklung
- Fehleruntersuchung und Ursachenanalyse
- Entwicklung von Soft-Real-Time-Windows-Apps
- Kontakt
Quellen
-
Microsoft Learn, I/O Completion Ports. Dazu, dass ein I/O-Completion-Port auf Multiprozessorsystemen ein effizientes Threading-Modell für viele asynchrone I/O-Anforderungen bereitstellt; dass bei Abschluss asynchroner I/O ein Completion-Paket in FIFO-Reihenfolge in die Warteschlange des Ports gelegt wird; dass das Ziel nicht auf Dateien auf dem Datenträger beschränkt ist, sondern jedes Handle, das Overlapped I/O unterstützt, etwa Sockets, Named Pipes und Mailslots; dass Threads, die am Port warten, in LIFO-Reihenfolge freigegeben werden und bei Concurrency-Wert 1 und gefüllter Warteschlange kein Threadwechsel auftritt; dass ein Thread beim ersten Aufruf von GetQueuedCompletionStatus an diesen Port gebunden wird und gleichzeitig nur an einen Port gebunden sein kann; dass der Concurrency-Wert die Zahl ausführbarer Threads begrenzt und das insgesamt beste Maximum die CPU-Anzahl ist; dass ein wartender Thread ein Completion-Paket verarbeiten kann, wenn ein laufender Thread aus anderem Grund in Wartezustand eintritt (und die Obergrenze vorübergehend überschritten werden kann, wenn der blockierte Thread aufwacht); und dass neue Serveranwendungen zuerst die Windows-Threadpool-API (CreateThreadpoolIo und Ähnliches, intern IOCP) prüfen 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 einen I/O-Completion-Port neu anlegt als auch ein Handle an einen bestehenden Port bindet; dass sich beim Binden ein CompletionKey (benutzerdefinierter Wert) angeben lässt, der im Completion-Paket enthalten ist; und dass NumberOfConcurrentThreads die Obergrenze der Threads ist, die Completion-Pakete parallel verarbeiten können, wobei 0 die Prozessoranzahl des Systems verwendet. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. Dazu, dass PostQueuedCompletionStatus ein anwendungseigenes Completion-Paket in die Warteschlange eines I/O-Completion-Ports legen kann, ohne asynchrones I/O zu starten, und der Port dadurch neben dem Empfang von I/O-Abschlüssen auch als Empfangsstelle für Kommunikation von anderen Threads im Prozess dienen kann. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. Dazu, dass der Threadpool von .NET Worker-Threads und Threads für asynchrone I/O-Abschlüsse bereitstellt; dass ThreadPool.GetAvailableThreads die verfügbare Zahl von Worker-Threads und I/O-Completion-Threads getrennt liefert; und dass Threads des Threadpools nicht lange blockieren sollten. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. Dazu, dass ThreadPoolBoundHandle.BindHandle ein ThreadPoolBoundHandle zurückgibt, das ein Betriebssystem-Handle an den Systemthreadpool (dessen I/O-Completion-Port) bindet; dass Low-Level-asynchrones I/O auf das gebundene Handle zusammen mit NativeOverlapped erfolgt; und dass die Abschlussverarbeitung asynchroner I/O dadurch vom Threadpool ausgeführt wird. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). Dazu, dass bei I/O-gebundenen Tasks nach der Übergabe des Aufrufs an das Betriebssystem kein eigener Thread existiert, der auf den Abschluss wartet (das sogenannte There is no thread); dass der Abschluss über Gerätetreiber und Interrupt gemeldet wird und die registrierte Fortsetzung läuft; dass await standardmäßig den aktuellen Kontext (SynchronizationContext und Ähnliches) erfasst und die Fortsetzung dort ausführt, und ohne erfassenswerten Kontext im Threadpool; und dass sich diese Erfassung mit ConfigureAwait(false) abschalten lässt. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. Dazu, dass GetQueuedCompletionStatus ein Completion-Paket aus der Warteschlange des Completion-Ports entnimmt (oder wartet, wenn keines da ist); dass als Ergebnis übertragene Bytes, CompletionKey und OVERLAPPED-Zeiger anfallen; und dass ein Rückgabewert FALSE bei nicht-NULL-OVERLAPPED-Zeiger bedeutet, das Completion-Paket eines fehlgeschlagenen I/O-Vorgangs sei entnommen worden, und nur OVERLAPPED NULL bedeutet, dass (etwa durch Timeout) kein Paket entnommen wurde. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. Dazu, dass GetQueuedCompletionStatusEx mehrere Completion-Pakete auf einmal entnehmen kann und die Zahl der entnommenen Einträge zurückgibt. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. Dazu, dass FILE_SKIP_COMPLETION_PORT_ON_SUCCESS das Verhalten wählt, kein Paket in den Completion-Port zu legen, wenn I/O sofort erfolgreich ist und das Ergebnis an Ort und 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 den Datenträger?
Teil 4 einer bebilderten Reihe über den Windows-Cache-Manager. Behandelt den als Dateizuordnung umgesetzten Cache, Read-Ahead und verzöge...
Die Tiefen von Windows I/O (Teil 2) — Synchrones und asynchrones I/O: Was OVERLAPPED wirklich bedeutet
Teil 2 der Serie erklärt synchrones und asynchrones I/O (Overlapped I/O) unter Windows anhand von Diagrammen. Behandelt werden FILE_FLAG_...
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, Abschluss) — Wie Minifilter funktionieren und langsame I/O mit Procmon untersuchen
Erklärt, wie Minifilter Datei-I/O überwachen und steuern: FltMgr, Altitudes, Pre-/Post-Callbacks, fltmc, langsame Vorgänge in Procmon fin...
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 zugleich 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-Threadpools.
- 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ätestapel, 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 Benutzermodus noch im Kernel einen Thread, der einfach nur wartet. Nach Abschluss wird ein Completion-Paket in das IOCP des Threadpools 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 Threadpool läuft, und Ähnliches), läuft die Fortsetzung entweder auf einem Threadpool-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 Threadpool 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 Threadpool-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.