Die Tiefen von Windows I/O (Teil 2) — Synchrones und asynchrones I/O: Was OVERLAPPED wirklich bedeutet

· · Windows, Win32, I/O, Asynchrones I/O, OVERLAPPED, Kernel, .NET, CSharp

Im letzten Teil (Teil 1) haben wir gesehen, dass eine Windows-I/O-Anfrage zu einem Paket namens IRP wird, das durch den Gerätestapel fließt, und dass Ausgabe und Abschluss ganz unten im Kernel von Anfang an getrennt sind.

In diesem Teil graben wir uns durch den Mechanismus, mit dem eine Anwendung diese Trennung nutzt — asynchrones I/O (Overlapped I/O). Sie setzen FILE_FLAG_OVERLAPPED, und es kommt trotzdem synchron zurück. Sie verwenden ein OVERLAPPED mehrfach, und Ihre Daten werden beschädigt. Sie rufen CancelIoEx auf, und es stoppt nicht. Sie brechen ab, und die Anwendung stürzt mit einer Zugriffsverletzung ab — all diese „Schauergeschichten über asynchrones I/O“ entstehen daraus, den Mechanismus nicht als ein einziges Bild im Kopf zu haben. Dieser Artikel baut dieses Bild auf.

Das ist Teil 2 der Serie „Die Tiefen von Windows I/O“. Der Gesamtaufbau steht am Anfang von Teil 1.

1. Das Wichtigste zuerst

  • Die Grenze zwischen synchronem und asynchronem I/O liegt nicht im Kernel, sondern in „ob gewartet wird“. Die Garantie von synchronem I/O ist, dass der Aufruf nicht vor dem Abschluss zurückkehrt. Nur wenn eine Anfrage zurückgestellt wird, wartet der I/O-Manager auf den Abschluss, und der Thread schläft in einem Wartezustand, der keine CPU verbraucht (Abschnitt 2).1
  • FILE_FLAG_OVERLAPPED ist ein Modus des Handles (des Dateiobjekts). Er wird im Moment von CreateFile festgelegt und kann nicht pro Aufruf umgeschaltet werden. Weil ein asynchrones Handle keinen vom System geführten Dateizeiger hat, geben Sie bei einer Datei auf der Festplatte jedes Mal die Position über das Offset der OVERLAPPED-Struktur an (Abschnitt 3).21
  • Die OVERLAPPED-Struktur ist „ein Beleg für einen Vorgang“. Sie brauchen einen für jeden laufenden Vorgang, und die offizielle Dokumentation hält ausdrücklich fest, dass gemeinsame Nutzung oder Wiederverwendung zu Datenbeschädigung führt. Weder Struktur noch Puffer dürfen bis zum Abschluss angefasst werden (Abschnitt 3).34
  • Es gibt im Kern vier Wege, den Abschluss zu erhalten: die Signalisierung des Handles (veraltet), ein OVERLAPPED-Ereignis plus GetOverlappedResult, eine APC (alertable wait) und I/O-Completion-Ports (nächstes Mal) (Abschnitt 4).156
  • Auch asynchron ausgegebenes I/O kann synchron abschließen. Cache-Treffer, NTFS-Komprimierung/-Verschlüsselung und dateilängenverändernde Schreibvorgänge sind die klassischen Fälle. „Asynchron“ heißt nicht „blockiert nie“ (Abschnitt 5).3
  • Ein Abbruch ist eine „Bitte“. Auch nach dem Aufruf von CancelIoEx kommt der Vorgang als Abschluss mit ERROR_OPERATION_ABORTED zurück. Räumen Sie erst auf, nachdem Sie diesen Abschluss gesehen haben (Abschnitt 6).78
  • .NETs FileOptions.Asynchronous ist der direkte Schalter für diesen Modus. Eine Diskrepanz zwischen dem Modus des Handles und der aufgerufenen API erzeugt „vorgetäuschte Asynchronität“, bei der der Threadpool einspringt, oder unnötigen Wartezeit-Overhead (Abschnitt 7).910

2. Synchrones I/O — Wo schläft der Thread?

Beginnen wir mit der Grundform. Ein ohne FILE_FLAG_OVERLAPPED geöffnetes Handle befindet sich im synchronen Modus. Rufen Sie ReadFile auf, kehrt die Funktion erst zurück, wenn das I/O abgeschlossen ist.1

Wie wir in Teil 1 gesehen haben, stellt der Treiber die Anfrage zurück (pending) und wartet auf die Antwort der Hardware. Wer wartet also bei synchronem I/O? Der I/O-Manager wartet auf den Abschluss, bevor er die Kontrolle an die Anwendung zurückgibt.

Treiber (Stapel)I/O-ManagerAnwendungs-ThreadTreiber (Stapel)I/O-ManagerAnwendungs-ThreadDer Thread wechselt im Kernel in einen Wartezustandund schläft, ohne CPU zu verbrauchenReadFile (synchrones Handle)Gibt ein IRP ausSTATUS_PENDING (wartet auf Antwort)Abschluss (IoCompleteRequest)Gibt das Ergebnis zurück und weckt den ThreadReadFile kehrt mit TRUE/FALSE zurück

Abbildung 1: Synchrones I/O (falls die Anfrage zurückgestellt wird). Der I/O-Manager wartet auf den Abschluss, bevor er zur Anwendung zurückkehrt

Dieses Diagramm gilt für den Fall, dass die Anfrage zurückgestellt wird. Kann der Treiber die Anfrage sofort abschließen (etwa bei einem Cache-Treffer — der Weg „sofort abschließen“ aus Abbildung 5 in Teil 1), entsteht überhaupt kein Warten, und der Aufruf kehrt mit dem Ergebnis unmittelbar zurück. Die Garantie von synchronem I/O ist „der Aufruf kehrt nicht vor Abschluss zurück“, nicht „schläft zwangsläufig“.

Zwei Punkte sind hier wichtig.

  • „Warten“ kostet keine CPU. Ein Thread im Wartezustand wird aus der Menge der lauffähigen Threads des Schedulers entfernt. Dass es besser ist, warten zu lassen, statt sich mit Polling selbst das Wasser abzugraben, haben wir in „Warum Sie unter Windows Ereigniswarten gegenüber Sleep(1) bevorzugen sollten“ beschrieben.
  • Bei einem Handle im synchronen Modus führt der Kernel den Dateizeiger (aktuelle Position). Deshalb können aufeinanderfolgende ReadFile-Aufrufe „an der Stelle weiterlesen“. Dieser Zustand liegt nicht am Handle, sondern am Dateiobjekt, sodass mit DuplicateHandle duplizierte Handles sich die Position teilen (Teil 1, Abschnitt 3.3).

Die Schwäche von synchronem I/O läuft auf eines hinaus: Während des Wartens kann dieser Thread nichts anderes tun. Betreiben Sie synchrones I/O auf dem UI-Thread, friert das Fenster ein; starten Sie auf einem Server einen Thread pro Verbindung, ertrinken Sie schon bei ein paar hundert Verbindungen in Threads. Für einen anderen Thread, der in synchronem I/O feststeckt, gibt es zudem eine API namens CancelSynchronousIo, um ihn von außen zu retten (Abschnitt 6).11

3. Asynchrones I/O — Modus des Handles und Beleg des Vorgangs

3.1. Der Modus steht pro Handle fest

Übergeben Sie CreateFile FILE_FLAG_OVERLAPPED, wird das Dateiobjekt hinter diesem Handle im asynchronen Modus geöffnet.1 Wichtig ist hier, dass dies eine Eigenschaft pro Handle ist. „Nur diesen einen Aufruf asynchron“ geht nicht. Sie können dieselbe Datei mit zwei Handles öffnen, einem für synchrone und einem für asynchrone Nutzung (es entstehen dann einfach zwei Dateiobjekte).

Ein Handle im asynchronen Modus hat noch einen weiteren wichtigen Unterschied: Das System führt keinen Dateizeiger.2 Weil mehrere Vorgänge gleichzeitig unterwegs sein können, hat „die aktuelle Position“ keine Bedeutung. Bei Geräten mit einer Position, etwa einer Datei auf der Festplatte, geben Sie die Lese-/Schreibposition daher jedes Mal explizit über Offset/OffsetHigh der OVERLAPPED-Struktur an. Bei Geräten ohne Konzept einer Sek-Position, etwa seriellen Ports oder benannten Pipes, wird Offset dagegen nicht zur Positionsangabe verwendet (lassen Sie es auf 0). Auch dann ist, wie im nächsten Abschnitt zu sehen, die OVERLAPPED-Struktur selbst für jeden einzelnen Vorgang erforderlich.

3.2. OVERLAPPED ist „ein Beleg für einen Vorgang“

Die Aufgabe der OVERLAPPED-Struktur ist es, einen laufenden Vorgang zu identifizieren und seinen Zustand zu transportieren.4

Feld Aufgabe
Offset / OffsetHigh Die Position in der Datei, die dieser Vorgang liest/schreibt (bei der Ausgabe angegeben; bei Geräten ohne Position ungenutzt)
hEvent Ein bei Abschluss signalisiertes Ereignis (optional; manuelles Zurücksetzen empfohlen)
Internal Der Zustand des Vorgangs. Vor Abschluss steht etwas wie STATUS_PENDING darin (für das System)
InternalHigh Die übertragene Bytezahl bei Abschluss (für das System)
OVERLAPPED-Struktur = Beleg für einen VorgangOffset: was gelesen wirdhEvent: wie der Abschluss erfahren wirdInternal/InternalHigh:Zustand und Ergebnis (vom System geschrieben)Handle (Dateiobjekt) = ModusSynchroner ModusKernel führt die aktuelle PositionReadFile kehrt erst bei Abschluss zurückAsynchroner Modus (FILE_FLAG_OVERLAPPED)Aktuelle Position wird nicht geführtAusgabe und Abschluss sind getrenntWird einmalig bei CreateFile festgelegtBei jeder Ausgabe von ReadFile/WriteFile wird eines vorbereitet

Abbildung 2: Der Modus liegt am Handle, der Zustand am Vorgang (dem Beleg). Wer diese Aufteilung verwechselt, hat einen Unfall

Daraus ergeben sich von selbst zwei Verbote, die die offizielle Dokumentation ausdrücklich nennt.3

  1. Sie brauchen so viele OVERLAPPED-Strukturen, wie Vorgänge gleichzeitig laufen. Geben Sie drei aus, brauchen Sie drei. Wiederverwendung führt zu „unvorhersehbaren Ergebnissen oder Datenbeschädigung“.
  2. Bis zum Abschluss müssen sowohl OVERLAPPED als auch der Datenpuffer am Leben gehalten werden und dürfen nicht angefasst werden. Der Kernel schreibt in diesen Bereich hinein. Mit einem lokalen OVERLAPPED als Variable auszugeben und dann die Funktion zu verlassen, ist ein klassischer Weg, den Kernel über den eigenen Stack trampeln zu lassen.

3.3. Die Ausgabe kann auf drei Arten zurückkommen

Ein ReadFile auf ein asynchrones Handle kann auf drei Arten zurückkehren.2

ReadFile (asynchrones Handle, mit OVERLAPPED)Wie lautet der Rückgabewert?TRUESofort abgeschlossen (synchroner Abschluss)Standardmäßig trifft zusätzlich eine Abschlussbenachrichtigung einFALSE + ERROR_IO_PENDINGAngenommen. Der Abschluss wird später gemeldetFALSE + ein anderer FehlerDie Ausgabe selbst ist fehlgeschlagenAuf die Abschlussbenachrichtigung warten(die vier Wege aus Abschnitt 4)

Abbildung 3: Die Dreiteilung bei asynchroner Ausgabe. Asynchrones I/O funktioniert erst, wenn sowohl TRUE (sofortiger Abschluss) als auch ERROR_IO_PENDING korrekt behandelt werden

Die Prüfung, die Sie in Code gießen, ist die Kombination aus Rückgabewert und GetLastError. Diese Tabelle bildet die Verzweigung unmittelbar ab.

Rückgabewert von ReadFile GetLastError() Bedeutung Was der Aufrufer tut
TRUE (nicht prüfen) Sofort abgeschlossen (synchroner Abschluss) Standardmäßig trifft zusätzlich eine Abschlussbenachrichtigung ein. Die Ergebnisverarbeitung dem Benachrichtigungsweg überlassen
FALSE ERROR_IO_PENDING (997) Angenommen. In Bearbeitung Nichts tun. Auf die Abschlussbenachrichtigung warten, ohne OVERLAPPED oder Puffer anzufassen
FALSE Alles andere Die Ausgabe selbst ist fehlgeschlagen Es trifft keine Abschlussbenachrichtigung ein. Sofort den Fehler behandeln und OVERLAPPED sowie Puffer aufräumen
// C++ / Win32
// hFile : ein mit FILE_FLAG_OVERLAPPED geöffnetes Handle
// ov    : ein nur für diesen Vorgang reserviertes OVERLAPPED (Offset und hEvent sind bereits gesetzt)
// buf/len: ein nur für diesen Vorgang reservierter Puffer. Erst nach der Abschlussbenachrichtigung freigeben
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // Bei asynchroner Ausgabe wird für lpNumberOfBytesRead NULL übergeben;
    // die übertragene Bytezahl wird später über GetOverlappedResult nach Abschluss geholt
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) Synchroner Abschluss. Standardmäßig trifft trotzdem eine Abschlussbenachrichtigung ein,
        // deshalb hier das Ergebnis nicht verarbeiten
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) Angenommen. Auf die Abschlussbenachrichtigung warten, ohne ov oder buf anzufassen
        return ERROR_IO_PENDING;
    }

    // (3) Die Ausgabe selbst ist fehlgeschlagen. Es trifft keine Abschlussbenachrichtigung ein,
    // deshalb räumt der Aufrufer hier sofort auf
    return err;
}

ERROR_IO_PENDING ist kein Fehler, sondern eine „Annahme“. Es wie einen gewöhnlichen Fehler zu behandeln, oder umgekehrt Code zu schreiben, der den Fall TRUE (synchroner Abschluss) gar nicht vorsieht — das sind die beiden klassischen Bugs hier. Warum synchroner Abschluss vorkommt, behandeln wir in Abschnitt 5.

Und hier gibt es einen wichtigen Hinweis. Standardmäßig trifft auch bei einem synchron abgeschlossenen Vorgang (TRUE) zusätzlich eine Abschlussbenachrichtigung ein. Bei einem mit einem I/O-Completion-Port verknüpften Handle wird trotzdem ein Abschlusspaket in die Warteschlange gestellt, bei der Ereignismethode wird das Ereignis trotzdem signalisiert. Schreiben Sie deshalb „bei TRUE das Ergebnis sofort verarbeiten und bei eintreffender Benachrichtigung erneut verarbeiten“, entsteht der Unfall, dass derselbe Vorgang doppelt verarbeitet und sein Beleg doppelt freigegeben wird. Die sichere Grundform ist, die Ergebnisverarbeitung für beide Wege — „TRUE (synchroner Abschluss)“ und „ERROR_IO_PENDING“ — auf den Benachrichtigungsweg zu bündeln. Der dritte Weg ist die Ausnahme — schlägt die Ausgabe selbst fehl (FALSE + ein anderer Fehler), trifft keine Abschlussbenachrichtigung ein. Leiten Sie diesen Fall auf das Warten auf Benachrichtigung um, warten Sie ewig; deshalb behandelt die ausgebende Stelle den Fehler und räumt den Beleg sofort selbst auf. Nur wenn Sie auf „bei synchronem Abschluss die Benachrichtigung überspringen und sofort verarbeiten“ umschalten wollen, aktivieren Sie ausdrücklich SetFileCompletionNotificationModes (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) — das unterdrückt allerdings nur das Paket an den I/O-Completion-Port, nicht die Signalisierung von OVERLAPPED.hEvent. Es ist eine Optimierung, die sich nur für den IOCP-Weg eignet und bei der Ereignismethode nicht einsetzbar ist (Abschnitt 5).12

Übergeben Sie einem Handle im synchronen Modus ein OVERLAPPED, wird zwar ab der Offset-Position gelesen, das Blockieren bis zum Abschluss ändert sich dadurch aber nicht.2 „OVERLAPPED übergeben, also asynchron“ stimmt nicht — der Modus gehört allein dem Handle.

4. Wie erfährt man vom Abschluss? — Vier Benachrichtigungswege

Da Ausgabe und Abschluss getrennt sind, steht im Zentrum des Entwurfs, wie man „fertig“ empfängt. Es gibt im Kern vier Wege.1

I/O ist im Kernel abgeschlossen(IoCompleteRequest → Ergebnis wird über eine APC festgelegt)(1) Das Datei-Handle wird signalisiertEmpfang: WaitForSingleObject(Handle)(2) hEvent von OVERLAPPED wird signalisiertEmpfang: WaitForSingleObject + GetOverlappedResult(3) Eine Abschlussroutine wird in die APC-Warteschlange des ausgebenden Threads gestelltEmpfang: läuft während eines alertable wait wie SleepEx(4) Ein Abschlusspaket landet an einem I/O-Completion-PortEmpfang: GetQueuedCompletionStatus (Teil 3)

Abbildung 4: Die vier Wege der Abschlussbenachrichtigung. Welcher es ist, hängt von der Art der Ausgabe ab (ob hEvent vorhanden ist, ob ReadFileEx verwendet wurde, ob eine Portverknüpfung besteht)

Zunächst das Gesamtbild als Tabelle; jeder Abschnitt erklärt eine Zeile davon.

Methode Thread, auf dem die Abschlussverarbeitung läuft Anzahl gleichzeitig möglicher I/Os Geeignet für
(1) Handle-Signalisierung Ein beliebiger wartender Thread Praktisch eines. Bei mehreren gleichzeitig lässt sich nicht unterscheiden, welches abgeschlossen ist Fast nirgends (4.1)
(2) Ereignis + GetOverlappedResult Ein beliebiger wartender Thread Pro Vorgang wird ein Ereignis benötigt. Wartet man mit WaitForMultipleObjects gesammelt, liegt die Grenze bei 64 Bis zu wenigen gleichzeitigen I/Os. Gerätekommunikation (4.2)
(3) APC (ReadFileEx) Der ausgebende Thread, und nur während er sich in einem alertable wait befindet Keine Begrenzung der Anzahl, aber die gesamte Abschlussverarbeitung läuft seriell auf diesem einen Thread Kommunikationsverarbeitung, die auf einem einzigen Thread bleiben soll (4.3)
(4) I/O-Completion-Port Die dem Port zugeordnete Gruppe von Worker-Threads Viele mit wenigen Threads bedienbar Server, Threadpools (4.4)

4.1. Handle-Signalisierung — nicht verwenden

Geben Sie ohne gesetztes hEvent aus, wird bei Abschluss das Datei-Handle selbst signalisiert. Das wirkt auf den ersten Blick praktisch, aber sind mehrere Vorgänge über dasselbe Handle unterwegs, lässt sich nicht unterscheiden, welcher abgeschlossen ist.1 Abgesehen vom Sonderfall „asynchrones I/O wird immer nur einzeln ausgegeben“ ist es sicherer, darauf zu verzichten.

4.2. Ereignis + GetOverlappedResult — die Grundform

Setzen Sie bei der Ausgabe in OVERLAPPED.hEvent ein manuell zurückzusetzendes Ereignis, warten Sie mit WaitForSingleObject (oder WaitForMultipleObjects für mehrere gleichzeitig), und holen Sie mit GetOverlappedResult das Ergebnis (Erfolg/Misserfolg und übertragene Bytezahl) ab.13 Übergeben Sie GetOverlappedResults bWait TRUE, erhalten Sie „bis zum Abschluss warten und abholen“ in einem Aufruf. Ist das Ereignis automatisch zurücksetzend, gibt es die Falle, dass ein anderes Warten das Signal konsumiert und GetOverlappedResult dann festhängt — deshalb wird manuelles Zurücksetzen empfohlen.134

Das ist der übersichtlichste, solideste Weg, um einige wenige gleichzeitige I/Os zuverlässig zu behandeln. Bei Geräten wie seriellen Ports, bei denen „lesen während man schreibt“ zwingend nötig ist, ist diese Form auch heute noch im Einsatz („Fallstricke von Anwendungen für serielle Kommunikation“).

4.3. APC — wird an den ausgebenden Thread zugestellt

ReadFileEx/WriteFileEx nehmen statt eines Ereignisses eine Abschlussroutine (Callback) entgegen. Bei Abschluss wird diese Routine in die APC-Warteschlange des ausgebenden Threads gestellt und läuft, wenn der Thread in einen alertable wait eintritt, etwa SleepEx oder WaitForSingleObjectEx.14515

Das Kennzeichen dieser Methode ist, dass die Abschlussverarbeitung immer auf dem ausgebenden Thread läuft. Dadurch entfällt die Notwendigkeit für Sperren, aber solange der ausgebende Thread nicht in einen alertable wait eintritt, läuft die Abschlussroutine niemals. Die Kombination mit der Nachrichtenschleife eines UI-Threads erfordert MsgWaitForMultipleObjectsEx, und der Entwurf des Wartens ist schwierig genug, dass für den allgemeinen Fall meist Ereignis oder IOCP gewählt wird.

Und „die APC kommt nicht“ ist der klassische Bug dieser Methode. Der Grund ist fast immer einer: Das Warten ist nicht alertable.

// C++ / Win32. hFile ist ein mit FILE_FLAG_OVERLAPPED geöffnetes Handle,
// ov und buf werden bis zum Abschluss am Leben gehalten (Abschnitt 3.2)

// Schlechtes Beispiel: Die Abschlussroutine wird niemals aufgerufen
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // Kein alertable wait. Die APC wird nicht zugestellt

// Gutes Beispiel: alertable weiter warten, bis genau dieses I/O fertig ist
//
// Dieses Flag von der Abschlussroutine aus setzen (z. B. in einer Struktur gemeinsam mit ov gehalten)
volatile bool completed = false;

// Unbedingt prüfen, ob die Ausgabe selbst erfolgreich war. Rückgabewert 0 bedeutet,
// dass keine Abschlussroutine eingereiht wurde
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // Sofort abholen -- wird durch die nächste API überschrieben
    ReportError(err);                   // z. B. Gerät entfernt, Handle bereits ungültig
    return;                             // NICHT in die untere Wartschleife eintreten
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // TRUE als zweites Argument macht das Warten alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // Irgendeine APC wurde ausgeführt. Das muss aber nicht die eigene sein,
        // deshalb completed prüfen und bei Bedarf weiterwarten
        continue;
    }
    // Bei Timeout zurückgekehrt. Das I/O steht noch aus, deshalb bei Abbruch
    // mit CancelIoEx abbrechen und auf die Zustellung des Abschlusses warten
    CancelIoEx(hFile, ov);
}

Treten Sie niemals in die Wartschleife ein, ohne den Rückgabewert von ReadFileEx zu prüfen. Schlägt die Ausgabe selbst fehl — etwa weil das Gerät gerade entfernt wurde oder das Handle bereits ungültig ist —, gibt ReadFileEx 0 zurück, und es wird keine einzige Abschlussroutine eingereiht. Tritt man in diesem Zustand in while (!completed) ein, wird completed niemals wahr, und es entsteht eine Schleife, die endlos SleepEx und CancelIoEx gegen ein nicht existierendes I/O wiederholt. Und weil das nach außen genauso aussieht wie „das Gerät antwortet einfach nicht“, dauert es lange, bis man der eigentlichen Ursache auf die Spur kommt. Kommt 0 zurück, holen Sie GetLastError() sofort an Ort und Stelle ab (schon ein einziger dazwischenliegender API-Aufruf überschreibt ihn) und verlassen die Funktion, ohne in das Warten einzutreten.

Ein einzelner Aufruf von SleepEx reicht nicht. Kehrt der Aufruf per Timeout zurück, verlässt der Thread an dieser Stelle den alertable wait. Das I/O steht noch aus; verschwinden ov oder buf danach durch das Verlassen des Gültigkeitsbereichs, schreibt der Kernel in einen Puffer, den er noch für lebendig hält (Abschnitt 3.2). Halten Sie entweder das Warten aufrecht, bis die Abschlussroutine den Abschluss vermerkt hat, oder brechen Sie mit CancelIoEx ab und warten Sie dann auf die Zustellung des Abschlusses — eines von beidem.

Dass WAIT_IO_COMPLETION zurückkommt, bedeutet nur, „mindestens eine APC wurde ausgeführt“ — es bedeutet nicht zwangsläufig, dass es die Abschlussroutine des eigenen I/O war. Ist auf demselben Thread ein anderes I/O oder eine über QueueUserAPC eingereihte APC anhängig, kehrt man dafür zurück. Verlassen Sie sich deshalb nicht allein auf den Rückgabewert, sondern prüfen Sie das selbst gesetzte Flag.

Die Wahl der Wartfunktion selbst ist dabei einfach. Ersetzen Sie Sleep durch SleepEx(..., TRUE) und WaitForSingleObject durch WaitForSingleObjectEx(..., TRUE). Wenn Sie eine Abschlussroutine geschrieben haben und nichts passiert, prüfen Sie zuerst, ob der Name der Wartfunktion auf Ex endet und ob das alertable-Argument TRUE ist.5

4.4. I/O-Completion-Port — die skalierende Lösung (nächstes Mal)

Der Mechanismus, um viele gleichzeitige I/Os mit wenigen Threads zu bedienen, ist der I/O-Completion-Port (IOCP). Verknüpfen Sie ein Handle mit einem Port, landet der Abschluss in der Warteschlange des Ports, und Worker-Threads holen ihn mit GetQueuedCompletionStatus ab.6 Das ist auch der Ort, an dem .NETs async/await-I/O letztlich landet. Wir graben nächstes Mal ein ganzes Teil lang dort.

5. Das Problem „eigentlich asynchron, aber synchron abgeschlossen“

Hier stolpert der Entwurf asynchronen I/Os zuerst. Auch wenn Sie korrekt im asynchronen Modus ausgeben, ist ein synchroner Abschluss des I/O völlig normal. Microsoft nennt in einem Dokument zur Fehlerbehebung ausdrücklich die typischen Gründe.3

Keine davonReadFile/WriteFile an ein asynchrones Handle ausgebenTrifft eine Bedingung für synchronen Abschluss zu?Sofort erfüllbare Anfrage(z. B. Daten liegen im Cache)NTFS-komprimierte Datei(komprimierte Dateien werden nie asynchron)NTFS-verschlüsselte (EFS) DateiSchreibvorgang, der die Dateilänge verlängertKehrt sofort mit TRUE zurück= wurde innerhalb des Aufrufs bis zum Abschluss ausgeführtKehrt mit ERROR_IO_PENDING zurück= läuft tatsächlich asynchron

Abbildung 5: Die wichtigsten Bedingungen für synchronen Abschluss. Cache, Komprimierung, Verschlüsselung und verlängernde Schreibvorgänge werden „nie asynchron“

Für jede gibt es einen Grund.3

  • Cache-Treffer. Viele Treiber haben eine Sonderbehandlung, bei der eine sofort erfüllbare Anfrage direkt an Ort und Stelle abgeschlossen wird — bei Festplatten, wenn die Daten bereits im Cache im Arbeitsspeicher liegen. Das ist schnell, also kann man sich nicht beschweren, aber Code, der voraussetzt, dass „immer ERROR_IO_PENDING zurückkommt“, bricht genau hier.
  • Umgekehrt gibt es auch eine Falle, wenn die Daten nicht im Cache liegen. Windows’ Cache ist über File-Mapping implementiert, und weil die Behandlung eines Seitenfehlers bei fehlender Seite keinen asynchronen Mechanismus besitzt, kann ein an sich cache-fähiges asynchrones Lesen trotzdem synchron verarbeitet werden. Der Cache-Mechanismus selbst wird in Teil 4 behandelt.
  • NTFS-Komprimierung, EFS-Verschlüsselung. Der Dateisystemtreiber wandelt den Zugriff auf komprimierte oder verschlüsselte Dateien in synchronen Zugriff um.
  • Ein Schreibvorgang, der die Dateilänge verlängert. Ein Schreibvorgang, der die Länge ändert, wird synchron.

Die praktischen Konsequenzen sind einfach.

  1. Schreiben Sie unbedingt den Pfad „kehrt sofort mit TRUE zurück“. Alle drei Zweige aus Abbildung 3 gehören zum Normalbetrieb. Allerdings trifft auch bei synchronem Abschluss standardmäßig zusätzlich eine Abschlussbenachrichtigung ein, deshalb ist es sicherer, die eigentliche Ergebnisverarbeitung über den Benachrichtigungsweg zu bündeln (Abschnitt 3.3).
  2. Das lässt sich nicht als Garantie für Reaktionsfähigkeit nutzen. „Es ist asynchron, also friert die UI nicht ein“ gilt nicht. Threads, die niemals einfrieren dürfen, brauchen von vornherein einen Entwurf, bei dem sie selbst gar kein I/O ausgeben — durch Auslagerung auf einen eigenen Thread oder einen Threadpool. Diesen Boden haben wir auch in „Praxisleitfaden, um unter gewöhnlichem Windows möglichst nah an Soft-Realtime heranzukommen“ behandelt.
  3. Bei hochfrequentem I/O ist synchroner Abschluss auch eine Optimierungschance. Es gibt eine API, SetFileCompletionNotificationModes, die bei synchronem Abschluss das Paket an den I/O-Completion-Port einspart, und sie lohnt sich in Kombination mit IOCP (Teil 3).12

6. Abbrechen und Aufräumen — „Hör auf“ ist eine Bitte

Der richtige Weg, um ein lange laufendes I/O (ein nicht antwortendes Netzwerkziel, serielle Daten, die nicht ankommen) zu stoppen, ist CancelIoEx.7

TreiberI/O-ManagerAnwendungTreiberI/O-ManagerAnwendungFordert einen Abbruch für das betreffendenicht abgeschlossene IRP an (markiert es)Bricht ab, wenn der Zustand das zulässtkurz vor Abschluss kann er auch normal abschließenErst nachdem diese Benachrichtigung gesehen wurde,OVERLAPPED und Puffer freigebenCancelIoEx(Handle, OVERLAPPED)Ruft die Abbruchroutine aufIoCompleteRequest(STATUS_CANCELLED)Die Abschlussbenachrichtigung trifft einGetOverlappedResult meldet ERROR_OPERATION_ABORTED

Abbildung 6: Wie ein Abbruch tatsächlich abläuft. Auch ein abgebrochener Vorgang kommt als „Abschluss“ zurück

Kennt man den Mechanismus, ergeben sich drei zwangsläufige Konsequenzen.

  • Ein Abbruch ist eine asynchrone „Bitte“. Auch wenn CancelIoEx erfolgreich ist, hat es nur „markiert“. Ein Vorgang, der bereits kurz vor dem Abschluss stand, kann trotzdem normal abschließen.8
  • Auch ein abgebrochener Vorgang kommt über die Abschlussbenachrichtigung als ERROR_OPERATION_ABORTED zurück. Bis diese Benachrichtigung eintrifft, gelten OVERLAPPED und Puffer für den Kernel als in Benutzung. Wer sie vorher freigibt, erzeugt Speicherbeschädigung. „Seit dem Einbau des Abbruchs stürzt es ab“ hat fast immer diese Ursache.78
  • Räumen Sie nicht abgeschlossenes I/O auf, bevor Sie das Handle schließen. Wie in Teil 1 gezeigt, wird beim Schließen des letzten Handles im Rahmen der Cleanup-Verarbeitung zwar auch der Abbruch nicht abgeschlossener IRPs angestoßen, aber Code, der „das Handle schließt, während ausgegebenes I/O noch aussteht“, gerät bei der Verwaltung von Abschlussbenachrichtigung und Pufferlebensdauer leicht durcheinander. Abbrechen → Abschluss abwarten → schließen ist die zu befolgende Reihenfolge.

Zwei Ergänzungen. Das ältere CancelIo kann nur vom aufrufenden Thread selbst ausgegebene Vorgänge abbrechen (eine Einschränkung, die bis zur Einführung von CancelIoEx in Vista bestand und heute keinen Grund mehr bietet, absichtlich darauf zurückzugreifen).16 Und für einen anderen Thread, der in synchronem I/O feststeckt, verwenden Sie CancelSynchronousIo.11 „Das Betriebssystem kümmert sich nicht um Timeouts — den Abbruch entwirft man selbst“ ist der Kern der praktischen Arbeit mit asynchronem I/O.

7. Aus Sicht von .NET — eine Modus-Diskrepanz erzeugt „vorgetäuschte Asynchronität“

Alles bisher Gesagte bildet sich direkt auf .NET-Code ab. Der Konstruktorparameter useAsync von FileStream (oder FileOptions.Asynchronous) ist genau der direkte Schalter für FILE_FLAG_OVERLAPPED (siehe die Entsprechungstabelle in Teil 1).

JaNeinawait fs.ReadAsync(...)Ist das Handle im asynchronen Modus(FileOptions.Asynchronous)?Echtes asynchrones I/OGibt etwas OVERLAPPED-Äquivalentes ausDer Abschluss erreicht den Threadpool über IOCP (Teil 3)Vorgetäuschte AsynchronitätEin Thread aus dem Threadpoolübernimmt ein synchrones Read und wartet

Abbildung 7: Bei demselben ReadAsync-Aufruf sieht es unter der Haube je nach Modus des Handles völlig anders aus

Der Unterschied liegt in nur einer einzigen Zeile — dort, wo die Datei geöffnet wird. Die Aufrufstelle von ReadAsync bleibt gleich, deshalb merkt man es beim Lesen des Codes nicht.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) Vorgetäuschte Asynchronität. Lässt man useAsync weg oder setzt es auf false, wird das Handle im synchronen Modus geöffnet
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // Der Aufrufer wird nicht blockiert, aber im Hintergrund übernimmt ein Threadpool-Thread ein synchrones Read und wartet
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) Echte Asynchronität. useAsync: true bildet sich direkt auf FILE_FLAG_OVERLAPPED ab
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // Der Abschluss erreicht den Threadpool über IOCP (Teil 3)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) Ab .NET 6. Eine unmittelbare Schreibweise mit explizitem Modus und Offset
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

Der Unterschied zwischen (A) und (B) besteht nur aus dem einen Wort useAsync (FileOptions.Asynchronous zu schreiben läuft auf dasselbe hinaus) — und genau dieses eine Wort ist die Reproduktionsbedingung für „vorgetäuschte Asynchronität“. Beim Durchsehen bestehenden Codes suchen Sie nicht bei ReadAsync/WriteAsync, sondern dort, wo der FileStream erzeugt wird. Kurze Überladungen wie File.OpenRead oder new FileStream(path, FileMode.Open) öffnen allesamt im synchronen Modus. Erzeugen Sie einen FileStream aus einem SafeFileHandle, muss das Argument isAsync zudem mit dem tatsächlichen Modus des Handles übereinstimmen.

  • Ein Handle im synchronen Modus plus ReadAsync ist eine „vorgetäuschte Asynchronität“, bei der ein Threadpool-Thread das synchrone Lesen ausführt. Der Aufrufer wartet nicht, aber im Hintergrund schläft ein Thread. Bei gelegentlichem Auftreten ist der reale Schaden gering, auf einem Server oder bei hochfrequenter Verarbeitung wird es aber zur Ursache einer Erschöpfung des Threadpools.9
  • Ein Handle im asynchronen Modus plus ein synchrones Read ist die umgekehrte Diskrepanz und verursacht Overhead durch internes Warten auf den Abschluss. Modus und aufgerufene API aufeinander abzustimmen ist hier der Grundsatz.10
  • Ab .NET 6 wurde das Innenleben von FileStream vollständig neu geschrieben, und es kam eine API hinzu — File.OpenHandle + RandomAccess —, die „mit explizitem SafeFileHandle und Offset lesen und schreiben“ erlaubt.9 Diese Form, bei der jedes Mal ein Offset übergeben wird, ist genau die rohe Win32-Gestalt, die wir in diesem Artikel gesehen haben: asynchrones Handle plus OVERLAPPED.Offset.
  • Ist das Handle im asynchronen Modus, landet ein Abbruch von Datei-I/O über CancellationToken intern letztlich bei CancelIoEx. Hinter einem ReadAsync, dem ein Token übergeben wurde und das mit einer OperationCanceledException endet, läuft genau das Diagramm aus Abschnitt 6. Auch hier gilt: Der Abbruch ist eine „Bitte“, und Unmittelbarkeit ist nicht garantiert. Bei der „vorgetäuschten Asynchronität“ eines Handles im synchronen Modus gibt es dagegen keinen Overlapped-Vorgang, den man abbrechen könnte, sodass dieser Weg nicht zur Verfügung steht. Neuere .NET-Laufzeiten enthalten zwar auch einen Mechanismus, der für solche synchron laufenden Aufrufe einen Abbruch über CancelSynchronousIo versucht, aber ob das wirkt, hängt von der Laufzeitversion und der Art des Vorgangs ab und garantiert keinen zuverlässigen Abbruch. Soll der Abbruch Teil des Entwurfs sein, ist der richtige Weg, den Modus abzustimmen und echtes asynchrones I/O zu verwenden.

Für die Praxis eine Ebene darüber — wie man async/await tatsächlich schreiben sollte (ConfigureAwait, die Beziehung zum UI-Thread) — siehe „Praxisleitfaden zu C# async/await“ und „Async und der UI-Thread bei WPF/WinForms auf einen Blick“. Dieser Artikel ist ein Stockwerk darunter, das nächste Mal (IOCP) noch ein Stockwerk tiefer.

8. Zusammenfassung

  • Synchrones und asynchrones I/O sind keine getrennten Rohrleitungen, sondern der Unterschied liegt darin, ob der I/O-Manager auf den Abschluss wartet oder ohne zu warten zurückkehrt. Der Thread bei synchronem I/O schläft in einem Wartezustand und verbraucht keine CPU.1
  • Der Modus liegt am Handle (Dateiobjekt), der Zustand am Vorgang (OVERLAPPED). Weil bei einem asynchronen Handle kein Dateizeiger geführt wird, geben Sie bei Dateien mit Position jedes Mal über Offset an, wo gelesen wird.24
  • Halten Sie OVERLAPPED und Puffer bis zur Abschlussbenachrichtigung am Leben und rühren Sie sie nicht an. Bereiten Sie einen für jede gleichzeitige Ausgabe vor. Wiederverwendung bedeutet Datenbeschädigung.3
  • Die Abschlussbenachrichtigung erfolgt über vier Wege: Handle, Ereignis, APC, IOCP. Verwenden Sie bei mehreren gleichzeitigen I/Os keine Handle-Signalisierung, nutzen Sie manuell zurücksetzende Ereignisse, und setzen Sie für APCs einen alertable wait voraus.1135
  • Auch asynchron ausgegebenes I/O schließt bei Cache, NTFS-Komprimierung/-Verschlüsselung und verlängernden Schreibvorgängen synchron ab. Schreiben Sie den Pfad „kehrt sofort mit TRUE zurück“ unbedingt als Normalfall, und verlassen Sie sich nicht darauf für Reaktionsfähigkeit.3
  • Ein Abbruch ist eine Bitte. Warten Sie auch nach CancelIoEx die Abschlussbenachrichtigung (ERROR_OPERATION_ABORTED) ab, bevor Sie aufräumen. Abbrechen → Abschluss bestätigen → schließen, in dieser Reihenfolge.78
  • .NETs FileOptions.Asynchronous ist die direkte Leitung zu FILE_FLAG_OVERLAPPED, und eine Diskrepanz zwischen Modus und API erzeugt „vorgetäuschte Asynchronität“. Unter der Haube eines CancellationToken läuft CancelIoEx.910

Weiter geht es in Teil 3, „I/O-Completion-Ports (IOCP) und der .NET-Threadpool — Der Keller unter async/await“. Wir steigen hinab zu der Frage, warum IOCP — in Abschnitt 4.4 hier nur dem Namen nach erwähnt — ein Entwurf ist, der „eine Warteschlange von Abschlussbenachrichtigungen“ und „die Kontrolle über die Anzahl ausführender Threads“ vereint, und sogar dazu, auf welchem Thread die Fortsetzung eines await tatsächlich läuft.

Verwandte Artikel

Verwandte Beratungsleistungen

Die KomuraSoft LLC übernimmt den Entwurf von Windows-Geschäftsanwendungen und Geräte-Kommunikationsanwendungen, die asynchrones I/O verwenden, sowie die Ursachenermittlung bei Störungen wie Einfrieren, Abstürzen bei Abbruch oder Erschöpfung des Threadpools.

  1. Microsoft Learn, Synchronous and asynchronous I/O. Darüber, dass bei synchronem I/O die Funktion bis zum Abschluss blockiert, während bei asynchronem I/O die ausgebende Funktion sofort zurückkehrt und der Thread mit anderer Arbeit fortfahren kann, dass asynchrones I/O ein mit FILE_FLAG_OVERLAPPED geöffnetes Handle erfordert, über die Benachrichtigungsmethoden für den Abschluss — die Signalisierung des Datei-Handles, die Signalisierung des in der OVERLAPPED-Struktur angegebenen Ereignisses, eine während eines alertable wait ausgeführte Abschlussroutine (APC) und I/O-Completion-Ports —, und darüber, dass die Signalisierung des Datei-Handles nicht unterscheiden kann, welcher Vorgang abgeschlossen ist, wenn mehrere Vorgänge gleichzeitig laufen.  2 3 4 5 6 7 8 9

  2. Microsoft Learn, ReadFile function. Darüber, dass lpOverlapped bei einem mit FILE_FLAG_OVERLAPPED geöffneten Handle erforderlich ist und die Leseanfangsposition über Offset/OffsetHigh der OVERLAPPED-Struktur angegeben wird, dass bei asynchroner Verarbeitung FALSE und ERROR_IO_PENDING zurückgegeben werden, dass das System für ein asynchrones Handle keinen Dateizeiger führt, und dass beim Übergeben eines OVERLAPPED an ein ohne FILE_FLAG_OVERLAPPED geöffnetes Handle zwar ab dem angegebenen Offset gelesen wird, ReadFile aber weiterhin erst nach Abschluss des Lesevorgangs zurückkehrt.  2 3 4 5

  3. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. Über die Gründe, warum für asynchrones Verhalten codiertes I/O trotzdem synchron abschließt: eine NTFS-komprimierte Datei (der Dateisystemtreiber greift nicht asynchron auf komprimierte Dateien zu, sodass alle Vorgänge synchron werden), eine NTFS-verschlüsselte Datei, ein Schreibvorgang, der die Dateilänge verlängert, sowie der Fall, dass der Treiber den Vorgang bei sofort erfüllbarer Anfrage (etwa wenn die Daten bereits im Arbeitsspeicher-Cache liegen) direkt abschließt und TRUE zurückgibt; darüber, dass Windows’ Cache über File-Mapping implementiert ist und bei fehlender Seite kein asynchroner Seitenfehlermechanismus existiert; und zusätzlich darüber, dass für drei ausgegebene I/Os drei OVERLAPPED-Strukturen benötigt werden, Wiederverwendung zu unvorhersehbaren Ergebnissen oder Datenbeschädigung führt, und dass der zugehörige Datenpuffer bis zum Abschluss des Vorgangs nie gelesen oder geschrieben werden darf.  2 3 4 5 6 7

  4. Microsoft Learn, OVERLAPPED structure. Darüber, dass die OVERLAPPED-Struktur Informationen für asynchrone Ein- und Ausgabe hält, dass Offset/OffsetHigh die Dateiposition, hEvent ein bei Abschluss signalisiertes Ereignis und Internal/InternalHigh den Statuscode des Vorgangs sowie die übertragene Bytezahl halten, dass die Struktur während der Ausführung des Vorgangs gültig und unverändert gehalten werden muss, und über Vorsichtsmaßnahmen bei der Verwendung des Ereignisses.  2 3 4

  5. Microsoft Learn, Alertable I/O. Darüber, dass bei alertable I/O ein Eintrag für die Abschlussroutine in die APC-Warteschlange des Threads gestellt wird, dass die APC ausgeführt wird, wenn der Thread über SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx u. Ä. in einen alertable Zustand eintritt, und dass eine APC immer im Kontext des Threads ausgeführt wird, der sie ausgelöst hat.  2 3 4

  6. Microsoft Learn, I/O Completion Ports. Darüber, dass ein I/O-Completion-Port ein effizientes Threading-Modell zur Verarbeitung vieler asynchroner I/O-Anfragen auf einem Mehrprozessorsystem bereitstellt, dass durch die Verknüpfung eines Datei-Handles mit einem Port Abschlusspakete in eine Warteschlange gestellt werden, die Worker-Threads mit GetQueuedCompletionStatus abholen, und dass der Port die Anzahl gleichzeitig laufender Threads steuert.  2

  7. Microsoft Learn, CancelIoEx function. Darüber, dass CancelIoEx nicht abgeschlossenes I/O eines angegebenen Handles zum Abbruch markiert, unabhängig davon, welcher Thread es ausgegeben hat, dass die Angabe von lpOverlapped nur diesen einen Vorgang trifft, NULL dagegen alle nicht abgeschlossenen I/Os, dass ein abgebrochener Vorgang mit ERROR_OPERATION_ABORTED abschließt, und dass der Abbruch aller Vorgänge nicht garantiert ist, weshalb man warten muss, bis die Abschlussverarbeitung erledigt ist.  2 3 4

  8. Microsoft Learn, Canceling pending I/O operations. Über den Mechanismus zum Abbrechen nicht abgeschlossener I/Os, darüber, dass ein Vorgang trotz angeforderten Abbruchs bereits auf dem Weg zum Abschluss sein kann, dass der Abschluss eines abgebrochenen Vorgangs bestätigt werden sollte, bevor Ressourcen freigegeben werden, und über die Aufteilung, für synchrone Vorgänge CancelSynchronousIo und für asynchrone Vorgänge CancelIo/CancelIoEx zu verwenden.  2 3 4

  9. Microsoft .NET Blog, File IO improvements in .NET 6. Darüber, dass die interne Implementierung von FileStream in .NET 6 vollständig neu geschrieben wurde, dass sich die Strategie danach richtet, ob das Handle im asynchronen Modus geöffnet ist, dass File.OpenHandle ein SafeFileHandle direkt liefert und RandomAccess threadsicheres Lesen und Schreiben mit explizitem Offset ermöglicht, und dass asynchrone Aufrufe an ein nicht im asynchronen Modus geöffnetes Handle an den Threadpool ausgelagert werden.  2 3 4

  10. Microsoft Learn, Asynchronous file I/O (.NET). Über das Konzept hinter asynchronem Datei-I/O in .NET, darüber, dass useAsync (FileOptions.Asynchronous) im Konstruktor von FileStream angegeben wird, um asynchrones I/O auf Betriebssystemebene zu aktivieren, und über die Unterscheidung zwischen der Verwendung synchroner und asynchroner Methoden.  2 3

  11. Microsoft Learn, CancelSynchronousIo function. Darüber, dass CancelSynchronousIo einen von einem angegebenen Thread ausgeführten synchronen I/O-Vorgang zum Abbruch markiert, und dass der abgebrochene Vorgang als Fehlschlag mit ERROR_OPERATION_ABORTED zurückgegeben wird.  2

  12. Microsoft Learn, SetFileCompletionNotificationModes function. Darüber, dass FILE_SKIP_COMPLETION_PORT_ON_SUCCESS erlaubt, bei sofort erfolgreichem I/O das Einreihen eines Abschlusspakets an einem I/O-Completion-Port zu überspringen, und dass FILE_SKIP_SET_EVENT_ON_HANDLE erlaubt, das Setzen des Ereignisses des Datei-Handles zu überspringen.  2

  13. Microsoft Learn, GetOverlappedResult function. Darüber, dass GetOverlappedResult das Ergebnis eines asynchronen Vorgangs (Erfolg/Misserfolg und übertragene Bytezahl) liefert, dass die Übergabe von TRUE für bWait auf den Abschluss des Vorgangs wartet, und darüber, dass bei einem automatisch zurücksetzenden Ereignis in OVERLAPPEDs hEvent, das von einem anderen Warten konsumiert wird, ein Aufruf mit bWait=TRUE den Abschluss möglicherweise nicht erkennt und hängen bleibt, weshalb ein manuell zurückzusetzendes Ereignis verwendet werden sollte.  2 3

  14. Microsoft Learn, ReadFileEx function. Darüber, dass ReadFileEx eine beim Abschluss des Lesevorgangs aufgerufene Abschlussroutine (FileIOCompletionRoutine) entgegennimmt, dass die Abschlussroutine läuft, wenn sich der aufrufende Thread in einem alertable-wait-Zustand befindet, und dass ein mit FILE_FLAG_OVERLAPPED geöffnetes Handle erforderlich ist. 

  15. Microsoft Learn, Asynchronous Procedure Calls. Darüber, dass eine APC eine Funktion ist, die asynchron im Kontext eines bestimmten Threads ausgeführt wird, dass jeder Thread seine eigene APC-Warteschlange besitzt, und dass eine Benutzermodus-APC nur ausgeführt wird, wenn sich der Thread in einem alertable Zustand befindet. 

  16. Microsoft Learn, CancelIo function. Darüber, dass CancelIo nur I/O-Vorgänge abbrechen kann, die der aufrufende Thread selbst ausgegeben hat, und dass zum Abbrechen von auch durch andere Threads ausgegebenen Vorgängen CancelIoEx zu verwenden ist. 

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 ändert sich, wenn ich FILE_FLAG_OVERLAPPED setze?
Das Dateiobjekt hinter dem Handle wird im „asynchronen Modus“ geöffnet. Das ist eine Eigenschaft pro Handle, die im Moment des CreateFile-Aufrufs feststeht — Sie können nicht pro Aufruf zwischen synchron und asynchron umschalten. Bei einem Handle im asynchronen Modus müssen Sie ReadFile/WriteFile immer eine OVERLAPPED-Struktur übergeben. Das System führt für dieses Handle keinen Dateizeiger (aktuelle Position), daher geben Sie bei Geräten mit einer Position, etwa einer Datei auf der Festplatte, die Lese-/Schreibposition jedes Mal über das Offset-Feld der OVERLAPPED-Struktur an (bei Geräten ohne Positionsbegriff, etwa seriellen Ports, wird Offset nicht verwendet). Ein ausgegebener Vorgang kann die Kontrolle zurückgeben, bevor er abgeschlossen ist; in diesem Fall gibt ReadFile FALSE zurück, und GetLastError meldet ERROR_IO_PENDING. Den Abschluss erhalten Sie über einen Benachrichtigungsmechanismus — ein Ereignis, eine APC, einen I/O-Completion-Port und so weiter.
Warum kommt ein asynchron ausgegebenes I/O sofort abgeschlossen zurück?
Weil der asynchrone Modus bedeutet, dass Sie nicht auf den Abschluss warten müssen, nicht dass Sie niemals warten müssen. Microsofts Dokumentation nennt die typischen Gründe, warum ein asynchron ausgegebener Vorgang trotzdem synchron abschließt: eine sofort erfüllbare Anfrage (etwa wenn die Daten bereits im Cache liegen), eine NTFS-komprimierte Datei, eine NTFS-verschlüsselte (EFS) Datei sowie ein Schreibvorgang, der die Dateilänge verlängert. In diesen Fällen geben ReadFile/WriteFile TRUE zurück, und das Ergebnis steht bereits fest. Code, der asynchrones I/O verwendet, muss deshalb sowohl den Fall „Rückgabe mit ERROR_IO_PENDING“ als auch den Fall „sofortiger Abschluss“ vorsehen — und Reaktionsfähigkeit ist damit nie absolut garantiert. Standardmäßig trifft übrigens auch bei einem synchron abgeschlossenen Vorgang zusätzlich eine Abschlussbenachrichtigung ein (ein signalisiertes Ereignis oder ein Paket an einem I/O-Completion-Port), weshalb es am sichersten ist, die Ergebnisverarbeitung allein über den Benachrichtigungsweg zu bündeln.
Darf ich eine OVERLAPPED-Struktur wiederverwenden?
Nicht für mehrere gleichzeitig laufende Vorgänge. Eine OVERLAPPED-Struktur repräsentiert „den Zustand eines einzelnen, gerade laufenden Vorgangs“, und Microsofts Dokumentation hält ausdrücklich fest, dass für drei ausgegebene I/O-Vorgänge drei OVERLAPPED-Strukturen benötigt werden und eine Wiederverwendung zu unvorhersehbaren Ergebnissen oder Datenbeschädigung führt. Bis ein Vorgang abgeschlossen ist, müssen sowohl die Struktur als auch der Lese-/Schreibpuffer gültig bleiben, und ihr Inhalt darf nicht angefasst werden. Wollen Sie eine Struktur nach Abschluss erneut verwenden, initialisieren Sie sie jedes Mal neu, damit Restdaten aus der vorherigen Nutzung keine Wirkung mehr haben. Für hEvent ist ein manuell zurückzusetzendes Ereignis die sichere Wahl.
Wie breche ich ein laufendes I/O mittendrin ab?
Mit CancelIoEx lässt sich, unabhängig davon, welcher Thread den Vorgang ausgegeben hat, ein Abbruch für die nicht abgeschlossenen I/Os eines angegebenen Handles anfordern. Übergeben Sie im zweiten Argument eine OVERLAPPED-Struktur, um nur diesen einen Vorgang zu treffen, oder NULL, um alle Vorgänge dieses Handles zu treffen. Das ältere CancelIo kann nur Vorgänge abbrechen, die der aufrufende Thread selbst ausgegeben hat. Wichtig ist: Ein Abbruch ist eine „Bitte“, keine sofortige „Garantie“. Ein Vorgang, der bereits kurz vor dem Abschluss stand, kann trotzdem normal abschließen, und ein abgebrochener Vorgang wird als Abschluss mit ERROR_OPERATION_ABORTED gemeldet. In beiden Fällen dürfen Sie die OVERLAPPED-Struktur und den Puffer erst freigeben, wenn die Abschlussbenachrichtigung eingetroffen ist. Für einen anderen Thread, der in synchronem I/O feststeckt, gibt es die eigene API CancelSynchronousIo.
Was passiert, wenn ich bei .NETs FileStream FileOptions.Asynchronous (useAsync) nicht angebe?
Das Handle wird im synchronen Modus geöffnet, sodass ReadAsync/WriteAsync kein echtes asynchrones I/O ergibt: Stattdessen übernimmt ein Thread aus dem Threadpool das synchrone Lesen/Schreiben — eine „vorgetäuschte Asynchronität“. Der aufrufende Thread wird nicht blockiert, aber im Hintergrund wartet ein anderer Thread, was zur Erschöpfung des Threadpools und zu geringerer Skalierbarkeit führen kann. Öffnen Sie umgekehrt im asynchronen Modus und rufen dann das synchrone Read/Write auf, entsteht intern Overhead durch das Warten auf den Abschluss. Der Grundsatz lautet, „den Modus des Handles“ und „die aufgerufene API“ aufeinander abzustimmen; ab .NET 6 erlauben File.OpenHandle und RandomAccess eine unmittelbare Schreibweise mit explizitem Modus und Offset.

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