Les profondeurs de l'I/O Windows (partie 1) — Chaque lecture et écriture devient un IRP : la vue d'ensemble du système d'E/S

· · Windows, Win32, E/S, Noyau, Pilote de périphérique, .NET, CSharp, Investigation de bugs

Une simple ligne File.ReadAllText, ou un seul appel à ReadFile. Que se passe-t-il à l’intérieur de Windows entre le moment où vous appelez la fonction et celui où elle revient ?

Ouvrez Process Monitor pour traquer un bogue, et des termes peu familiers comme IRP_MJ_READ et FASTIO_READ s’alignent devant vous. Suivez une fuite de handles, et un fichier dont vous étiez sûr que CloseHandle s’était occupé est toujours vivant. « Ça se comporte différemment sur un lecteur réseau. » « C’est lent uniquement sur les machines équipées d’un antivirus. » Ces phénomènes, qui reviennent sans cesse sur le terrain des applications métier, se produisent tous sur le même terrain : le système d’E/S de Windows.

Cet article ouvre une série, « Les profondeurs de l’I/O Windows », qui creuse ce terrain depuis ses fondations. Tout comme le célèbre ouvrage Windows Internals (dont l’édition japonaise est intitulée Inside Windows) descend jusqu’à la conception du noyau pour l’expliquer, cette série ne traite pas de « comment utiliser l’API » mais de « pourquoi elle se comporte ainsi ».1 Voici le programme prévu :

  1. La vue d’ensemble du système d’E/S — chaque lecture et écriture devient un IRP (cet article)
  2. E/S synchrones et asynchrones — ce que signifie vraiment OVERLAPPED
  3. Les ports d’achèvement d’E/S (IOCP) et le pool de threads .NET — le sous-sol d’async/await
  4. Le gestionnaire de cache — quand votre WriteFile atteint-il réellement le disque ?
  5. À l’intérieur de NTFS — comprendre le système de fichiers à travers la MFT
  6. Pilotes filtres et minifiltres — pourquoi Procmon et les antivirus peuvent intercepter vos E/S

Ce premier volet pose, à l’aide de schémas, les personnages et le déroulement d’une requête sur lesquels s’appuient tous les épisodes suivants. Ce n’est pas un article sur l’écriture de pilotes. C’est un article qui vise à permettre aux développeurs d’application de suivre l’appel d’API qu’ils viennent de faire jusqu’à sa destination finale.

Prérequis : vous pouvez suivre cet article si vous avez utilisé FileStream en C#, ou appelé CreateFile/ReadFile en C/C++. Aucune expérience du développement de pilotes n’est nécessaire, et le code côté noyau n’apparaît que sous forme d’illustration conceptuelle. Comme estimation de temps de lecture, prévoyez environ 25 minutes pour le parcourir en entier en regardant les schémas, ou 2 à 3 minutes si vous voulez seulement la conclusion du chapitre 1.

Guide pour sauter des parties : cet article est long, voici donc des points d’entrée selon votre objectif.

  • Vous voulez juste la vue d’ensemble → chapitre 1 (la conclusion) → chapitre 2 (la résolution de noms) → chapitre 5 (un aller-retour de ReadFile)
  • Vous voulez clarifier le vocabulaire (objet pilote/périphérique/fichier, IRP) → chapitres 3 et 4
  • Vous voulez comprendre comment les choses cassent en pratique (fuites de handles, « fichier en cours d’utilisation ») → chapitre 6
  • Vous voulez essayer par vous-même → chapitre 7

1. La conclusion, d’abord

  • L’E/S Windows est pilotée par paquets. La plupart des requêtes envoyées à un pilote de périphérique sont conditionnées dans un paquet appelé IRP (I/O Request Packet) et descendent, du haut vers le bas, une pile de pilotes (la pile de périphériques).23
  • CreateFile n’ouvre pas nécessairement un « fichier ». La résolution de noms se fait dans l’espace de noms de l’Object Manager, et C: est, en dessous, un lien symbolique vers un nom de périphérique NT tel que \Device\HarddiskVolume3 (chapitre 2).45
  • Il y a trois personnages dans cette histoire. L’objet pilote, qui détient une table de fonctions de traitement ; l’objet périphérique, qui est la destination d’une requête ; et l’objet fichier, qui porte l’état d’une ouverture particulière. Le HANDLE que vous détenez est une référence à un objet fichier (chapitre 3).6789
  • Chaque pilote n’a que trois choix. Compléter l’IRP lui-même, le transmettre au pilote du dessous, ou le mettre en attente pour le compléter plus tard. Cette combinaison de trois choix explique à elle seule les pilotes filtres, la mise en cache et l’E/S asynchrone (chapitre 4).310
  • Il n’existe pas, tout en bas du noyau, de mécanisme séparé appelé « E/S synchrone ». L’E/S synchrone est en réalité simplement la garantie que « l’appel ne revient pas avant l’achèvement » ; l’attente ne survient que lorsqu’une requête passe en attente. C’est ici que s’ouvrent les portes des parties 2 et 3 (chapitre 5).11
  • CloseHandle ne signifie pas « fermer » — cela signifie « rendre un handle ». Il y a deux étapes : le cleanup, quand le dernier handle se ferme, et le close, quand toutes les références à l’intérieur du noyau ont aussi disparu. Cet écart explique exactement pourquoi un fichier peut dire « en cours d’utilisation » alors que vous pensiez déjà l’avoir fermé (chapitre 6).1213
  • Vous pouvez observer cette couche par vous-même. WinObj vous montre l’espace de noms, et Process Monitor vous montre le flux des IRP. Le vocabulaire même de Procmon est le vocabulaire de cet article (chapitre 7).14

2. Ce que signifie vraiment « tout ressemble à un fichier »

2.1. Où va le nom que vous passez à CreateFile ?

Toute API de fichier Win32 commence par un « nom ». Un chemin comme C:\project\report.csv, un chemin UNC comme \\server\share\data.csv, une désignation de périphérique comme \\.\COM3 — tous peuvent être passés au même CreateFile.15

Derrière cette cohérence se cache un espace de noms unique géré par l’Object Manager du noyau. À l’intérieur du noyau, les périphériques, les événements et les sections de mémoire partagée sont tous enregistrés comme des « objets » dans cet espace de noms en forme d’arbre. WinObj, de Sysinternals, permet de regarder directement dans cet espace de noms.14

\ - racine de l'espace de noms\Deviceobjets périphériques créés par les pilotes\GLOBAL??où résident les noms globalement visibles depuis Win32\BaseNamedObjectsmutex nommés et autresHarddiskVolume3Serial0Mup - redirecteur réseauC: -> \Device\HarddiskVolume3COM1 -> \Device\Serial0PhysicalDrive0 -> \Device\Harddisk0\DR0

Figure 1 : l’espace de noms de l’Object Manager (extrait). Ce qui vit sous \GLOBAL?? sont des liens symboliques ; les objets réels vivent sous \Device

Le point clé est que les noms qu’une application Win32 utilise (lettres de lecteur, noms de ports COM) et les noms que le noyau utilise (noms de périphérique NT) vivent dans des niveaux différents. Ce sont les liens symboliques qui relient les deux.

2.2. Une lettre de lecteur est un lien symbolique

Quand un pilote veut que son périphérique soit visible depuis les applications Win32, il appelle IoCreateSymbolicLink pour créer un lien symbolique depuis un nom de périphérique MS-DOS, comme \DosDevices\COM1, vers un nom de périphérique NT.4 C: fonctionne de la même façon — en dessous, c’est un lien vers un périphérique de volume tel que \Device\HarddiskVolume3.

Ainsi, la résolution de noms pour CreateFile("C:\project\report.csv") se déroule comme suit :

Nom passé par l'applicationC:\project\report.csvLa couche Win32 le convertit au format NT\??\C:\project\report.csvL'Object Manager parcourt l'espace de nomset découvre que \??\C: est un lien symboliqueSuit le lien et le remplace\Device\HarddiskVolume3\project\report.csv\Device\HarddiskVolume3atteint l'objet périphérique du volumeLa résolution du reste, \project\report.csv, est laisséeau pilote de système de fichiers, NTFS -le gestionnaire d'E/S émet IRP_MJ_CREATE

Figure 2 : résolution de noms pour CreateFile. La première moitié est le travail de l’Object Manager ; une fois qu’un périphérique est atteint, la seconde moitié devient le travail du système de fichiers

Ce schéma répond à plusieurs questions qui reviennent tout le temps.

  • Ce que signifie le préfixe \\.\. Le \\.\ dans \\.\PhysicalDrive0 ou \\.\COM10 est une notation qui pointe directement vers l’espace de noms de périphériques Win32 (grossièrement le répertoire \??). Il court-circuite la lettre de lecteur et nomme directement l’emplacement du lien.515
  • Pourquoi CON et NUL ne peuvent pas être utilisés comme noms de fichiers. Ils sont réservés comme noms de périphérique MS-DOS, et où qu’ils apparaissent dans un chemin, ils peuvent être résolus vers le périphérique correspondant à la place.5 Les pièges pratiques autour de ce sujet sont réunis dans « MAX_PATH et les pièges des chemins et noms de fichiers sous Windows ».
  • Les chemins UNC ne sont pas non plus un cas particulier. \\server\share se résout vers le périphérique du redirecteur réseau (\Device\Mup), à partir duquel le client SMB transporte la requête sur le réseau. La cause profonde des différences de comportement entre chemins locaux et chemins UNC est que le périphérique de résolution est différent (voir « Les pièges des lecteurs réseau et des chemins UNC »).

Pour être précis, \?? n’est pas vraiment un alias vers un unique répertoire physique — c’est un point d’entrée virtuel représentant un ordre de recherche : « d’abord vérifier la table de périphériques DOS locale à la session d’ouverture de session, et si elle n’y est pas, retomber sur \GLOBAL?? ». Les lettres de lecteur créées avec net use ou subst atterrissent dans ce côté local, ce qui explique pourquoi un même PC peut montrer des lecteurs différents à des utilisateurs différents (sessions d’ouverture de session différentes), et pourquoi un service ne peut pas voir les lecteurs réseau mappés d’un utilisateur. La figure 1 ci-dessus ne montre que le côté global (\GLOBAL??).

Autrement dit, la façon précise de dire « sous Windows, tout ressemble à un fichier » est celle-ci : tout nom est finalement résolu vers un objet périphérique, et toute requête à partir de là est unifiée dans le même format — l’IRP. Alors, qu’est-ce exactement que cet objet périphérique ? Mettons de l’ordre dans les personnages de l’histoire.

3. Le casting compte trois objets

La structure du système d’E/S de Windows peut se dessiner comme une relation entre trois types d’objets du noyau.

Les termes techniques vont s’empiler. Pour que vous ne vous perdiez pas, voici d’abord un tableau de traduction, faisant correspondre « ce que vous voyez dans le code que vous écrivez habituellement » et « comment le noyau l’appelle ». Les chapitres 3 et 4 sont écrits dans le vocabulaire de la colonne de droite.

Ce que vous voyez côté Win32 / .NET Le pendant côté noyau En un mot
HANDLE / SafeFileHandle Une entrée dans la table des handles (une référence à un objet fichier) Un ticket numéroté pour « une ouverture » (3.3)
L’état ouvert détenu par un FileStream Objet fichier Le conteneur du mode de partage, des indicateurs et de la position courante (3.3)
Une lettre de lecteur C: ou \\.\COM3 Objet périphérique (résultat de la résolution du nom) La destination de la requête (chapitre 2, 3.2)
« Le pilote NTFS », « le pilote de disque » Objet pilote Une table de fonctions de traitement, une par type de requête (3.1)
Un appel à ReadFile / stream.Read Normalement, un IRP Le paquet de requête transporté jusqu’à sa destination (chapitre 4). Les lectures et écritures synchrones vers un fichier en cache empruntent un raccourci appelé fast I/O qui évite de créer un IRP (5.2). Dans Procmon, ce sont les lignes qui commencent par FASTIO_
Les indicateurs FileOptions / CreateFile Attributs enregistrés sur l’objet fichier Déterminent la lecture anticipée et le comportement asynchrone (tableau de correspondance au chapitre 7)
La valeur de GetLastError / une exception .NET NTSTATUS (STATUS_PENDING, etc.) Le statut d’achèvement, traduit en code d’erreur Win32 en remontant

3.1. L’objet pilote — une table de fonctions de traitement

Quand un pilote est chargé, le gestionnaire d’E/S crée un objet pilote (DRIVER_OBJECT) pour le représenter.6 Du point de vue d’un développeur d’application, le membre le plus important est le tableau MajorFunction — une table faisant correspondre « type de requête » à « fonction de traitement », où le type de requête est exprimé par un code de fonction majeure tel que IRP_MJ_CREATE (ouvrir), IRP_MJ_READ (lire), IRP_MJ_WRITE (écrire), IRP_MJ_CLEANUP ou IRP_MJ_CLOSE.16

En forçant un peu la comparaison avec du C#, cela ressemble grossièrement à ceci :

// Illustration conceptuelle. En réalité, c'est une structure C à l'intérieur du noyau
class DriverObject
{
    // Indexé par IRP_MJ_XXX. 28 types au total
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// Pour ntfs.sys, MajorFunction[IRP_MJ_READ] contient « le gestionnaire de lecture de NTFS »

3.2. L’objet périphérique — la destination d’une requête

Pour chaque périphérique dont il a la charge, un pilote crée un objet périphérique (DEVICE_OBJECT).7 C’est la destination d’une requête d’E/S. Elle n’est pas forcément en correspondance un-à-un avec un périphérique physique — cela peut être une entité logique comme un volume (HarddiskVolume3), ou un « périphérique qui n’existe que pour intercepter le trafic » créé par un pilote filtre. Un objet périphérique pointe en retour vers l’objet pilote qui l’a créé, de sorte que connaître la destination revient aussi à connaître la table de fonctions de traitement.

3.3. L’objet fichier — l’état d’« une ouverture »

Chaque fois que CreateFile réussit, le noyau crée un objet fichier. Ce n’est pas « le fichier sur le disque » en lui-même — il représente une session particulière d’ouverture de ce fichier (ou de ce périphérique).8 Ouvrez le même fichier deux fois, et vous obtenez deux objets fichier. Le pointeur de fichier courant (pour un handle synchrone), ainsi que le mode de partage et les indicateurs utilisés à l’ouverture, vivent tous ici.

Et le HANDLE que reçoit votre application est une référence à un objet fichier, atteinte via la table des handles propre au processus.9

Espace noyauProcessus - mode utilisateurObjet fichier 1report.csv ouvert en lecturedécalage courant : 4096Objet fichier 2report.csv ouvert en ajoutdécalage courant : 65536Objet périphériqueéquivalent à HarddiskVolume3Objet pilote NTFSMajorFunction = table des gestionnairesHANDLE 0x1A4HANDLE 0x1B8

Figure 3 : la relation entre les trois objets. Un handle pointe, via la table des handles, vers un objet fichier ; un objet fichier pointe vers un périphérique ; un périphérique pointe vers un pilote

Avec ce schéma en tête, plusieurs connaissances pratiques cessent d’être des choses à mémoriser et deviennent tout simplement évidentes.

  • Ce qu’est réellement une fuite de handles : un amas d’objets fichier (et des ressources enchaînées derrière eux) qui continuent d’être référencés et ne peuvent jamais être libérés. Ce que vous comptez, ce sont les entrées de la table des handles — exactement ce que vous montrent Process Explorer et handle.exe (voir « Process Explorer / Handle / VMMap en pratique », et, comme récit d’investigation, « Enquête sur des plantages en fonctionnement longue durée d’une caméra industrielle - la fuite de handles »).
  • Pourquoi deux handles ouverts sur le même fichier ont des pointeurs de fichier indépendants : le pointeur vit du côté de l’objet fichier. À l’inverse, un handle dupliqué avec DuplicateHandle pointe vers le même objet fichier, et les deux handles partagent donc le pointeur.
  • Une violation de partage (sharing violation) est le noyau qui compare les modes de partage du groupe d’objets fichier existants avec la nouvelle requête CreateFile. Nous avons traité le côté pratique de l’exclusion mutuelle dans « Les fondamentaux de l’exclusion mutuelle pour l’intégration de fichiers ».

4. L’IRP — une requête d’E/S devient un colis

4.1. Pourquoi en faire un paquet ?

Quand le gestionnaire d’E/S reçoit une requête d’une application — ouvrir, lire, écrire, etc. — il la conditionne dans un paquet appelé IRP (I/O Request Packet) et le remet à un pilote. La plupart des requêtes vers un pilote de périphérique arrivent sous forme d’IRP.3 Les périphériques ne fonctionnent pas à la même vitesse que l’OS (un disque est plusieurs ordres de grandeur plus lent que le CPU), si bien que les requêtes sont transformées en « colis » plutôt qu’en « appels », dans une forme qui permet de séparer l’émission et l’achèvement.2

En plus d’un en-tête portant les informations générales de la requête, un IRP possède une zone appelée emplacement de pile d’E/S — un par pilote censé être traversé. Chaque pilote lit « ses propres instructions » (le code de fonction majeure et ses paramètres) depuis son propre emplacement de pile.17

4.2. Descendre la pile de périphériques

Les objets périphériques s’empilent pour former une pile de périphériques.18 Une lecture depuis un fichier sur un disque local, par exemple, suit à peu près ce chemin :

Côté stockage de la pileCôté système de fichiers de la pileGestion des volumes / partitions(volmgr, etc.)Pilote de classe disque(disk.sys)Port de stockage / miniport(storport, etc.)Filtres de système de fichiers(antivirus, chiffrement, Procmon, etc.)NTFSconvertit le décalage dans le fichier en position sur le volumeLe gestionnaire d'E/S assemble l'IRP(IRP_MJ_READ + emplacements de pile)Matériel de disque

Figure 4 : le chemin emprunté par une requête de lecture. Le côté système de fichiers et le côté stockage sont des piles de périphériques distinctes ; NTFS émet un nouvel IRP de niveau inférieur adressé au côté stockage pour faire réaliser son travail

Deux choses valent la peine d’être retenues de ce schéma.

  1. Les filtres sont des résidents légitimes. La raison pour laquelle un antivirus peut inspecter chaque E/S de fichier n’est pas un piratage — c’est parce que ce mécanisme « s’insérer au milieu » est un point d’extension officiel de l’OS.19 Process Monitor se tient au même endroit et enregistre toutes les requêtes d’E/S. C’est aussi le premier endroit à soupçonner pour investiguer « l’accès aux fichiers est lent uniquement dans cet environnement particulier » (davantage à ce sujet dans la partie 6).
  2. Le sens d’une requête est traduit à chaque couche. L’application dit « 8 Ko à partir du décalage 4096 de ce fichier » ; NTFS traduit cela en « ce cluster sur ce volume » ; la pile de stockage le traduit encore en « ce secteur sur ce disque ». Chaque couche supérieure ignore tout de la couche en dessous. Il y a ici une précision importante à ajouter : un seul IRP ne voyage pas de l’application jusqu’au disque sans transformation. Le côté système de fichiers et le côté stockage sont des piles distinctes, et, dans le cadre du traitement de l’IRP adressé au fichier, NTFS crée et émet un nouvel IRP de niveau inférieur, adressé au volume (la pile de stockage). Pour un fichier fragmenté, une seule lecture peut même se scinder en plusieurs IRP de niveau inférieur — la granularité et la durée de vie d’une requête changent à chaque couche.

4.3. Les trois choix de chaque pilote

Il n’y a, fondamentalement, que trois choses qu’un pilote recevant un IRP peut faire.310

la couche du dessous l'a complétée sur-le-champla couche du dessous l'a mise en attente(le statut d'attente se propage jusqu'à l'appelant)Un pilote reçoit l'IRPComment traiter cette requête ?(1) La compléter lui-mêmeappeler IoCompleteRequestex. répondre immédiatement avec des données du cache(2) La transmettre au périphérique du dessousappeler IoCallDriverex. un filtre l'inspecte, puis la laisse passer(3) La mettre en attenteretourner STATUS_PENDING et mettre l'IRP en fileex. attendre la réponse du matérielAppeler IoCompleteRequest plus tarddéclenché par une interruption ou similaireLe traitement d'achèvement remonte la pileen ordre inverse (la routine d'achèvement de chaque couche est appelée)

Figure 5 : les trois choix d’un pilote qui reçoit un IRP. Ces trois choix ne s’excluent pas mutuellement — le chemin le plus courant est « une mise en attente survient plus bas après avoir été transmise » — et chaque chemin se termine par un achèvement via IoCompleteRequest

Ces trois choix ne sont pas mutuellement exclusifs. Le chemin le plus ordinaire est « (2) la transmettre vers le bas, et une couche plus bas choisit (3), la mise en attente », auquel cas STATUS_PENDING se propage inchangé, à la fois vers les pilotes intermédiaires et vers l’appelant d’origine. Quand une couche inférieure la complète plus tard, chaque couche qui avait enregistré une routine d’achèvement en la transmettant vers le bas est rappelée, dans l’ordre inverse — autrement dit, « transmettre » et « mettre en attente » se produisent superposés sur ce même unique IRP.

Ce choix à trois voies est la source de la flexibilité de l’E/S Windows.

  • Un succès de cache permet une complétion immédiate, ce qui est rapide (le gestionnaire de cache, dans la partie 4).
  • N’importe quel nombre de filtres peuvent s’insérer et laisser passer la requête (les minifiltres, dans la partie 6).
  • Parce qu’une requête peut être mise en attente, un thread n’a pas besoin de se figer en attendant un périphérique lent (l’E/S asynchrone, dans les parties 2 et 3).

Le reste de cette série n’est, en réalité, qu’un ensemble de notes en bas de page pour ce seul schéma.

5. Suivre un aller-retour complet de ReadFile

Maintenant que les personnages et les accessoires sont en place, suivons un aller-retour complet de ReadFile, dans le cas où il rate le cache et va jusqu’au disque (nous couvrirons le cas mis en cache dans la partie 4).

Matériel de disquePile de stockageFiltre + NTFSGestionnaire d'E/SThread de l'applicationMatériel de disquePile de stockageFiltre + NTFSGestionnaire d'E/SThread de l'applicationRésout l'objet fichier à partir du handleet assemble l'IRP (IRP_MJ_READ)E/S synchrone : le thread s'endort ici en attendant l'achèvementE/S asynchrone : le contrôle revient et un autre travail peut continuerPoursuit le traitement d'achèvementvia une DPC, déclenchée depuis l'ISRUne fois tous les IRP de niveau inférieurnécessaires achevésExécute la routine d'achèvement de chaque couche en ordre inverse,et fixe le résultat via un APC vers le thread demandeurReadFile -> NtReadFile (appel système)IoCallDriver (vers le haut de la pile)Traduit la position sur le volumeet émet un IRP de niveau inférieur adressé à la pile de stockageÉmet la commande de lectureSTATUS_PENDING (l'IRP de niveau inférieur est en attente)L'IRP d'origine revient aussi en attente(le trajet aller s'arrête ici)Interruption - « la donnée a fini d'être lue »Complète l'IRP de niveau inférieur (IoCompleteRequest)reçu par la routine d'achèvement de NTFSComplète l'IRP d'origine (IRP_MJ_READ)Le statut et le nombre d'octets sont fixés (ex. un événement est signalé)

Figure 6 : un aller-retour de ReadFile lorsqu’il rate le cache. L’achèvement de l’IRP de niveau inférieur et l’achèvement de l’IRP d’origine sont des étapes séparées, et le trajet « aller » et le trajet « retour » se déroulent aussi comme des événements séparés

5.1. Le trajet aller et le trajet retour sont des événements séparés

La ligne critique de ce schéma est STATUS_PENDING. Le pilote de stockage lâche prise dès qu’il a émis une commande vers le matériel, et le traitement « aller » s’arrête exactement là. Le fait que la donnée ait fini d’être lue est annoncé plus tard, par un événement complètement séparé — une interruption — et c’est cela qui déclenche le traitement d’achèvement du « retour ».10

Autrement dit, à l’intérieur du noyau, l’émission et l’achèvement d’une requête d’E/S sont conçus pour pouvoir être séparés. Il n’existe pas de plomberie distincte appelée « E/S synchrone » — le sens précis de l’E/S synchrone est simplement la garantie que l’appel ne revient pas avant l’achèvement. L’attente ne survient que lorsqu’une requête passe en attente ; pour une requête qu’un pilote peut compléter sur-le-champ (le chemin « compléter » de la figure 5), le thread revient avec son résultat sans jamais s’endormir, même en E/S synchrone. La raison pour laquelle Win32 bascule entre synchrone et asynchrone selon la façon dont le handle est ouvert (FILE_FLAG_OVERLAPPED) tient précisément au fait que l’asynchrone n’est pas « une fonctionnalité spéciale supplémentaire » — c’est une différence dans la façon d’attendre.11

Une fois cette vision acquise, les questions traitées dans la partie 2 — pourquoi le caractère asynchrone ou non se décide au moment de l’ouverture du handle, et non par appel (même si la structure OVERLAPPED elle-même est nécessaire pour chaque opération individuelle en cours), et ce que signifie le fait qu’une opération « censée être asynchrone » « se complète de façon synchrone » — commencent à ressembler à la conséquence naturelle de la façon dont le mécanisme est construit, plutôt qu’à des faits arbitraires. La raison pour laquelle l’async/await de .NET ne consomme pas de thread pendant une attente d’E/S (une histoire que nous avons traitée du côté pratique dans « Un tableau de décision pratique pour async/await en C# ») trouve elle aussi son fondement dans ce même schéma.

5.2. Il y a aussi une exception — un raccourci qui évite l’IRP

Pour être honnête, toutes les E/S ne deviennent pas des IRP. Pour les lectures et écritures synchrones vers un fichier qui se trouve dans le cache, le système de fichiers offre un raccourci appelé fast I/O — copier directement depuis le cache sans assembler d’IRP du tout. Dans la colonne Operation de Procmon, ce sont les lignes qui commencent par FASTIO_. Nous couvrirons les conditions dans lesquelles ce raccourci ne peut pas être utilisé, ainsi que sa relation avec le gestionnaire de cache, dans la partie 4.

6. Derrière CloseHandle — cleanup et close sont deux choses différentes

Enfin, comment fermer ce qui a été ouvert. Ceci est directement lié aux investigations de fuites de handles et aux problèmes de « fichier en cours d’utilisation ».

Ce que fait CloseHandle, c’est retirer une entrée de la table des handles du processus. Un objet fichier porte un compteur de handles (le nombre de handles) et un compteur de références (le nombre de références détenues depuis l’intérieur du noyau), et les deux diminuent indépendamment l’un de l’autre.

Système de fichiersGestionnaire d'E/SObject ManagerApplicationSystème de fichiersGestionnaire d'E/SObject ManagerApplicationRetire l'entrée de la table des handleset décrémente le compteur de handlesAnnule les E/S en cours sur cet objet fichieret libère les verrousalt[c'était le dernier handle]Mais si des références internes au noyau subsistent -E/S en cours, section mappée en mémoire, etc. -l'objet fichier est toujours vivantLe démontage de l'objet fichier se termine -ce n'est qu'à ce moment qu'il est vraiment « fermé »alt[le compteur de références a lui aussi atteint zéro]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

Figure 7 : les deux étapes — cleanup (le dernier handle est fermé) et close (toute référence a aussi disparu)

  • IRP_MJ_CLEANUP annonce que « le dernier handle vient d’être fermé ». Mais la documentation elle-même précise que la libération de l’objet fichier peut encore être en suspens s’il reste des E/S en cours.12
  • IRP_MJ_CLOSE annonce que « le compteur de références a atteint zéro ». Il y a un écart entre cleanup et close, et l’un ne suit pas nécessairement l’autre immédiatement.13

Connaître ces deux étapes permet d’expliquer un certain nombre de mystères du terrain.

  • Un fichier mappé en mémoire n’est pas libéré même après l’avoir fermé. Une section mappée continue de détenir une référence vers l’objet fichier, si bien que close n’arrive qu’une fois la vue démappée et la section fermée. Nous avons traité le côté pratique de la mémoire partagée dans « Les pièges de la mémoire partagée et les bonnes pratiques ».
  • La chasse au coupable d’un « fichier en cours d’utilisation » ne s’arrête pas aux seuls handles. Même avec tous les handles fermés, une référence interne au noyau — une image mappée, par exemple — peut encore accrocher le fichier. C’est exactement pour cette raison que la recherche de Process Explorer couvre à la fois les handles et les DLL (fichiers mappés).
  • Vous ne devez jamais compter sur SafeFileHandle ou un finaliseur de .NET pour « finir par » fermer les choses, précisément parce qu’un handle oublié, non fermé, retarde le cleanup et prolonge les violations de partage et les verrous détenus.

7. Vérifiez par vous-même

Tout ce qui précède peut s’observer avec rien de plus qu’un PC Windows avec des droits administrateur.

Il ne vous faut que trois choses.

  • Des droits administrateur. Process Monitor charge un pilote noyau, il doit donc s’exécuter en tant qu’administrateur. WinObj masque également certains objets à qui n’en a pas.
  • Les outils Sysinternals. WinObj et Process Monitor sont distribués gratuitement par Microsoft. Récupérez-les individuellement depuis leurs pages de téléchargement (WinObj, Process Monitor), ou récupérez l’ensemble complet avec la Sysinternals Suite. Il n’y a pas d’installeur — décompressez simplement et exécutez l’exe.
  • Une petite action à observer. Enregistrer un fichier depuis le Bloc-notes, ou copier un petit fichier, suffit amplement. Essayez sur votre disque local, pas sur un dossier partagé métier ou une machine de production.

Regardez l’espace de noms avec WinObj. Lancez WinObj, de Sysinternals, et ouvrez le répertoire GLOBAL?? : vous verrez, directement, que C: est un lien symbolique vers \Device\HarddiskVolumeN (figure 1). Sous \Device, vous pouvez voir les noms réels des objets périphériques que les pilotes ont créés.14

Lisez le vocabulaire des IRP dans Procmon. Activez Filter > Enable Advanced Output dans le menu de Process Monitor, et la colonne Operation bascule de ReadFile vers le vocabulaire côté noyau : IRP_MJ_READ, FASTIO_READ, etc. En observant simplement une copie de fichier, vous verrez se dérouler pour de vrai le déroulement même décrit dans cet article : IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE. Nous avons réuni le côté pratique de l’utilisation de Procmon dans « Guide pratique de Process Monitor (ProcMon) ».

Rester conscient du fond depuis .NET. Le FileStream de C# appelle en interne CreateFileW et conserve le handle sous forme de SafeFileHandle. Les FileOptions passés à son constructeur correspondent presque directement aux indicateurs Win32.

.NET (FileOptions) Win32 (indicateur CreateFile) Signification (partie concernée)
Asynchronous FILE_FLAG_OVERLAPPED Ouvre le handle pour l’E/S asynchrone (parties 2 et 3)
WriteThrough FILE_FLAG_WRITE_THROUGH Empêche les écritures de s’arrêter au cache (partie 4)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN Un indice pour la lecture anticipée (partie 4)
RandomAccess FILE_FLAG_RANDOM_ACCESS Un indice pour supprimer la lecture anticipée (partie 4)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE Supprime dès que le dernier handle se ferme (une application du mécanisme du chapitre 6)

À partir de .NET 6, File.OpenHandle associé à la classe RandomAccess permet aussi d’écrire dans un style beaucoup plus proche de la forme brute de Win32 — « handle plus E/S à décalage spécifié » — sans passer par l’abstraction de FileStream. Une fois que vous comprenez ce que signifie la colonne de droite de ce tableau, les choix de la colonne de gauche cessent d’être des choses à mémoriser.

8. Résumé

  • L’E/S Windows repose sur une conception unique et cohérente : décider la destination (un objet périphérique) dans l’espace de noms de l’Object Manager, conditionner la requête dans un IRP, et la faire descendre la pile de périphériques.2318
  • C: est un lien symbolique, \\.\ nomme directement l’emplacement d’un lien, et un chemin UNC se résout vers un redirecteur. Le véritable secret derrière « tout ressemble à un fichier » est la cohérence de la résolution de noms.45
  • Il y a trois personnages dans ce casting : l’objet pilote (une table de fonctions de traitement), l’objet périphérique (la destination) et l’objet fichier (l’état d’une ouverture). Un HANDLE est une référence à un objet fichier.6789
  • Les choix d’un pilote se résument à trois : compléter, laisser passer ou mettre en attente. L’interception par un filtre, la réponse immédiate depuis le cache et l’E/S asynchrone sont tous des applications de ce même choix à trois voies.310
  • L’émission et l’achèvement d’une requête sont conçus pour être séparables, et l’E/S synchrone est la garantie que « l’appel ne revient pas avant l’achèvement ». Pour une requête qui passe en attente, le trajet aller (l’émission) et le trajet retour (interruption puis achèvement) se déroulent comme des événements séparés.1110
  • CloseHandle ne fait que « rendre un handle ». Une fois que vous connaissez les deux étapes — cleanup (le dernier handle) et close (la dernière référence) — « fermé, mais encore en cours d’utilisation » cesse d’être un mystère.1213

La suite est la partie 2, « E/S synchrones et asynchrones — ce que signifie vraiment OVERLAPPED ». Nous y creusons le mécanisme qui permet à une application d’utiliser réellement la « séparation de l’émission et de l’achèvement » vue dans cet article — FILE_FLAG_OVERLAPPED, les quatre façons d’être notifié de l’achèvement, l’annulation, et le piège où une opération « censée être asynchrone » revient de façon synchrone.

Articles connexes

Domaines de conseil associés

合同会社小村ソフト (Komura Software LLC) traite la conception et l’investigation de bugs autour des E/S de fichiers dans les applications métier Windows (fuites de handles, « fichier en cours d’utilisation », ralentissements d’E/S propres à un environnement particulier, etc.).

Références

  1. Microsoft Learn, Windows Internals - Sysinternals. La page de présentation du livre Windows Internals (publié en japonais sous le titre Inside Windows), l’ouvrage de référence couvrant l’architecture du noyau Windows, y compris le système d’E/S, et un point de départ pour approfondir ce que couvre cette série. 

  2. Microsoft Learn, I/O manager. Sur la façon dont le gestionnaire d’E/S en mode noyau de Windows gère la communication entre les applications et les interfaces fournies par les pilotes de périphérique ; sur le fait que, les périphériques fonctionnant à des vitesses qui ne correspondent pas à celle de l’OS, la communication entre l’OS et les pilotes passe principalement par des IRP (I/O Request Packets) ; et sur les IRP comme quelque chose ressemblant à des paquets réseau ou à des messages Windows, transmis de l’OS à un pilote et d’un pilote à un autre.  2 3

  3. Microsoft Learn, I/O request packets. Sur le fait que la plupart des requêtes envoyées à un pilote de périphérique sont conditionnées sous forme d’IRP ; sur la façon dont les composants de l’OS et les pilotes envoient un IRP à un pilote via IoCallDriver (qui prend un pointeur vers un objet périphérique et un pointeur vers l’IRP) ; sur le fait qu’un IRP est normalement traité par plusieurs pilotes empilés en une pile de périphériques, d’abord envoyé à l’objet périphérique au sommet de la pile ; et sur la capacité de chaque pilote à choisir de traiter et compléter l’IRP ou de le transmettre au pilote du dessous.  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. Sur le fait qu’un nom de périphérique MS-DOS est un lien symbolique vers un nom de périphérique de style NT ; sur le fait qu’une application Windows en mode utilisateur accède à un périphérique par son nom de périphérique MS-DOS (une lettre de lecteur ou un nom de port COM), tandis que les pilotes et le noyau utilisent le nom de style NT ; et sur la façon dont un pilote crée le lien symbolique de \DosDevices\nom vers le périphérique à l’aide de IoCreateSymbolicLink.  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. Sur le fait que CON, PRN, AUX, NUL, COM1 à COM9 et LPT1 à LPT9 sont réservés comme noms de fichiers ; sur le fait que l’espace de noms Win32 comporte un « espace de noms de fichiers » et un « espace de noms de périphériques », et que le préfixe \\.\ signifie accéder à l’espace de noms de périphériques Win32 (par exemple, \\.\PhysicalDrive0) ; et sur les règles régissant cette résolution de noms.  2 3 4

  6. Microsoft Learn, Introduction to driver objects. Sur la façon dont le gestionnaire d’E/S crée une structure DRIVER_OBJECT au chargement d’un pilote ; sur le fait que l’objet pilote détient les points d’entrée vers l’ensemble des routines standard du pilote, y compris le tableau MajorFunction, qui est une table de dispatch ; et sur la façon dont le gestionnaire d’E/S utilise cette table pour appeler la fonction de traitement correspondant à une requête.  2 3

  7. Microsoft Learn, Introduction to device objects. Sur le fait qu’une structure DEVICE_OBJECT représente un périphérique logique, virtuel ou physique et devient la cible d’une requête d’E/S ; sur la façon dont un pilote crée un objet périphérique avec IoCreateDevice ; et sur le fait qu’un objet périphérique est lié au pilote (objet pilote) qui l’a créé.  2 3

  8. Microsoft Learn, Using files in a driver. Sur la façon dont, dans le noyau, un objet fichier représente « une instance d’un fichier (ou périphérique) ouvert » ; et sur le fait qu’un objet fichier est créé à chaque ouverture d’un fichier, portant le contexte de cette ouverture, comme le décalage courant en octets.  2 3

  9. Microsoft Learn, File handles. Sur le fait que le handle de fichier retourné par CreateFile est propre à un processus et lié à un objet fichier ouvert ; sur le fait qu’ouvrir plusieurs fois le même fichier produit à chaque fois des handles séparés (et des états ouverts séparés) ; et sur le fait qu’un handle doit être fermé avec CloseHandle une fois qu’il n’est plus nécessaire.  2 3

  10. Microsoft Learn, Completing IRPs. Sur le fait que l’appel à IoCompleteRequest est ce qui complète une opération d’E/S ; sur le fait qu’à l’achèvement, chaque routine IoCompletion enregistrée par un pilote de la pile supérieure est appelée à son tour ; et sur le déroulement par lequel l’achèvement d’une requête a lieu à un moment séparé de son émission, jusqu’à ce que le statut soit finalement retourné au demandeur.  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. Sur le fait qu’en E/S synchrone, une fonction ne revient pas avant l’achèvement de l’E/S et le thread est mis en attente, alors qu’en E/S asynchrone (E/S overlapped), la fonction ayant émis la requête revient immédiatement et le thread peut poursuivre d’autres travaux ; sur la nécessité d’ouvrir le handle avec FILE_FLAG_OVERLAPPED pour l’E/S asynchrone ; et sur les plusieurs façons disponibles de recevoir la notification d’achèvement.  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. Sur le fait que la réception de cette requête indique que « le dernier handle vers un objet fichier associé à l’objet périphérique cible a été fermé » ; sur le fait que la libération de l’objet fichier peut encore être en suspens en raison de requêtes d’E/S non résolues ; et sur le fait que cet IRP est envoyé dans le contexte du processus qui a fermé le handle.  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. Sur le fait que la réception de cette requête indique que « le compteur de références de l’objet fichier a atteint zéro et que l’objet fichier est sur le point d’être libéré » ; et sur le fait qu’elle est envoyée après la requête de cleanup, mais pas nécessairement immédiatement après, puisqu’elle attend l’achèvement des E/S en cours.  2 3

  14. Microsoft Learn, WinObj - Sysinternals. Sur le fait que WinObj est un outil qui affiche l’espace de noms de l’Object Manager NT, permettant de parcourir les objets qu’il contient, y compris les objets périphériques et les liens symboliques.  2 3

  15. Microsoft Learn, CreateFileW function. Sur le fait que CreateFile peut ouvrir et retourner des handles non seulement vers des fichiers mais aussi vers des périphériques tels que des disques physiques, des volumes, la console, des ports de communication (COM) et des tubes ; sur l’usage de noms au format \\.\ pour ouvrir un périphérique ; et sur la signification de divers indicateurs, dont FILE_FLAG_OVERLAPPED.  2

  16. Microsoft Learn, IRP major function codes. Une liste des codes de fonction majeure des IRP (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP, et d’autres) et ce que signifie chaque requête et quel pilote doit la traiter. 

  17. Microsoft Learn, I/O stack locations. Sur la façon dont le gestionnaire d’E/S prépare un emplacement de pile d’E/S à l’intérieur de l’IRP pour chaque pilote d’une chaîne de pilotes en couches ; sur le fait que chaque emplacement de pile porte le code de fonction majeure/mineure et les paramètres de cette requête ; et sur la façon dont chaque pilote récupère son propre emplacement de pile, et donc le contenu de la requête, via IoGetCurrentIrpStackLocation. 

  18. Microsoft Learn, Device nodes and device stacks. Sur le fait que les objets périphériques s’empilent pour former une pile de périphériques ; sur le fait qu’un IRP est d’abord envoyé à l’objet périphérique au sommet de la pile, chaque couche le traitant ou le transmettant plus bas ; et sur la façon dont l’objet périphérique d’un pilote filtre s’insère dans cette pile.  2

  19. Microsoft Learn, Filter Manager Concepts. Sur le fait que le filter manager est un pilote en mode noyau fourni avec Windows ; sur la façon dont les pilotes minifiltres peuvent intercepter les requêtes d’E/S vers le système de fichiers via des rappels pré- et post-opération ; et sur la façon dont le point d’interception de chaque minifiltre (son altitude) détermine son ordre au sein de la pile d’E/S. 

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Qu'est-ce qu'un IRP ?
Un IRP (I/O Request Packet, paquet de requête d'E/S) est le paquet que le gestionnaire d'E/S du noyau Windows utilise pour rassembler les demandes de lecture, d'écriture et autres d'une application et les remettre aux pilotes de périphérique. La documentation de développement de pilotes de Microsoft explique que la plupart des requêtes envoyées aux pilotes de périphérique sont conditionnées sous forme d'IRP. Un IRP porte un code de fonction majeure indiquant le type de requête (création, lecture, écriture, nettoyage, etc.) et un emplacement de pile pour chaque pilote qu'il traverse ; il est traité au fil de sa descente dans la pile de périphériques, du haut vers le bas. Chaque pilote choisit de compléter l'IRP lui-même, de le transmettre au pilote situé en dessous, ou de le mettre en attente pour le compléter plus tard. Un développeur d'application ne manipule jamais directement un IRP, mais des notations comme IRP_MJ_READ, visibles dans la colonne Operation de Process Monitor, sont ce mécanisme rendu visible.
Pourquoi Windows peut-il ouvrir un fichier, un port série et une imprimante avec le même appel CreateFile ?
Parce que le nom passé à CreateFile est, dans tous les cas, finalement résolu, via l'espace de noms de l'Object Manager, en un objet périphérique, et que le gestionnaire d'E/S crée alors un objet fichier lié à ce périphérique puis retourne un handle — toujours le même chemin. Une lettre de lecteur comme C: est, en dessous, un lien symbolique vers un nom de périphérique NT tel que \Device\HarddiskVolume3, et une désignation comme \\.\COM1 se résout de la même façon vers l'objet périphérique du port série. Quel que soit le périphérique vers lequel le nom se résout, toute requête ultérieure est conditionnée dans le même format d'IRP et livrée à un pilote, ce qui permet d'ouvrir et de lire ou d'écrire aussi bien des fichiers que des périphériques via la même API. Les chemins UNC suivent le même mécanisme : ils se résolvent simplement vers le périphérique du redirecteur réseau. Cette combinaison « espace de noms plus paquet » est la véritable source de la cohérence de l'E/S Windows.
Pourquoi un fichier n'est-il pas libéré immédiatement après l'appel de CloseHandle ?
Parce que ce que fait CloseHandle, c'est « rendre un handle », pas « fermer le fichier ». Un objet fichier à l'intérieur du noyau porte deux compteurs distincts : un compteur de handles (le nombre de handles ouverts) et un compteur de références (le nombre de références détenues par des composants du noyau). IRP_MJ_CLEANUP est envoyé au système de fichiers dès que le dernier handle est fermé, mais tant que des références internes au noyau subsistent — E/S en cours, section de fichier mappé en mémoire, etc. — l'objet fichier lui-même reste vivant, et IRP_MJ_CLOSE n'est envoyé qu'une fois le compteur de références retombé à zéro. De nombreux cas où un fichier ne peut pas être supprimé après avoir été mappé en mémoire, ou où une application signale un fichier comme « en cours d'utilisation » après l'avoir pourtant déjà fermé, s'expliquent par ce mécanisme en deux étapes.
En quoi la connaissance des IRP et des piles de périphériques est-elle utile à un développeur d'application ?
Même sans jamais écrire d'IRP soi-même, cela sert aussi bien à l'investigation qu'à la conception. D'abord, la colonne Operation de Process Monitor (avec la sortie avancée activée) affiche exactement le vocabulaire des IRP, comme IRP_MJ_CREATE et IRP_MJ_READ : connaître les termes de cette couche permet de vraiment lire le journal. Ensuite, savoir que des pilotes filtres, comme les antivirus, s'insèrent sur tout le chemin emprunté par les E/S de fichiers donne une piste pour investiguer un problème du type « l'accès aux fichiers est lent uniquement dans cet environnement particulier ». Enfin, comprendre que l'E/S Windows est conçue pour que l'émission et l'achèvement d'une requête puissent être séparés, et que l'E/S synchrone n'est qu'une garantie que « l'appel ne revient pas avant l'achèvement », permet de saisir depuis les fondations le comportement de l'E/S asynchrone, des ports d'achèvement d'E/S et de l'async/await de .NET, plutôt que de les retenir comme de simples faits mémorisés.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog