Die Tiefen von Windows I/O (Teil 1) — Jedes Lesen und Schreiben wird zu einem IRP: Das Gesamtbild des I/O-Systems

· · Windows, Win32, I/O, Kernel, Gerätetreiber, .NET, CSharp, Fehleranalyse

Eine einzige Zeile File.ReadAllText, oder ein einziger Aufruf von ReadFile. Was geschieht in Windows zwischen dem Moment, in dem Sie die Funktion aufrufen, und dem Moment, in dem sie zurückkehrt?

Öffnen Sie bei der Fehlersuche Process Monitor, reihen sich unvertraute Begriffe wie IRP_MJ_READ und FASTIO_READ vor Ihnen auf. Verfolgen Sie einen Handle-Leak, ist eine Datei, von der Sie sicher waren, dass CloseHandle sie erledigt hatte, immer noch am Leben. „Auf einem Netzlaufwerk verhält es sich anders.“ „Nur auf Rechnern mit installierter Antivirensoftware ist es langsam.“ Diese Phänomene, die im Alltag von Geschäftsanwendungen immer wieder auftauchen, spielen sich alle auf demselben Boden ab: dem Windows-I/O-System.

Dieser Artikel eröffnet eine Serie, „Die Tiefen von Windows I/O“, die diesen Boden von unten nach oben durchgräbt. So wie das renommierte Buch Windows Internals (deutscher Titel Insider-Wissen: Microsoft Windows Internals) bis hinunter zum Entwurf des Kernels erklärt, geht es auch in dieser Serie nicht um „wie man die API benutzt“, sondern um „warum sie sich so verhält, wie sie sich verhält“.1 Hier die geplante Reihenfolge:

  1. Das Gesamtbild des I/O-Systems — Jedes Lesen und Schreiben wird zu einem IRP (dieser Artikel)
  2. Synchrones und asynchrones I/O — Was OVERLAPPED wirklich bedeutet
  3. I/O-Completion-Ports (IOCP) und der .NET-Threadpool — Der Keller unter async/await
  4. Der Cache-Manager — Wann kommt Ihr WriteFile wirklich auf der Festplatte an?
  5. Innenleben von NTFS — Das Dateisystem anhand der MFT verstehen
  6. Filtertreiber und Minifilter — Warum Procmon und Virenscanner sich in Ihr I/O einklinken können

Dieser erste Teil legt anhand von Diagrammen das Personal und den Ablauf einer Anfrage fest, auf dem alle folgenden Teile aufbauen. Es ist kein Artikel über das Schreiben von Treibern. Es ist ein Artikel dafür, dass Anwendungsentwickler den API-Aufruf, den sie gerade gemacht haben, bis zu seinem Ziel verfolgen können.

Voraussetzungen: Sie können diesem Artikel folgen, wenn Sie in C# schon einmal FileStream verwendet oder in C/C++ CreateFile/ReadFile aufgerufen haben. Erfahrung in der Treiberentwicklung ist nicht nötig, und Kernel-seitiger Code taucht nur als konzeptionelle Darstellung auf. Als grobe Lesezeit rechnen Sie mit rund 25 Minuten, wenn Sie ihn vollständig mit Blick auf die Diagramme durchgehen, oder 2–3 Minuten, wenn Sie nur das Fazit aus Abschnitt 1 wollen.

Leitfaden zum Querlesen: Der Artikel ist lang, deshalb hier Einstiegspunkte nach Zweck.

  • Nur das Gesamtbild erfassen → Abschnitt 1 (Fazit) → Abschnitt 2 (Namensauflösung) → Abschnitt 5 (ein Umlauf von ReadFile)
  • Begriffe sortieren (Treiber-/Geräte-/Dateiobjekt, IRP) → Abschnitte 3 und 4
  • Wissen, wie es in der Praxis kaputtgeht (Handle-Leaks, „Datei in Verwendung“) → Abschnitt 6
  • Selbst ausprobieren → Abschnitt 7

1. Das Wichtigste zuerst

  • Windows-I/O ist paketgesteuert. Die meisten an einen Gerätetreiber gesendeten Anfragen werden in ein Paket namens IRP (I/O Request Packet) gepackt und fließen von oben nach unten durch einen Stapel von Treibern (den Gerätestapel).23
  • CreateFile öffnet nicht zwangsläufig eine „Datei“. Die Namensauflösung findet im Namensraum des Object Managers statt, und C: ist im Kern ein symbolischer Link auf einen NT-Gerätenamen wie \Device\HarddiskVolume3 (Abschnitt 2).45
  • In dieser Geschichte gibt es drei Figuren. Das Treiberobjekt mit einer Tabelle von Verarbeitungsfunktionen, das Geräteobjekt als Ziel einer Anfrage und das Dateiobjekt, das den Zustand eines bestimmten Öffnens hält. Das HANDLE, das Sie besitzen, ist eine Referenz auf ein Dateiobjekt (Abschnitt 3).6789
  • Jeder Treiber hat nur drei Möglichkeiten. Das IRP selbst abschließen, es an den darunterliegenden Treiber weitergeben oder es zurückstellen und später abschließen. Diese Kombination aus drei Möglichkeiten erklärt Filtertreiber, Caching und asynchrones I/O gleichermaßen (Abschnitt 4).310
  • Ganz unten im Kernel gibt es keinen eigenen Mechanismus namens „synchrones I/O“. Synchrones I/O ist im Grunde nur die Garantie „der Aufruf kehrt nicht vor Abschluss zurück“; gewartet wird nur, wenn eine Anfrage zurückgestellt (pending) wird. Genau hier öffnen sich die Türen zu Teil 2 und Teil 3 (Abschnitt 5).11
  • CloseHandle bedeutet nicht „schließen“, sondern „ein Handle zurückgeben“. Es gibt zwei Stufen: Cleanup, wenn das letzte Handle schließt, und Close, wenn auch jede Referenz im Kernel verschwunden ist. Genau diese Lücke ist der Grund, warum eine Datei „in Verwendung“ meldet, obwohl Sie dachten, sie geschlossen zu haben (Abschnitt 6).1213
  • Diese Schicht können Sie selbst beobachten. WinObj zeigt Ihnen den Namensraum, Process Monitor zeigt Ihnen den Fluss der IRPs. Procmons eigenes Vokabular ist das Vokabular dieses Artikels (Abschnitt 7).14

2. Was „alles sieht wie eine Datei aus“ wirklich bedeutet

2.1. Wohin geht der Name, den Sie CreateFile übergeben?

Jede Win32-Datei-API beginnt mit einem „Namen“. Ein Pfad wie C:\project\report.csv, ein UNC-Pfad wie \\server\share\data.csv, eine Geräteangabe wie \\.\COM3 — all das lässt sich an ein und dasselbe CreateFile übergeben.15

Hinter dieser Konsistenz steckt ein einziger Namensraum, verwaltet vom Object Manager des Kernels. Im Kernel sind Geräte, Ereignisse und Shared-Memory-Sektionen gleichermaßen als „Objekte“ in diesem baumförmigen Namensraum registriert. Mit Sysinternals’ WinObj können Sie direkt in diesen Namensraum hineinsehen.14

\ (Wurzel des Namensraums)\Device(von Treibern erzeugte Geräteobjekte)\GLOBAL??(Ablageort der von Win32 aus sichtbaren, globalen Namen)\BaseNamedObjects(benannte Mutexe u. Ä.)HarddiskVolume3Serial0Mup (Netzwerk-Redirector)C: → \Device\HarddiskVolume3COM1 → \Device\Serial0PhysicalDrive0 → \Device\Harddisk0\DR0

Abbildung 1: Der Namensraum des Object Managers (Auszug). Was unter \GLOBAL?? liegt, sind symbolische Links; die eigentlichen Objekte liegen unter \Device

Der springende Punkt ist, dass die Namen, die eine Win32-Anwendung verwendet (Laufwerksbuchstaben, COM-Port-Namen), und die Namen, die der Kernel verwendet (NT-Gerätenamen), auf unterschiedlichen Ebenen liegen. Symbolische Links verbinden beide.

Wenn ein Treiber sein Gerät für Win32-Anwendungen sichtbar machen möchte, erzeugt er mit IoCreateSymbolicLink einen symbolischen Link von einem MS-DOS-Gerätenamen wie \DosDevices\COM1 auf einen NT-Gerätenamen.4 Auch C: funktioniert nach demselben Prinzip: Im Kern ist es ein Link auf ein Volume-Gerät wie \Device\HarddiskVolume3.

Die Namensauflösung von CreateFile("C:\project\report.csv") verläuft daher so:

Von der App übergebener NameC:\project\report.csvWin32-Schicht wandelt in NT-Form um\??\C:\project\report.csvObject Manager durchsucht den Namensraumund erkennt: \??\C: ist ein symbolischer LinkFolgt dem Link und ersetzt ihn\Device\HarddiskVolume3\project\report.csvÜber \Device\HarddiskVolume3wird das Geräteobjekt des Volumes erreichtDie Auflösung des restlichen \project\report.csv überlässtder I/O-Manager dem Dateisystemtreiber (NTFS),indem er IRP_MJ_CREATE ausgibt

Abbildung 2: Namensauflösung bei CreateFile. Die erste Hälfte ist Aufgabe des Object Managers, sobald ein Gerät erreicht ist, wird die zweite Hälfte zur Aufgabe des Dateisystems

Dieses Bild löst einige Fragen, die immer wieder auftauchen.

  • Was das \\.\-Präfix bedeutet. Das \\.\ in \\.\PhysicalDrive0 oder \\.\COM10 ist eine Notation, um direkt auf den Win32-Gerätenamensraum (in etwa das \??-Verzeichnis) zu zeigen. Es umgeht den Laufwerksbuchstaben und benennt den Ablageort des Links direkt.515
  • Warum CON oder NUL nicht als Dateiname verwendet werden können. Diese Namen sind als MS-DOS-Gerätenamen reserviert, und egal an welcher Stelle des Pfads sie auftauchen, können sie stattdessen auf die Geräteseite aufgelöst werden.5 Die praktischen Fallstricke dazu sind in „MAX_PATH und die Fallstricke von Windows-Pfaden und Dateinamen“ zusammengefasst.
  • Auch UNC-Pfade sind kein Sonderfall. \\server\share wird auf das Gerät des Netzwerk-Redirectors (\Device\Mup) aufgelöst, und von dort trägt der SMB-Client die Anfrage über das Netzwerk. Die Wurzel des Problems, dass sich lokale Pfade und UNC-Pfade unterschiedlich verhalten, liegt darin, dass sich das Zielgerät unterscheidet (siehe „Fallstricke von Netzlaufwerken und UNC-Pfaden“).

Um genau zu sein: \?? ist kein Alias für ein tatsächlich vorhandenes Verzeichnis, sondern ein virtueller Einstiegspunkt, der eine Suchreihenfolge darstellt: „Zuerst die lokale, an die Anmeldesitzung gebundene DOS-Gerätezuordnung prüfen, und falls dort nichts gefunden wird, auf \GLOBAL?? zurückfallen.“ Mit net use oder subst erzeugte Laufwerksbuchstaben landen in diesem lokalen Bereich, weshalb auf demselben PC unterschiedliche Benutzer (Anmeldesitzungen) unterschiedliche Laufwerke sehen können und ein Dienst die Netzlaufwerke eines Benutzers nicht sieht. Abbildung 1 zeigt nur die globale Seite (\GLOBAL??).

Die präzise Formulierung von „unter Windows sieht alles wie eine Datei aus“ lautet also: Jeder Name wird letztlich auf ein Geräteobjekt aufgelöst, und jede darauffolgende Anfrage wird in dasselbe Format vereinheitlicht — das IRP. Doch was genau ist dieses Geräteobjekt? Sortieren wir das Personal.

3. Das Personal besteht aus drei Objekten

Die Struktur des Windows-I/O-Systems lässt sich als Beziehung zwischen drei Arten von Kernelobjekten zeichnen.

Ab hier häufen sich Fachbegriffe. Damit Sie nicht den Überblick verlieren, zunächst eine Übersetzungstabelle, die „das, was Sie in Ihrem üblichen Code sehen“ dem „Namen aus Kernelsicht“ gegenüberstellt. Die Abschnitte 3 und 4 sind in der Sprache der rechten Spalte geschrieben.

Was Sie auf der Win32-/.NET-Seite sehen Das Gegenstück im Kernel Kurz gesagt
HANDLE / SafeFileHandle Ein Eintrag in der Handle-Tabelle (eine Referenz auf ein Dateiobjekt) Eine Nummernmarke für „ein Öffnen“ (3.3)
Der offene Zustand, den ein FileStream hält Dateiobjekt Der Container für Freigabemodus, Flags und aktuelle Position (3.3)
Ein Laufwerksbuchstabe C: oder \\.\COM3 Geräteobjekt (das Ergebnis der Namensauflösung) Das Ziel der Anfrage (Abschnitt 2, 3.2)
„Der NTFS-Treiber“, „der Datenträgertreiber“ Treiberobjekt Eine Tabelle von Verarbeitungsfunktionen, eine pro Anfragetyp (3.1)
Ein Aufruf von ReadFile / stream.Read Normalerweise ein IRP Das Anfragepaket, das zu seinem Ziel transportiert wird (Abschnitt 4). Synchrones Lesen und Schreiben einer im Cache liegenden Datei nimmt eine Abkürzung namens Fast I/O, die das Erzeugen eines IRP überspringt (5.2). In Procmon sind das die Zeilen, die mit FASTIO_ beginnen
FileOptions / CreateFile-Flags Auf dem Dateiobjekt hinterlegte Attribute Bestimmen Read-Ahead und asynchrones Verhalten (Übersetzungstabelle in Abschnitt 7)
Der Wert von GetLastError / eine .NET-Ausnahme NTSTATUS (STATUS_PENDING u. Ä.) Der Abschlussstatus, der auf dem Weg nach oben in einen Win32-Fehlercode übersetzt wird

3.1. Das Treiberobjekt — Eine Tabelle von Verarbeitungsfunktionen

Wird ein Treiber geladen, erzeugt der I/O-Manager ein Treiberobjekt (DRIVER_OBJECT), das ihn repräsentiert.6 Aus Sicht eines Anwendungsentwicklers ist das wichtigste Mitglied das Array MajorFunction — eine Tabelle, die „Anfragetyp“ auf „Verarbeitungsfunktion“ abbildet, wobei der Anfragetyp durch einen Major-Function-Code wie IRP_MJ_CREATE (öffnen), IRP_MJ_READ (lesen), IRP_MJ_WRITE (schreiben), IRP_MJ_CLEANUP oder IRP_MJ_CLOSE ausgedrückt wird.16

In C# gezwungen, sieht das etwa so aus:

// Konzeptionelle Darstellung. In Wirklichkeit ist es eine C-Struktur im Kernel
class DriverObject
{
    // Indiziert nach IRP_MJ_XXX. Insgesamt 28 Arten
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// Bei ntfs.sys steht in MajorFunction[IRP_MJ_READ] die "Leseverarbeitung von NTFS"

3.2. Das Geräteobjekt — Das Ziel einer Anfrage

Für jedes Gerät, um das er sich kümmert, erzeugt ein Treiber ein Geräteobjekt (DEVICE_OBJECT).7 Das ist das Ziel einer I/O-Anfrage. Es steht nicht zwingend in einer 1:1-Beziehung zu einem physischen Gerät — es kann sich um eine logische Entität wie ein Volume (HarddiskVolume3) handeln oder um ein „nur zum Abfangen erzeugtes Gerät“ eines Filtertreibers. Ein Geräteobjekt verweist auf das Treiberobjekt, das es erzeugt hat, sodass mit dem Ziel auch die Tabelle der Verarbeitungsfunktionen feststeht.

3.3. Das Dateiobjekt — Der Zustand von „einmal geöffnet“

Jedes Mal, wenn CreateFile erfolgreich ist, erzeugt der Kernel ein Dateiobjekt. Das ist nicht „die Datei auf der Platte“ selbst, sondern ein Objekt, das eine bestimmte Sitzung des Öffnens dieser Datei (oder dieses Geräts) repräsentiert.8 Öffnen Sie dieselbe Datei zweimal, entstehen zwei Dateiobjekte. Der aktuelle Dateizeiger (bei einem synchronen Handle) sowie der Freigabemodus und die Flags beim Öffnen liegen hier.

Und das HANDLE, das Ihre Anwendung erhält, ist eine Referenz auf ein Dateiobjekt, vermittelt über die prozesseigene Handle-Tabelle.9

Kernel-AdressraumProzess (Benutzermodus)Dateiobjekt 1report.csv zum Lesen geöffnetaktueller Offset: 4096Dateiobjekt 2report.csv zum Anhängen geöffnetaktueller Offset: 65536Geräteobjektentspricht HarddiskVolume3Treiberobjekt NTFSMajorFunction = Tabelle der VerarbeitungsfunktionenHANDLE 0x1A4HANDLE 0x1B8

Abbildung 3: Die Beziehung der drei Objekte. Ein Handle zeigt über die Handle-Tabelle auf ein Dateiobjekt, ein Dateiobjekt zeigt auf ein Gerät, ein Gerät zeigt auf einen Treiber

Mit diesem Bild im Kopf verwandelt sich einiges praktisches Wissen von „auswendig gelernt“ in „selbstverständlich“.

  • Was ein Handle-Leak wirklich ist: ein Haufen von Dateiobjekten (und den dahinterhängenden Ressourcen), die weiterhin referenziert werden und nicht freigegeben werden können. Gezählt werden Einträge in der Handle-Tabelle — genau das, was Process Explorer oder handle.exe anzeigen (siehe „Process Explorer / Handle / VMMap in der Praxis“, und als Untersuchungsbericht „Absturzuntersuchung einer Industriekamera im Dauerbetrieb – Teil Handle-Leak“).
  • Warum zwei auf dieselbe Datei geöffnete Handles unabhängige Dateizeiger haben: weil der Zeiger auf der Seite des Dateiobjekts liegt. Umgekehrt zeigt ein mit DuplicateHandle duplizierter Handle auf dasselbe Dateiobjekt, sodass sich beide den Zeiger teilen.
  • Eine Freigabeverletzung (Sharing Violation) entsteht dadurch, dass der Kernel den Freigabemodus der bestehenden Gruppe von Dateiobjekten mit der neuen CreateFile-Anfrage abgleicht. Die praktische Seite der wechselseitigen Ausschließung haben wir in „Grundlagen der wechselseitigen Ausschließung bei Dateiintegration“ behandelt.

4. Das IRP — Eine I/O-Anfrage wird zum Paket

4.1. Warum überhaupt ein Paket?

Empfängt der I/O-Manager eine Anfrage einer Anwendung — öffnen, lesen, schreiben und so weiter —, verpackt er sie in ein Paket namens IRP (I/O Request Packet) und übergibt es einem Treiber. Die meisten Anfragen an einen Gerätetreiber kommen als IRP an.3 Geräte laufen nicht mit derselben Geschwindigkeit wie das Betriebssystem (eine Festplatte ist um Größenordnungen langsamer als die CPU), weshalb Anfragen statt zu „Aufrufen“ zu „Paketen“ gemacht wurden, in einer Form, die es erlaubt, Ausgabe und Abschluss zu trennen.2

Neben einem Header, der die Gesamtinformation der Anfrage trägt, besitzt ein IRP einen Bereich namens I/O-Stack-Location — eine für jeden Treiber, den es voraussichtlich durchläuft. Jeder Treiber liest aus seiner eigenen Stack-Location „seine eigenen Anweisungen“ (Major-Function-Code und Parameter).17

4.2. Den Gerätestapel hinabsteigen

Geräteobjekte stapeln sich zu einem Gerätestapel.18 Ein Lesevorgang auf eine Datei auf einer lokalen Festplatte nimmt zum Beispiel ungefähr diesen Weg:

Speicherseitiger StapelDateisystemseitiger StapelVolume-/Partitionsverwaltung(volmgr u. Ä.)Datenträger-Klassentreiber(disk.sys)Storage-Port/Miniport(storport u. Ä.)Dateisystemfilter(Antivirus, Verschlüsselung, Procmon usw.)NTFSwandelt den Offset innerhalb der Datei in eine Position auf dem Volume umI/O-Manager stellt das IRP zusammen(IRP_MJ_READ + Stack-Locations)Festplattengerät

Abbildung 4: Der Weg einer Leseanfrage. Dateisystemseite und Speicherseite sind getrennte Gerätestapel; NTFS gibt ein neues, an die Speicherseite adressiertes untergeordnetes IRP aus, um die Arbeit in Auftrag zu geben

Aus diesem Bild sollten Sie sich zwei Dinge merken.

  1. Filter sind rechtmäßige Bewohner. Dass Antivirensoftware jede Datei-I/O prüfen kann, ist kein Hack, sondern liegt daran, dass dieses „Sich-dazwischenklinken“ ein offizieller Erweiterungspunkt des Betriebssystems ist.19 Auch Process Monitor steht an derselben Stelle und zeichnet jede I/O-Anfrage auf. Das ist auch der erste Ort, an dem man bei der Untersuchung von „nur in dieser einen Umgebung ist der Dateizugriff langsam“ nachsehen sollte (mehr dazu in Teil 6).
  2. Auf jeder Schicht wird die Bedeutung der Anfrage übersetzt. Die Anwendung sagt „8 KB ab Offset 4096 dieser Datei“; NTFS übersetzt das in „dieses Cluster auf diesem Volume“, der Speicherstapel übersetzt es weiter in „diesen Sektor auf dieser Festplatte“. Die obere Schicht weiß nichts von den Umständen der unteren. Hier ist eine wichtige Ergänzung nötig: Ein einzelnes IRP wird nicht unverändert von der Anwendung bis zur Festplatte durchgereicht. Dateisystemseite und Speicherseite sind getrennte Stapel, und NTFS erzeugt und gibt im Rahmen der Verarbeitung des an die Datei adressierten IRP ein neues, untergeordnetes IRP aus, das an das Volume (den Speicherstapel) adressiert ist. Bei einer fragmentierten Datei kann sich ein einzelner Lesevorgang sogar in mehrere untergeordnete IRPs aufteilen — Granularität und Lebensdauer einer Anfrage ändern sich auf jeder Schicht.

4.3. Die drei Möglichkeiten jedes Treibers

Ein Treiber, der ein IRP empfängt, hat im Grunde nur drei Möglichkeiten.310

die untere Schicht hat sofort abgeschlossendie untere Schicht hat zurückgestellt(der Pending-Status wird bis zum Aufrufer weitergereicht)Ein Treiber empfängt das IRPWie wird diese Anfrage behandelt?(1) Selbst abschließenIoCompleteRequest aufrufenBeispiel: sofortige Antwort mit Daten aus dem Cache(2) An das darunterliegende Gerät weitergebenIoCallDriver aufrufenBeispiel: ein Filter prüft und lässt dann unverändert passieren(3) Zurückstellen (pending)STATUS_PENDING zurückgeben und das IRP in die Warteschlange stellenBeispiel: Warten auf die Antwort der HardwareAusgelöst durch einen Interrupt o. Ä.später IoCompleteRequestDie Abschlussverarbeitung läuft den Stapelin umgekehrter Reihenfolge hoch (die Completion-Routine jeder Schicht wird aufgerufen)

Abbildung 5: Die drei Möglichkeiten eines Treibers beim Empfang eines IRP. Diese drei schließen sich nicht gegenseitig aus — der gewöhnlichste Weg ist, dass die untere Schicht nach dem Weitergeben zurückstellt —, und jeder Weg endet schließlich mit dem Abschluss über IoCompleteRequest

Diese drei sind keine sich gegenseitig ausschließenden Möglichkeiten. Der gewöhnlichste Weg ist, dass bei (2) nach unten weitergegeben wird und irgendeine Schicht darunter (3), das Zurückstellen, wählt; in diesem Fall wird STATUS_PENDING unverändert weitergereicht, sowohl an die dazwischenliegenden Treiber als auch an den ursprünglichen Aufrufer. Wenn eine untere Schicht später abschließt, wird jede Schicht, die beim Weitergeben eine Completion-Routine registriert hatte, in umgekehrter Reihenfolge zurückgerufen — mit anderen Worten: „Weitergeben“ und „Zurückstellen“ ereignen sich überlagert auf ein und demselben IRP.

Diese Dreiteilung ist die Quelle der Flexibilität von Windows-I/O.

  • Bei einem Cache-Treffer sofort abschließen, das ist schnell (der Cache-Manager, in Teil 4).
  • Beliebig viele Filter können sich einklinken und unverändert passieren lassen (Minifilter, in Teil 6).
  • Weil sich eine Anfrage zurückstellen lässt, muss ein Thread nicht einfrieren, während er auf ein langsames Gerät wartet (asynchrones I/O, in den Teilen 2 und 3).

Der gesamte Rest der Serie ist im Grunde nur eine Sammlung von Fußnoten zu diesem einen Diagramm.

5. Einem Umlauf von ReadFile folgen

Nun, da Personal und Requisiten bereitstehen, verfolgen wir einen kompletten Umlauf von ReadFile, für den Fall, dass er den Cache verfehlt und bis zur Festplatte geht (den Cache-Fall behandeln wir in Teil 4).

FestplattengerätSpeicherstapelFilter + NTFSI/O-ManagerAnwendungs-ThreadFestplattengerätSpeicherstapelFilter + NTFSI/O-ManagerAnwendungs-ThreadLöst das Dateiobjekt aus dem Handle aufund stellt das IRP (IRP_MJ_READ) zusammenSynchrones I/O: schläft hier bis zum AbschlussAsynchrones I/O: die Kontrolle kehrt zurück, andere Arbeit ist möglichSetzt die Abschlussverarbeitungüber einen DPC aus der ISR heraus fortSobald alle benötigtenuntergeordneten IRPs abgeschlossen sindFührt die Completion-Routine jeder Schicht in umgekehrter Reihenfolge ausund legt das Ergebnis über eine APC am anfragenden Thread festReadFile → NtReadFile (Systemaufruf)IoCallDriver (an den Kopf des Stapels)Übersetzt die Position auf dem Volumeund gibt ein an den Speicherstapel adressiertes untergeordnetes IRP ausGibt den Lesebefehl ausSTATUS_PENDING (das untergeordnete IRP ist zurückgestellt)Auch das ursprüngliche IRP kehrt zurückgestellt zurück(hier endet der Hinweg)Interrupt „Daten sind fertig gelesen“Schließt das untergeordnete IRP ab (IoCompleteRequest)die Completion-Routine von NTFS nimmt es entgegenSchließt das ursprüngliche IRP (IRP_MJ_READ) abStatus und Bytezahl stehen fest (z. B. per Ereignisbenachrichtigung)

Abbildung 6: Ein Umlauf von ReadFile, der den Cache verfehlt. Der Abschluss des untergeordneten IRP und der Abschluss des ursprünglichen IRP sind getrennte Schritte, und auch „Hinweg“ und „Rückweg“ laufen als getrennte Ereignisse ab

5.1. Hinweg und Rückweg sind getrennte Ereignisse

Der entscheidende Punkt in diesem Diagramm ist die Zeile STATUS_PENDING. Der Speichertreiber lässt los, sobald er einen Befehl an die Hardware ausgegeben hat, und die „Hinweg“-Verarbeitung endet genau dort. Dass die Daten fertig gelesen sind, wird später durch ein völlig anderes Ereignis mitgeteilt — einen Interrupt —, und von dort setzt die „Rückweg“-Abschlussverarbeitung ein.10

Mit anderen Worten: Im Kernel sind die Ausgabe und der Abschluss einer I/O-Anfrage so gebaut, dass sie sich trennen lassen. Es gibt keine eigene Verrohrung namens „synchrones I/O“ — die präzise Bedeutung von synchronem I/O ist einfach die Garantie, dass der Aufruf nicht vor Abschluss zurückkehrt. Gewartet wird nur, wenn eine Anfrage zurückgestellt wird; bei einer Anfrage, die ein Treiber sofort abschließen kann (der Weg „abschließen“ in Abbildung 5), kehrt der Thread mit seinem Ergebnis zurück, ohne auch nur einmal einzuschlafen, selbst bei synchronem I/O. Dass Win32 über die Art, wie das Handle geöffnet wird (FILE_FLAG_OVERLAPPED), zwischen synchron und asynchron umschaltet, liegt genau daran, dass asynchron nicht „eine besondere Zusatzfunktion“ ist, sondern ein Unterschied darin, wie gewartet wird.11

Mit dieser Sichtweise wirken die Fragen, die Teil 2 behandelt — warum entscheidet sich, ob ein Aufruf asynchron ist, beim Öffnen des Handles und nicht pro Aufruf (auch wenn die OVERLAPPED-Struktur selbst für jede einzelne laufende Operation benötigt wird), und was bedeutet es, wenn etwas, „das eigentlich asynchron sein sollte“, „synchron abschließt“? — wie die natürliche Konsequenz des Aufbaus des Mechanismus, statt wie beliebige Einzelfakten. Auch der Grund, warum .NETs async/await beim Warten auf I/O keinen Thread verbraucht (eine Geschichte, die wir von der praktischen Seite in „Praxisleitfaden zu C# async/await“ behandelt haben), gründet auf genau diesem Diagramm.

5.2. Es gibt auch eine Ausnahme — eine Abkürzung, die kein IRP erzeugt

Um ehrlich zu sein: Nicht jedes I/O wird zu einem IRP. Für synchrones Lesen und Schreiben einer Datei, die im Cache liegt, bietet das Dateisystem eine Abkürzung namens Fast I/O — direktes Kopieren aus dem Cache, ohne überhaupt ein IRP zusammenzustellen. In der Operation-Spalte von Procmon sind das die Zeilen, die mit FASTIO_ beginnen. Unter welchen Bedingungen diese Abkürzung nicht genutzt werden kann und wie sie mit dem Cache-Manager zusammenhängt, behandeln wir in Teil 4.

6. Hinter CloseHandle — Cleanup und Close sind zwei verschiedene Dinge

Zum Schluss noch, wie man das Geöffnete wieder schließt. Das hängt direkt mit Handle-Leak-Untersuchungen und „Datei in Verwendung“-Problemen zusammen.

Was CloseHandle tut, ist, einen Eintrag aus der Handle-Tabelle des Prozesses zu entfernen. Ein Dateiobjekt führt einen Handle-Zähler (die Anzahl der Handles) und einen Referenzzähler (die Anzahl der Referenzen aus dem Kernel heraus), und beide sinken unabhängig voneinander.

DateisystemI/O-ManagerObject ManagerAnwendungDateisystemI/O-ManagerObject ManagerAnwendungEntfernt den Eintrag aus der Handle-Tabelleund verringert den Handle-ZählerBricht nicht abgeschlossene I/O dieses Dateiobjekts abund gibt Sperren freialt[das war das letzte Handle]Bleiben aber kernelinterne Referenzen bestehen— nicht abgeschlossene I/O, eine Sektion (Memory-Mapping) usw. —lebt das Dateiobjekt nochDer Abbau des Dateiobjekts ist abgeschlossenerst jetzt ist es wirklich "geschlossen"alt[auch der Referenzzähler ist auf null gefallen]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

Abbildung 7: Die zwei Stufen — Cleanup (das letzte Handle wurde geschlossen) und Close (auch jede Referenz ist verschwunden)

  • IRP_MJ_CLEANUP kündigt an, dass „das letzte Handle geschlossen wurde“. Die Dokumentation selbst weist jedoch ausdrücklich darauf hin, dass die Freigabe des Dateiobjekts noch aussteht, solange nicht abgeschlossene I/O bestehen bleibt.12
  • IRP_MJ_CLOSE kündigt an, dass „der Referenzzähler auf null gefallen ist“. Zwischen Cleanup und Close gibt es eine Lücke, die nicht zwingend unmittelbar geschlossen wird.13

Wer diese zwei Stufen kennt, kann sich einige Rätsel aus der Praxis erklären.

  • Eine speicherabgebildete Datei wird auch nach dem Schließen nicht freigegeben. Der Grund ist, dass eine abgebildete Sektion weiterhin eine Referenz auf das Dateiobjekt hält; Close trifft erst ein, wenn die View entmappt und die Sektion geschlossen wurde. Die praktische Seite rund um Shared Memory haben wir in „Fallstricke und Best Practices für Shared Memory“ behandelt.
  • Die Suche nach dem Verursacher einer „Datei in Verwendung“ endet nicht allein bei den Handles. Auch wenn alle Handles geschlossen sind, kann eine kernelinterne Referenz — etwa ein gemapptes Image — die Datei noch festhalten. Genau deshalb durchsucht Process Explorer sowohl Handles als auch DLLs (gemappte Dateien).
  • Auch dass man sich nicht darauf verlassen sollte, dass SafeFileHandle oder ein Finalizer in .NET „irgendwann“ schließt, liegt daran, dass ein vergessenes, offenes Handle den Cleanup verzögert und Freigabeverletzungen sowie gehaltene Sperren in die Länge zieht.

7. Selbst nachsehen

Alles bis hierher lässt sich mit nichts weiter als einem einzigen Windows-PC mit Administratorrechten beobachten.

Sie brauchen nur drei Dinge.

  • Administratorrechte. Process Monitor lädt einen Kerneltreiber und muss deshalb als Administrator ausgeführt werden. Auch WinObj zeigt manche Objekte nur an, wenn Sie Administrator sind.
  • Die Sysinternals-Tools. WinObj und Process Monitor werden von Microsoft kostenlos vertrieben. Laden Sie sie einzeln von ihren Download-Seiten herunter (WinObj, Process Monitor), oder holen Sie sich gleich das gesamte Paket mit der Sysinternals Suite. Es gibt keinen Installer — Sie entpacken nur und führen die exe aus.
  • Eine geeignete Aktion zum Beobachten. Eine Datei mit Notepad speichern oder eine kleine Datei kopieren genügt völlig. Probieren Sie es auf Ihrer lokalen Festplatte aus, nicht auf einer geschäftlich genutzten Freigabe oder einem Produktivsystem.

Den Namensraum mit WinObj betrachten. Starten Sie Sysinternals’ WinObj und öffnen Sie das Verzeichnis GLOBAL??, dann sehen Sie direkt, dass C: ein symbolischer Link auf \Device\HarddiskVolumeN ist (Abbildung 1). Unter \Device sehen Sie die tatsächlichen Namen der von den Treibern erzeugten Geräteobjekte.14

Das Vokabular der IRPs in Procmon lesen. Aktivieren Sie im Menü von Process Monitor Filter > Enable Advanced Output, dann wechselt die Operation-Spalte von ReadFile zum kernelseitigen Vokabular: IRP_MJ_READ, FASTIO_READ und so weiter. Verfolgen Sie nur eine einzige Dateikopie, sehen Sie tatsächlich genau den Ablauf, den dieser Artikel beschreibt: IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE. Die praktische Anwendung von Procmon haben wir in „Praxisleitfaden zu Process Monitor (ProcMon)“ zusammengefasst.

Von .NET aus den Untergrund im Blick behalten. C#s FileStream ruft intern CreateFileW auf und hält das Handle als SafeFileHandle. Die FileOptions des Konstruktors sind fast eine direkte Entsprechung zu den Win32-Flags.

.NET (FileOptions) Win32 (CreateFile-Flag) Bedeutung (relevanter Teil)
Asynchronous FILE_FLAG_OVERLAPPED Öffnet das Handle für asynchrones I/O (Teile 2 und 3)
WriteThrough FILE_FLAG_WRITE_THROUGH Lässt Schreibvorgänge nicht im Cache stehen (Teil 4)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN Ein Hinweis für Read-Ahead (Teil 4)
RandomAccess FILE_FLAG_RANDOM_ACCESS Ein Hinweis zur Unterdrückung von Read-Ahead (Teil 4)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE Löscht, sobald das letzte Handle schließt (eine Anwendung des Mechanismus aus Abschnitt 6)

Ab .NET 6 lässt sich mit File.OpenHandle und der Klasse RandomAccess auch eine Schreibweise wählen, die näher an der Rohform von Win32 liegt — „Handle plus offset-spezifiziertes I/O“ —, ohne über die Abstraktion von FileStream zu gehen. Sobald Sie die Bedeutung der rechten Spalte dieser Tabelle verstanden haben, wird die Wahl auf der linken Seite nicht mehr auswendig gelernt.

8. Zusammenfassung

  • Windows-I/O beruht auf einem einzigen, konsistenten Entwurf: Im Namensraum des Object Managers wird das Ziel (ein Geräteobjekt) bestimmt, die Anfrage wird in ein IRP verpackt und durch den Gerätestapel geschickt.2318
  • C: ist ein symbolischer Link, \\.\ benennt den Ablageort eines Links direkt, und ein UNC-Pfad wird auf einen Redirector aufgelöst. Der wahre Grund für „alles sieht wie eine Datei aus“ ist die Konsistenz der Namensauflösung.45
  • Es gibt drei Figuren im Personal: das Treiberobjekt (Tabelle der Verarbeitungsfunktionen), das Geräteobjekt (das Ziel) und das Dateiobjekt (der Zustand eines Öffnens). Ein HANDLE ist eine Referenz auf ein Dateiobjekt.6789
  • Ein Treiber hat drei Möglichkeiten: abschließen, weitergeben oder zurückstellen. Das Einklinken von Filtern, die sofortige Antwort aus dem Cache und asynchrones I/O sind alle Anwendungen dieser einen Dreiteilung.310
  • Ausgabe und Abschluss einer Anfrage sind so gebaut, dass sie sich trennen lassen, und synchrones I/O ist die Garantie, dass „der Aufruf nicht vor Abschluss zurückkehrt“. Bei einer zurückgestellten Anfrage laufen Hinweg (Ausgabe) und Rückweg (Interrupt, dann Abschluss) als getrennte Ereignisse ab.1110
  • CloseHandle „gibt nur ein Handle zurück“. Kennt man die zwei Stufen — Cleanup (das letzte Handle) und Close (die letzte Referenz) —, ist „geschlossen, aber trotzdem in Verwendung“ kein Rätsel mehr.1213

Weiter geht es in Teil 2, „Synchrones und asynchrones I/O — Was OVERLAPPED wirklich bedeutet“. Wir gehen dem Mechanismus auf den Grund, mit dem eine Anwendung die in diesem Artikel gesehene „Trennung von Ausgabe und Abschluss“ tatsächlich nutzt — FILE_FLAG_OVERLAPPED, die vier Wege der Abschlussbenachrichtigung, Abbruch und die Falle, dass etwas „eigentlich Asynchrones“ synchron zurückkommt.

Verwandte Artikel

Verwandte Beratungsleistungen

Die KomuraSoft LLC übernimmt Entwurf und Fehleranalyse rund um Datei-I/O in Windows-Geschäftsanwendungen (Handle-Leaks, „Datei in Verwendung“, I/O-Verzögerungen in bestimmten Umgebungen und Ähnliches).

  1. Microsoft Learn, Windows Internals - Sysinternals. Die Einführungsseite zum Buch Windows Internals (deutscher Titel Insider-Wissen: Microsoft Windows Internals), dem Standardwerk zur Kernelarchitektur von Windows einschließlich des I/O-Systems, als Ausgangspunkt, um tiefer in das einzusteigen, was diese Serie behandelt. 

  2. Microsoft Learn, I/O manager. Darüber, dass der kernelmodus-seitige I/O-Manager von Windows die Kommunikation zwischen Anwendungen und den von Gerätetreibern bereitgestellten Schnittstellen verwaltet, dass die Kommunikation zwischen Betriebssystem und Treibern hauptsächlich über IRPs (I/O Request Packets) erfolgt, weil Geräte mit einer anderen Geschwindigkeit als das Betriebssystem arbeiten, und dass IRPs als etwas, das Netzwerkpaketen oder Windows-Nachrichten ähnelt, vom Betriebssystem an einen Treiber und von Treiber zu Treiber weitergereicht werden.  2 3

  3. Microsoft Learn, I/O request packets. Darüber, dass die meisten an einen Gerätetreiber gesendeten Anfragen als IRP verpackt werden, dass Betriebssystemkomponenten und Treiber ein IRP über IoCallDriver (das einen Zeiger auf ein Geräteobjekt und einen Zeiger auf das IRP entgegennimmt) an einen Treiber senden, dass ein IRP normalerweise von mehreren, als Gerätestapel übereinandergelegten Treibern verarbeitet wird und zunächst an das Geräteobjekt an der Spitze des Stapels gesendet wird, und dass jeder Treiber wählen kann, ob er das IRP verarbeitet und abschließt oder an den darunterliegenden Treiber weiterleitet.  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. Darüber, dass ein MS-DOS-Gerätename ein symbolischer Link auf einen NT-artigen Gerätenamen ist, dass eine Windows-Anwendung im Benutzermodus über den MS-DOS-Gerätenamen (Laufwerksbuchstabe oder COM-Port-Name) auf ein Gerät zugreift, während Treiber und Kernel den NT-artigen Namen verwenden, und darüber, wie ein Treiber mit IoCreateSymbolicLink den symbolischen Link von \DosDevices\Name auf das Gerät erzeugt.  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. Darüber, dass CON, PRN, AUX, NUL, COM1 bis COM9 und LPT1 bis LPT9 als Dateinamen reserviert sind, dass der Win32-Namensraum einen „Datei-Namensraum“ und einen „Geräte-Namensraum“ besitzt, dass das Präfix „\\.\“ den Zugriff auf den Win32-Geräte-Namensraum bedeutet (Beispiel: \\.\PhysicalDrive0), und über die Regeln dieser Namensauflösung.  2 3 4

  6. Microsoft Learn, Introduction to driver objects. Darüber, dass der I/O-Manager beim Laden eines Treibers eine DRIVER_OBJECT-Struktur erzeugt, dass das Treiberobjekt die Einstiegspunkte in die Standardroutinen des Treibers hält, einschließlich des Arrays MajorFunction, das eine Dispatch-Tabelle ist, und dass der I/O-Manager diese Tabelle nutzt, um die zu einer Anfrage passende Verarbeitungsfunktion aufzurufen.  2 3

  7. Microsoft Learn, Introduction to device objects. Darüber, dass eine DEVICE_OBJECT-Struktur ein logisches, virtuelles oder physisches Gerät repräsentiert und zum Ziel einer I/O-Anfrage wird, dass ein Treiber mit IoCreateDevice ein Geräteobjekt erzeugt, und dass ein Geräteobjekt an den Treiber (das Treiberobjekt) gebunden ist, der es erzeugt hat.  2 3

  8. Microsoft Learn, Using files in a driver. Darüber, dass im Kernel ein Dateiobjekt „eine Instanz einer geöffneten Datei (oder eines Geräts)“ repräsentiert, und dass bei jedem Öffnen einer Datei ein Dateiobjekt erzeugt wird, das den Kontext dieses Öffnens hält, etwa den aktuellen Byte-Offset.  2 3

  9. Microsoft Learn, File handles. Darüber, dass das von CreateFile zurückgegebene Datei-Handle prozessspezifisch ist und an ein geöffnetes Dateiobjekt gebunden ist, dass mehrfaches Öffnen derselben Datei jeweils zu einem eigenen Handle (und eigenen offenen Zustand) führt, und dass ein Handle, sobald es nicht mehr benötigt wird, mit CloseHandle geschlossen werden sollte.  2 3

  10. Microsoft Learn, Completing IRPs. Darüber, dass der Aufruf von IoCompleteRequest eine I/O-Operation abschließt, dass beim Abschluss jede von einem darüberliegenden Treiber registrierte IoCompletion-Routine der Reihe nach aufgerufen wird, und über den Ablauf, bei dem der Abschluss einer Anfrage zeitlich getrennt von ihrer Ausgabe erfolgt, bis schließlich der Status an den Anfragenden zurückgegeben wird.  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. Darüber, dass bei synchronem I/O eine Funktion erst nach Abschluss der I/O zurückkehrt und der Thread zum Warten gezwungen wird, während bei asynchronem I/O (Overlapped I/O) die Funktion, die die Anfrage ausgegeben hat, sofort zurückkehrt und der Thread mit anderer Arbeit fortfahren kann, über die Notwendigkeit, das Handle für asynchrones I/O mit FILE_FLAG_OVERLAPPED zu öffnen, und über die mehreren verfügbaren Wege, eine Abschlussbenachrichtigung zu erhalten.  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. Darüber, dass der Empfang dieser Anfrage anzeigt, dass „das letzte Handle zu einem mit dem Ziel-Geräteobjekt verknüpften Dateiobjekt geschlossen wurde“, dass die Freigabe des Dateiobjekts wegen nicht abgeschlossener I/O-Anfragen dennoch aussteht, und dass dieses IRP im Kontext des Prozesses gesendet wird, der das Handle geschlossen hat.  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. Darüber, dass der Empfang dieser Anfrage anzeigt, dass „der Referenzzähler des Dateiobjekts auf null gefallen ist und das Dateiobjekt freigegeben werden soll“, und dass es nach der Cleanup-Anfrage gesendet wird, aber nicht zwingend unmittelbar danach, weil es auf den Abschluss nicht abgeschlossener I/O wartet.  2 3

  14. Microsoft Learn, WinObj - Sysinternals. Darüber, dass WinObj ein Tool ist, das den Namensraum des NT-Object-Managers anzeigt und das Durchsuchen der darin enthaltenen Objekte erlaubt, einschließlich Geräteobjekten und symbolischen Links.  2 3

  15. Microsoft Learn, CreateFileW function. Darüber, dass CreateFile nicht nur Dateien, sondern auch Geräte wie physische Datenträger, Volumes, die Konsole, Kommunikationsports (COM-Ports) und Pipes öffnen und ein Handle zurückgeben kann, über die Verwendung von Namen im Format „\\.\“ beim Öffnen eines Geräts, und über die Bedeutung verschiedener Flags einschließlich FILE_FLAG_OVERLAPPED.  2

  16. Microsoft Learn, IRP major function codes. Eine Liste der Major-Function-Codes von IRPs (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP u. a.) sowie darüber, was jede Anfrage bedeutet und welcher Treiber sie behandeln sollte. 

  17. Microsoft Learn, I/O stack locations. Darüber, dass der I/O-Manager für jeden Treiber in einer Kette geschichteter Treiber eine I/O-Stack-Location im IRP vorbereitet, dass jede Stack-Location den Major-/Minor-Function-Code und die Parameter dieser Anfrage enthält, und dass jeder Treiber über IoGetCurrentIrpStackLocation seine eigene Stack-Location abruft, um den Inhalt der Anfrage zu erfahren. 

  18. Microsoft Learn, Device nodes and device stacks. Darüber, dass sich Geräteobjekte zu einem Gerätestapel stapeln, dass ein IRP zunächst an das Geräteobjekt an der Spitze des Stapels gesendet wird, wobei jede Schicht es entweder verarbeitet oder weiter nach unten weiterleitet, und darüber, wie das Geräteobjekt eines Filtertreibers in diesen Stapel eingefügt vorliegt.  2

  19. Microsoft Learn, Filter Manager Concepts. Darüber, dass der Filter-Manager ein mit Windows ausgeliefertes, kernelmodus-seitiges Treiber ist, dass Minifilter-Treiber sich über Pre- und Post-Operation-Callbacks in I/O-Anfragen an das Dateisystem einklinken können, und dass die Einklinkposition (Altitude) jedes Minifilters seine Reihenfolge im I/O-Stapel bestimmt. 

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

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

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

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

Was ist ein IRP?
Ein IRP (I/O Request Packet) ist das Paket, mit dem der I/O-Manager des Windows-Kernels Lese-, Schreib- und andere Anfragen einer Anwendung bündelt und an Gerätetreiber weitergibt. Microsofts Dokumentation zur Treiberentwicklung erklärt, dass die meisten an Gerätetreiber gesendeten Anfragen als IRP verpackt werden. Ein IRP trägt einen Major-Function-Code, der die Art der Anfrage angibt (Erstellen, Lesen, Schreiben, Cleanup und so weiter), sowie für jeden durchlaufenen Treiber eine eigene Stack-Location, und es wird verarbeitet, während es den Gerätestapel von oben nach unten durchläuft. Jeder Treiber entscheidet dabei, ob er das IRP selbst abschließt, es an den darunterliegenden Treiber weitergibt oder es zurückstellt (pending) und später abschließt. Anwendungsentwickler fassen ein IRP nie direkt an, aber Bezeichnungen wie IRP_MJ_READ, die in der Operation-Spalte von Process Monitor auftauchen, sind genau dieser Mechanismus, sichtbar gemacht.
Warum lassen sich in Windows eine Datei, ein serieller Port und ein Drucker mit demselben CreateFile öffnen?
Weil jeder an CreateFile übergebene Name letztlich über den Namensraum des Object Managers auf ein Geräteobjekt aufgelöst wird und der I/O-Manager dann ein an dieses Gerät gebundenes Dateiobjekt erzeugt und ein Handle zurückgibt — immer derselbe Weg. Ein Laufwerksbuchstabe wie C: ist im Kern ein symbolischer Link auf einen NT-Gerätenamen wie \Device\HarddiskVolume3, und eine Angabe wie \\.\COM1 wird ebenso auf das Geräteobjekt des seriellen Ports aufgelöst. Auf welches Gerät der Name auch aufgelöst wird, jede nachfolgende Anfrage wird in dasselbe IRP-Format verpackt und an einen Treiber ausgeliefert — deshalb lassen sich Dateien und Geräte über dieselbe API öffnen sowie lesen und schreiben. UNC-Pfade folgen demselben Mechanismus; sie werden lediglich auf das Gerät des Netzwerk-Redirectors aufgelöst. Genau diese Kombination aus „Namensraum plus Paket“ ist der wahre Grund für die Konsistenz von Windows-I/O.
Warum wird eine Datei nach dem Aufruf von CloseHandle nicht sofort freigegeben?
Weil CloseHandle „ein Handle zurückgeben“ bedeutet, nicht „die Datei schließen“. Ein Dateiobjekt im Kernel führt zwei getrennte Zähler: einen Handle-Zähler (die Anzahl offener Handles) und einen Referenzzähler (die Anzahl der Referenzen aus Kernelkomponenten). IRP_MJ_CLEANUP wird an das Dateisystem gesendet, sobald das letzte Handle geschlossen wird, aber solange kernelinterne Referenzen bestehen bleiben — nicht abgeschlossene I/O, ein Abschnitt einer speicherabgebildeten Datei und dergleichen —, bleibt das Dateiobjekt selbst am Leben, und IRP_MJ_CLOSE wird erst gesendet, wenn der Referenzzähler auf null gefallen ist. Viele Phänomene, bei denen sich eine Datei nach dem Memory-Mapping nicht löschen lässt oder eine Anwendung eine Datei trotz vermeintlichem Schließen als „in Verwendung“ meldet, lassen sich mit diesem zweistufigen Mechanismus erklären.
Wozu nützt Anwendungsentwicklern das Wissen über IRPs und Gerätestapel?
Auch wenn man nie selbst ein IRP schreibt, zahlt sich das Wissen sowohl bei der Fehlersuche als auch beim Entwurf aus. Erstens zeigt die Operation-Spalte von Process Monitor (bei aktivierter erweiterter Ausgabe) genau das Vokabular von IRPs, etwa IRP_MJ_CREATE und IRP_MJ_READ — wer diese Begriffe kennt, kann das Protokoll tatsächlich lesen. Zweitens weiß man, dass Filtertreiber wie Antivirensoftware auf jedem Weg liegen, den Datei-I/O nimmt, was bei der Untersuchung eines Problems wie „der Dateizugriff ist nur in dieser bestimmten Umgebung langsam“ eine erste Spur liefert. Und wenn man versteht, dass Windows-I/O so aufgebaut ist, dass sich Ausgabe und Abschluss einer Anfrage trennen lassen, und dass synchrones I/O lediglich die Garantie ist, dass „der Aufruf vor Abschluss nicht zurückkehrt“, ergeben asynchrones I/O, I/O-Completion-Ports und das Verhalten von async/await in .NET von Grund auf Sinn, statt auswendig gelernte Fakten zu sein.

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