Les profondeurs de l'E/S Windows (épisode 4) — Le gestionnaire de cache : quand votre WriteFile atteint-il vraiment le disque ?

· · Windows, Win32, E/S, Cache, Noyau, Système de fichiers, .NET, CSharp

WriteFile a retourné un succès. Alors, où se trouve la donnée à cet instant ?

La réponse est, presque à coup sûr, pas encore sur le disque. Elle a simplement été copiée dans le cache en mémoire. C’est pour cela que « on avait enregistré, mais après une coupure de courant, c’était perdu » peut arriver, c’est pour cela que les benchmarks de copie de fichiers affichent des débits physiquement impossibles, et c’est pour cela que les bases de données appellent scrupuleusement fsync.

Ce quatrième épisode de la série « Les profondeurs de l’E/S Windows » porte sur ce qui se trouve entre les deux : le gestionnaire de cache. Dans l’épisode 2, nous avions écrit que « si la donnée est déjà dans le cache, une E/S asynchrone se termine aussi de manière synchrone », et dans l’épisode 1, nous avions laissé en suspens « le raccourci qui ne crée pas d’IRP (le fast I/O) ». Cet épisode referme tous ces fils.

1. La conclusion, d’abord

  • Le cache de fichiers de Windows fonctionne en écriture différée (write-back). La lecture se fait d’abord depuis le cache système de fichiers, l’écriture se fait d’abord vers le cache également. La répercussion sur le disque est effectuée plus tard par l’OS.1
  • Le cache repose en réalité sur un mappage de fichiers. Le gestionnaire de cache mappe des sections de 256 Ko du fichier dans l’espace d’adressage système, et la lecture/écriture devient « une copie mémoire vers/depuis cette vue » (chapitre 2).1
  • L’écriture est répercutée après coup par le lazy writer, qui s’exécute chaque seconde. Un crash de l’application ne fait pas perdre les données, mais une coupure de courant ou un plantage de l’OS fait perdre le cache non encore écrit (dirty) (chapitre 4).1
  • Trois outils permettent d’« écrire de façon garantie ». FlushFileBuffers (= Flush(true) en .NET), FILE_FLAG_WRITE_THROUGH, et FILE_FLAG_NO_BUFFERING. Vider le cache à chaque écriture est inefficace en cas d’écritures fréquentes ; la documentation officielle recommande alors de combiner NO_BUFFERING et WRITE_THROUGH (chapitre 5).21
  • NO_BUFFERING impose des exigences d’alignement. La taille et le décalage doivent être des multiples entiers de la taille de secteur, et l’adresse du tampon doit aussi être alignée sur une frontière de secteur physique. Et même avec NO_BUFFERING, les métadonnées continuent d’être mises en cache (section 5.3).31
  • La vue mappée et le cache partagent les mêmes données. Les fichiers mappés en mémoire et l’E/S en cache classique sont cohérents entre eux ; rendre une vue mappée persistante se fait en deux étapes, FlushViewOfFile puis FlushFileBuffers (chapitre 6).45
  • Une lecture ou une écriture synchrone sur un fichier déjà en cache peut se passer de créer un IRP. Un raccourci appelé fast I/O va directement au gestionnaire de cache — c’est la réponse laissée en suspens dans l’épisode 1 (chapitre 7).6

2. La véritable nature du cache — le fichier est mappé en mémoire

2.1. Emplacements de 256 Ko et copie mémoire

Si l’on imagine le cache de fichiers de Windows comme un simple « conteneur de blocs disque », de nombreux comportements deviennent inexplicables. Voici l’image correcte : le gestionnaire de cache mappe des sections de 256 Ko du fichier dans des « emplacements » (slots) de l’espace d’adressage système, et une lecture ou une écriture en cache s’exécute comme une copie mémoire entre cet emplacement et le tampon de l’application.1

Espace d'adressage systèmeApplication (mode utilisateur)ReadFile/WriteFile =copie mémoire avec l'emplacementLe chargement au premier accès etla réécriture différée se font par pageCache système de fichiersemplacement mappant une section de 256 Ko du fichierTampon de l'application(zone passée à ReadFile/WriteFile)Fichier sur le disque

Figure 1 : la réalité de l’E/S en cache. Du point de vue de l’application, « lire ou écrire un fichier » n’est, la plupart du temps, qu’une simple copie mémoire

Un point facile à mal comprendre. 256 Ko est la granularité de la vue (du mappage), pas une indication que l’E/S disque se fait toujours par blocs de 256 Ko. Les pages à l’intérieur de l’emplacement sont chargées selon les besoins, et le volume réel d’E/S qui part vers le disque varie selon la taille de la requête et le motif d’accès. Pour une section lue pour la première fois, une E/S disque se produit pour la remplir (c’est ici que l’IRP de l’épisode 1 descend vers la pile de stockage inférieure). Si elle est déjà dans le cache, la lecture se termine par une simple copie. Le fait, observé au chapitre 5 de l’épisode 2, qu’« en cas de cache hit, une émission asynchrone se termine aussi de manière synchrone » était une manifestation de ce comportement consistant à « terminer sur-le-champ une requête à laquelle on peut répondre immédiatement ». À l’inverse, comme vu également dans l’épisode 2, lorsqu’une page reste absente de la mémoire malgré un handle en cache activé, le mécanisme de traitement des défauts de page ne dispose pas de voie asynchrone, si bien qu’une lecture asynchrone peut être traitée de façon synchrone — un piège déjà rencontré.7

2.2. Ce qui se cache derrière « la mémoire libre a diminué »

L’utilisation ou non du cache, ainsi que l’état de la lecture anticipée, sont gérés par mode d’ouverture (par objet fichier)1, mais les données mises en cache elles-mêmes sont partagées au niveau du fichier (du flux). Ouvrir plusieurs fois le même fichier ne crée pas des caches distincts : tous les handles voient le même contenu de cache (c’est le fondement de la cohérence évoquée au chapitre 6). Le cache reste sous le contrôle du gestionnaire de cache tant que Windows fonctionne.1 Copier un gros fichier ou effectuer de nombreuses lectures/écritures fait progressivement basculer la mémoire physique libre vers le cache. Même si la mémoire libre semble diminuer dans le Gestionnaire des tâches, une grande partie de cette mémoire est « en attente, mais utilisée de façon utile, et cédée rapidement dès qu’une application en fait la demande ». Pour éviter de se tromper sur cette distinction lors d’un diagnostic de manque de mémoire, les pratiques d’observation sont aussi traitées dans « Distinguer une attente de GC d’une fuite mémoire en .NET ».

Ce comportement peut se vérifier à l’écran. En ouvrant Gestionnaire des tâches > Performance > Mémoire, la barre « Composition de la mémoire » en bas est divisée en En cours d’utilisation / Modifiée / En veille / Disponible. La majeure partie du cache de fichiers se retrouve dans cette zone En veille, totalisée dans la liste de droite sous « Mise en cache ». Pour un examen plus détaillé, l’onglet Mémoire du Moniteur de ressources affiche la même répartition avec les capacités correspondantes. En copiant un fichier de plusieurs Go puis en observant à nouveau, on constate que la zone En veille augmente et la mémoire disponible diminue, alors que « En cours d’utilisation » varie à peine — autrement dit, on peut vérifier visuellement que ce n’est pas « la mémoire qui a été engloutie », mais « la mémoire libre qui a été utilisée par le cache ».

3. La lecture anticipée — parier sur la lecture

À partir des motifs d’accès passés, le gestionnaire de cache charge par anticipation les sections susceptibles d’être lues ensuite (read-ahead). Pour un fichier lu séquentiellement, la suite des données est déjà dans le cache avant même que l’application ne la demande — c’est le secret de la rapidité des lectures séquentielles. Le volume de lecture anticipée n’est pas fixe : il varie selon le motif détecté et la taille des requêtes.

Historique des requêtes de lecture de l'applicationlecture séquentielle depuis le débutLe gestionnaire de cachedétecte un motifLecture anticipée : charger la suiteavant qu'elle ne soit demandée(volume variable selon le motif et la taille des requêtes)Indice FILE_FLAG_SEQUENTIAL_SCAN= favoriser la lecture anticipéeIndice FILE_FLAG_RANDOM_ACCESS= la limiter car elle serait gaspillée

Figure 2 : la lecture anticipée. En plus de la détection du motif d’accès, des indices peuvent être fournis via les indicateurs de CreateFile

FileOptions.SequentialScan / RandomAccess, mentionnés dans le tableau de correspondance de l’épisode 1, sont des indices destinés à ce moteur de lecture anticipée. Le premier convient à un traitement par lots qui « parcourt tout », le second à un accès de type navigation dans un index — considérer ces indicateurs comme un moyen de renseigner l’OS sur un avenir que seule l’application connaît rend leur usage évident.

4. L’écriture différée — ce que signifie vraiment le « succès » de WriteFile

4.1. Le lazy writer passe chaque seconde

Côté écriture, c’est un cache en écriture différée (write-back). WriteFile retourne un succès dès que la donnée a été copiée dans l’emplacement du cache, et la répercussion sur le disque est reportée. Cette politique consistant à « écrire plus tard » est l’écriture différée (lazy writing).1

C’est le lazy writer, déclenché chaque seconde par le gestionnaire de cache, qui assure cette répercussion. Il met en file d’écriture un huitième des pages qui n’ont pas été vidées récemment, et en ajoute davantage s’il reste beaucoup de données à écrire. Notez que les fichiers temporaires créés avec l’attribut FILE_ATTRIBUTE_TEMPORARY sont exclus des vidages du lazy writer — inutile d’écrire quelque chose destiné à être supprimé immédiatement.1 Ceci n’est cependant qu’un indice donné par l’attribut : en cas de pression mémoire, une réécriture peut quand même avoir lieu, et cela ne s’applique pas à un fichier « dont le nom ressemble seulement à un fichier temporaire ».

Disquelazy writer (déclenché chaque seconde)Cache systèmeApplicationDisquelazy writer (déclenché chaque seconde)Cache systèmeApplicationCopie dans l'emplacement,page marquée dirty (non écrite)De là jusqu'à la réécriture s'étend la « fenêtre à risque »en cas de coupure de courant ou de plantage de l'OS, ces données disparaissentPersistance effective seulement iciWriteFile(données)TRUE retourné immédiatementSélectionne 1/8 des pages dirtyRéécrit le lot

Figure 3 : l’écriture différée. Le succès de WriteFile signifie « remis à l’OS », pas « rendu persistant »

4.2. Que se passe-t-il, et jusqu’où les données disparaissent-elles ?

Précisons le sens exact de cette « fenêtre à risque ». Le sort des données dépend du type de défaillance.

Données juste après le succès de WriteFile(page dirty dans le cache)Que s'est-il passé ?Crash / arrêt forcédu processus de l'applicationArrêt de l'OS lui-même(coupure de courant, écran bleu)Les données surviventle cache appartient à l'OS,le lazy writer les réécrit comme prévuLes pages dirty sont perduesseule la part déjà arrivée sur le disque subsiste

Figure 4 : le type de défaillance détermine ce qui survit. Le cache n’est pas « la propriété du processus » mais « la propriété de l’OS »

  • Même si l’application meurt, les données ne disparaissent pas. Dès que la copie vers le cache est terminée, le propriétaire des données est l’OS. C’est grâce à cela que « l’application a planté juste après l’enregistrement, mais le fichier est resté intact ».
  • Quand l’OS lui-même s’arrête, la part non écrite disparaît. La fréquence de vidage est ajustée comme un compromis entre performance et fiabilité, et la documentation précise explicitement qu’« en cas de perte de courant soudaine, les données mises en cache sont perdues ».1

Autrement dit, la question à se poser lors de la conception d’une application métier est : « cette donnée peut-elle être perdue à l’instant même d’une coupure de courant ? » Quelques secondes de journal, peut-être. Un enregistrement confirmé de commande, sûrement pas. N’utilisez les outils du chapitre suivant que pour ce qui n’est pas tolérable à perdre.

5. La boîte à outils pour garantir une écriture

5.1. FlushFileBuffers — écrire intégralement, tout de suite

FlushFileBuffers écrit intégralement sur le périphérique les données mises en tampon pour le fichier spécifié. Comme les métadonnées du système de fichiers sont toujours mises en cache, un point à retenir est qu’un vidage (ou WRITE_THROUGH) est nécessaire pour garantir que même les métadonnées soient écrites.12 En .NET, FileStream.Flush(true) équivaut à cela (Flush() seul ne fait que transmettre le tampon interne de .NET à l’OS ; le cache de l’OS, lui, reste inchangé).8

La documentation officielle met cependant clairement en garde : l’appeler à chaque écriture est inefficace. Si de nombreuses écritures nécessitent chacune une persistance garantie, il vaut mieux utiliser la combinaison NO_BUFFERING + WRITE_THROUGH décrite plus loin.2

5.2. FILE_FLAG_WRITE_THROUGH — supprimer seulement le délai

Ouvert avec FILE_FLAG_WRITE_THROUGH, une écriture est à la fois écrite dans le cache et écrite immédiatement sur le disque, sans attendre le lazy writer.1 Le point important est que la lecture continue de bénéficier du cache : c’est la réponse directe à « je veux garder des lectures rapides, mais supprimer uniquement le délai d’écriture ».

5.3. FILE_FLAG_NO_BUFFERING — court-circuiter le cache

FILE_FLAG_NO_BUFFERING retire complètement le cache système de la lecture et de l’écriture. Toute lecture ou écriture devient une E/S vers le périphérique disque à chaque fois, sans passer par le cache.1 Cependant, ce qui est court-circuité s’arrête au cache système de Windows : comme le montre la figure 5, le cache d’écriture interne au périphérique reste un étage à part. Pour obtenir une résistance à la coupure de courant, l’ajout de WRITE_THROUGH ou de FlushFileBuffers reste nécessaire. C’est un outil destiné aux transferts massifs de données en bloc ou aux moteurs de base de données qui gèrent eux-mêmes leurs tampons, mais il vient avec des contraintes strictes.3

  • La taille et le décalage (offset) de lecture/écriture doivent être des multiples entiers de la taille de secteur du volume (pour un secteur de 512 octets : 512, 1024, 1536…).
  • L’adresse du tampon doit aussi être alignée sur la taille de secteur physique (une attention particulière est nécessaire pour les disques « Advanced Format » à secteur physique de 4 096 octets).
  • Malgré cela, les métadonnées continuent d’être mises en cache, donc une persistance complète nécessite de combiner WRITE_THROUGH ou d’appeler FlushFileBuffers.12

Ces « contraintes » sont précisément l’endroit où trébuche en premier quiconque pense qu’il suffit d’ajouter le drapeau. Lire ou écrire sans respecter l’alignement échoue avec ERROR_INVALID_PARAMETER (87). Résumons les trois points à respecter.3

Élément à aligner Condition Comment y satisfaire
Taille de lecture/écriture Multiple entier de la taille de secteur du volume Récupérer lpBytesPerSector via GetDiskFreeSpace et arrondir à ce multiple
Décalage (offset) du fichier Idem (même règle si spécifié via Offset de OVERLAPPED) Avancer par multiples de la taille de secteur
Adresse du tampon Alignée sur la taille de secteur physique Allouer avec VirtualAlloc (retourne une zone alignée sur une frontière de page, généralement 4 096 octets)

C’est le troisième point qui est le plus souvent oublié. malloc, new, ou un tableau C# ne garantissent aucun alignement sur une frontière de secteur. En utilisant VirtualAlloc, qui alloue sur une frontière de page, on satisfait du même coup les exigences des disques « Advanced Format » à secteur physique de 4 096 octets. Voici la forme minimale.

// C++ / Win32. La gestion des erreurs est réduite au minimum
DWORD sectorsPerCluster = 0, bytesPerSector = 0, freeClusters = 0, totalClusters = 0;
if (!GetDiskFreeSpaceW(L"C:\\", &sectorsPerCluster, &bytesPerSector,
                       &freeClusters, &totalClusters))
{
    return GetLastError();
}

// L'unité de lecture/écriture doit être un multiple entier de la taille de secteur (ici, environ 1 Mio)
const DWORD chunk = (1024 * 1024 / bytesPerSector) * bytesPerSector;

// Le tampon doit être une zone alignée sur une frontière de page (non garanti par malloc/new)
BYTE* buffer = static_cast<BYTE*>(
    VirtualAlloc(nullptr, chunk, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buffer == nullptr) { return GetLastError(); }

HANDLE h = CreateFileW(L"C:\\temp\\big.bin", GENERIC_READ, FILE_SHARE_READ, nullptr,
                       OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, nullptr);
if (h == INVALID_HANDLE_VALUE)
{
    // GetLastError retourne le résultat du « dernier appel Win32 ». Appeler VirtualFree
    // avant lui écraserait la raison réelle de l'échec de CreateFileW (accès refusé,
    // chemin inexistant, etc.) par le résultat du nettoyage, ne laissant qu'un code sans cause identifiable
    const DWORD err = GetLastError();
    VirtualFree(buffer, 0, MEM_RELEASE);
    return err;
}

// On avance par blocs de chunk octets à chaque fois, donc la taille comme le décalage restent alignés
DWORD read = 0;
while (ReadFile(h, buffer, chunk, &read, nullptr) && read > 0)
{
    // Traiter les read premiers octets de buffer
    // (read < chunk en fin de fichier ; c'est normal)
}

CloseHandle(h);
VirtualFree(buffer, 0, MEM_RELEASE);

Notez que FileOptions en .NET n’a pas de valeur correspondant à FILE_FLAG_NO_BUFFERING. Si c’est absolument nécessaire, il faudra appeler directement CreateFile, et dans ce cas également, les exigences d’alignement ci-dessus doivent être respectées soi-même. Raisonnez dans cet ordre : pas « NO_BUFFERING parce que je veux que ce soit rapide », mais « NO_BUFFERING parce que je gère moi-même mes tampons ».

5.4. Récapitulatif des usages

WriteFile par défaut : le succès est retourné icilazy writer (chaque seconde) / WRITE_THROUGH (immédiat)Selon la temporisation du périphérique /FlushFileBuffers exige une écriture complèteNO_BUFFERING contourne le cache et va directement iciTampon de l'applicationCache système de fichiers(pages dirty)Cache interne au périphérique disqueSupport de stockage non volatil

Figure 5 : la hiérarchie des données, et jusqu’où chaque outil pousse l’écriture. Attention au dernier étage : « le cache interne au périphérique disque »

Méthode Ce qui se passe Cas d’usage adapté
Par défaut (cache activé) Terminé par copie dans le cache. Répercuté par le lazy writer La plupart des E/S de fichiers
FlushFileBuffers / Flush(true) Écrit intégralement les données et métadonnées à cet instant Confirmation à un point clé (validation de transaction, etc.)
FILE_FLAG_WRITE_THROUGH Écriture immédiate sur disque à chaque fois (lecture toujours en cache) Journal/log dont chaque écriture ne peut être perdue
FILE_FLAG_NO_BUFFERING (+ WRITE_THROUGH) Sans passer par le cache. Exigences d’alignement Gestion de tampon maison, E/S massive en bloc

L’ordre de sélection se fait en deux temps : décider d’abord « combien d’enregistrements peut-on se permettre de perdre en cas de coupure de courant », puis vérifier « quel ralentissement est acceptable pour cela ». Plutôt que de parcourir le tableau de haut en bas, suivez cet arbre de décision.

Acceptable(ex. : log des quelques dernières secondes)Non acceptableÀ chaque point clé(ex. : confirmation d'une transaction)À chaque enregistrementNon (application classique)Oui (moteur de BD, etc.)Je m'apprête à écrire cette donnéePeut-elle être perdue à l'instantd'une coupure de courant ou d'un écran bleu ?Rester sur le comportement par défaut (cache activé)Le plus rapide. La plupart des E/S en relèventCe qui ne peut pas être perdu,est-ce « à chaque point clé » ou « à chaque enregistrement » ?FlushFileBuffers à chaque point cléFlush(true) en .NETCoût : seulement l'attente au point cléPeut-on gérer soi-même le tamponet satisfaire les exigences d'alignement du 5.3 ?FILE_FLAG_WRITE_THROUGHÉcriture immédiate sur disque à chaque foisLecture toujours rapide via le cacheFILE_FLAG_NO_BUFFERING+ FILE_FLAG_WRITE_THROUGHLa forme de « persistance fréquente » citée par la documentation officielle

Figure 6 : comment choisir l’outil. Le premier embranchement porte sur l’exigence de fiabilité, le second sur le coût acceptable. Il n’existe pas de voie « FlushFileBuffers à chaque enregistrement », car, comme vu en 5.1, la documentation officielle la juge inefficace

Voici, pour finir, trois motifs pratiques.

  1. « Écrire dans un fichier temporaire → vider → renommer » est la règle classique pour ne jamais laisser de fichier corrompu. Écrire intégralement le contenu, puis le valider par le renommage — cette remise atomique est traitée en détail dans « Les bases du contrôle d’exclusion pour l’intégration de fichiers ».
  2. Confier cela à une base de données est aussi une conception tout à fait légitime. Pour la manière dont SQLite construit sa durabilité avec le WAL et les vidages, référez-vous à « Utiliser SQLite dans une application métier en C# ». L’option « ne pas écrire sa propre stratégie de vidage » reste toujours disponible.
  3. En cas de benchmark, soupçonnez le cache. Une mesure de lecture « anormalement rapide » mesure le plus souvent un cache hit à partir de la deuxième exécution. Les bonnes pratiques de mesure sont résumées dans « Comment comparer correctement la vitesse de différentes versions d’un programme sous Windows ».

N’oubliez pas non plus le dernier étage de la figure 5 — le cache interne au périphérique disque. FlushFileBuffers exige une écriture complète incluant cet étage, mais pour une clé USB ou un disque externe, la politique de cache d’écriture du périphérique (« Retrait rapide » et « Performances supérieures ») entre en jeu. Pour la gestion des périphériques amovibles, référez-vous aussi à « Comment gérer les périphériques USB dans une application Windows ».

6. Cohérence avec les fichiers mappés en mémoire

En entendant, dans l’épisode 1, que « le cache repose en réalité sur un mappage de fichiers », certains ont dû se demander : la vue que l’on crée soi-même avec MapViewOfFile n’entre-t-elle pas en conflit avec le cache utilisé par ReadFile/WriteFile ?

Non. Parce qu’ils reposent sur le même mécanisme. L’objet de mappage de fichiers est adossé au fichier, et l’éviction des pages se traduit par une réécriture vers le fichier. Même si plusieurs processus créent des vues sur le même fichier local, le contenu observé est cohérent.4

Espace d'adressage systèmeEspace d'adressage du processus AVue du gestionnaire de cache(emplacement utilisé par ReadFile/WriteFile)Vue MapViewOfFileMêmes pages physiques(mémoire adossée au fichier)Fichier sur le disqueLes E/S en FILE_FLAG_NO_BUFFERINGsont hors de ce partage (directement vers le disque)

Figure 7 : la vue mappée comme le cache observent les mêmes « pages adossées au fichier ». Seul NO_BUFFERING reste en dehors

Deux points de vigilance.

  • Les E/S en FILE_FLAG_NO_BUFFERING sont hors de cette cohérence. Les lectures/écritures qui ne passent pas par le cache ne sont pas mises en correspondance avec le contenu accessible via la vue mappée/le cache. Si vous les mélangez, c’est à vous d’assurer la cohérence.
  • Rendre une vue mappée persistante se fait en deux temps. FlushViewOfFile déclenche l’écriture des pages dirty dans la plage concernée, mais elle n’écrit pas les métadonnées et n’attend pas non plus l’écriture physique effective depuis le cache du périphérique disque. Pour une garantie complète, appelez FlushFileBuffers après FlushViewOfFile.5

Les aspects pratiques du mappage de fichiers utilisé comme mémoire partagée (partage nommé, synchronisation, motifs d’incidents) sont traités dans « Les pièges de la mémoire partagée et les bonnes pratiques ».

7. Le fast I/O — la réponse laissée en suspens dans l’épisode 1

D’abord en deux lignes. Le fast I/O est un raccourci prévu pour les lectures et écritures synchrones sur un fichier déjà en cache, qui échange les données directement avec le cache sans construire d’IRP (I/O Request Packet, le conteneur de requête que le noyau transmet au pilote). Si la colonne Operation de Procmon affiche un mélange de IRP_MJ_READ et de FASTIO_READ, c’est simplement parce qu’une même « lecture » a emprunté soit le chemin habituel, soit le raccourci.

Dans la section 5.2 de l’épisode 1, nous avions écrit que « toute E/S ne devient pas forcément un IRP ». Voici la correction.

On sait que lire ou écrire un fichier déjà en cache peut se contenter d’une simple copie mémoire avec le cache, sans avoir besoin de construire un IRP et de le faire descendre dans la pile de périphériques. Windows a donc mis en place, pour les E/S synchrones sur un fichier en cache, un raccourci appelé fast I/O : sans créer d’IRP, il appelle directement le « point d’entrée fast I/O » du système de fichiers, un chemin qui copie directement depuis le gestionnaire de cache.6 Lorsque le fast I/O ne peut pas traiter la requête (absente du cache, verrou impliqué, filtre qui intervient, etc.), le traitement revient au chemin IRP habituel. Notez que c’est un chemin rapide réservé aux requêtes synchrones, et non pas une règle « cache hit = toujours fast I/O ». Les opérations sur un handle asynchrone (FILE_FLAG_OVERLAPPED) peuvent être traitées par le chemin IRP même lorsqu’elles se terminent sur-le-champ depuis le cache (chapitre 5 de l’épisode 2).

OuiNonLecture/écriture synchrone sur un handle en cache activéLe fast I/O peut-il traiter la requête ?(présente dans le cache, etc.)Fast I/Ocopie directe avec le cache, sans créer d'IRPaffiché comme FASTIO_ dans ProcmonChemin habituelconstruction d'un IRP vers la pile de périphériques(l'univers de la figure 6 de l'épisode 1)

Figure 8 : l’embranchement du fast I/O. C’est pour cela que Procmon affiche un mélange de FASTIO_READ et de IRP_MJ_READ

Cela explique pourquoi les lignes FASTIO_ apparaissaient mêlées lors de l’observation Procmon du chapitre 7 de l’épisode 1. Pour une lecture en cache hit, même l’IRP est un luxe superflu. L’existence de ce chemin a aussi des répercussions sur les pilotes filtres traités dans l’épisode 6 (les mini-filtres peuvent également intercepter le fast I/O).

8. Conclusion

  • Le cache de fichiers de Windows fonctionne en écriture différée, et repose en réalité sur un mappage de sections de 256 Ko du fichier. Une lecture/écriture en cache devient une copie mémoire avec l’emplacement.1
  • En lecture, la lecture anticipée parie sur l’avenir, et SequentialScan/RandomAccess en sont les indices.1
  • En écriture, le lazy writer, chaque seconde, répercute après coup. Les données survivent si l’application meurt, mais seule la part dirty disparaît si l’OS lui-même s’arrête. La question de conception est : « cette donnée peut-elle être perdue à l’instant d’une coupure de courant ? »1
  • Les outils pour écrire de façon garantie sont FlushFileBuffers (confirmation à un point clé), WRITE_THROUGH (à chaque écriture), NO_BUFFERING (sans passer par le cache + exigences d’alignement). Vider à chaque fois est inefficace ; pour une persistance fréquente, la documentation officielle recommande la combinaison NO_BUFFERING + WRITE_THROUGH. Attention aussi au fait que les métadonnées restent toujours mises en cache.231
  • La vue mappée et le cache partagent les mêmes pages et restent cohérents. Seul NO_BUFFERING reste en dehors. Rendre une vue mappée persistante se fait en deux temps : FlushViewOfFile puis FlushFileBuffers.45
  • Pour une lecture/écriture synchrone en cache hit, le fast I/O permet même de se passer d’IRP. C’est la véritable nature du FASTIO_ observé dans Procmon à l’épisode 1.6

La suite est l’épisode 5, « La structure interne de NTFS — comprendre le système de fichiers à partir du MFT ». Jusqu’ici, nous avons traité le fichier comme « un décalage et une suite d’octets » ; nous descendrons désormais vers la structure statique sur le disque, pour voir comment NTFS y organise réellement les données — MFT, flux de données multiples, journal, liens physiques.

Articles connexes

Domaines de conseil associés

合同会社小村ソフト (Komura Software LLC) prend en charge la conception et l’investigation d’incidents liés aux E/S de fichiers dans les applications métier Windows, du type « des données censées être enregistrées ont disparu » ou « l’écriture de fichiers est trop lente / suspicieusement trop rapide ».

Références

  1. Microsoft Learn, File Caching. Sur le fait que Windows met par défaut les données de fichiers en cache, que la lecture se fait depuis le cache système de fichiers et que l’écriture s’effectue aussi vers le cache selon un modèle d’écriture différée, que le cache est géré par objet fichier sous le contrôle du gestionnaire de cache, que la politique consistant à retarder l’écriture sur disque en la conservant dans le cache est appelée écriture différée (lazy writing), que lors de la lecture d’un fichier une section de 256 Ko est chargée dans un emplacement de 256 Ko de l’espace d’adressage système et que le processus utilisateur copie les données depuis/vers cet emplacement, que le gestionnaire de cache déclenche le lazy writer chaque seconde, met en file d’attente d’écriture disque un huitième des pages non vidées récemment et en ajoute davantage si nécessaire, que les fichiers temporaires ne sont pas vidés, qu’une défaillance système soudaine comme une perte de courant entraîne la perte des données en cache non écrites, que même avec FILE_FLAG_NO_BUFFERING désactivant le cache les métadonnées de fichier peuvent rester mises en cache, qu’avec FILE_FLAG_WRITE_THROUGH les données sont écrites à la fois dans le cache et immédiatement sur le disque sans le délai du lazy writer, et que les métadonnées du système de fichiers étant toujours mises en cache, leur persistance nécessite un vidage ou FILE_FLAG_WRITE_THROUGH.  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

  2. Microsoft Learn, FlushFileBuffers function. Sur le fait que WriteFile écrit normalement dans un tampon interne, que l’OS l’écrit périodiquement sur le disque, que FlushFileBuffers écrit intégralement sur le périphérique toutes les informations mises en tampon du fichier spécifié, que l’appeler à chaque écriture parmi de nombreuses écritures est inefficace et qu’une application nécessitant la persistance de données importantes lors d’écritures fréquentes devrait utiliser une E/S non mise en tampon via FILE_FLAG_NO_BUFFERING et FILE_FLAG_WRITE_THROUGH, et que l’appeler sur un handle de volume (avec des droits administrateur) permet de vider tous les fichiers ouverts sur ce volume.  2 3 4 5

  3. Microsoft Learn, File Buffering. Sur les exigences d’accès à un fichier ouvert avec FILE_FLAG_NO_BUFFERING : la taille de lecture/écriture et le décalage du fichier (y compris lorsqu’il est spécifié via OVERLAPPED) doivent être des multiples entiers de la taille de secteur du volume, l’adresse du tampon de lecture/écriture doit être alignée sur la taille de secteur physique, et une attention particulière est nécessaire pour les périphériques Advanced Format à secteur physique de 4 096 octets.  2 3 4

  4. Microsoft Learn, File Mapping. Sur le fait que l’objet de mappage de fichiers est adossé au fichier sur le disque, que le swap-out d’une page se traduit par une écriture des modifications vers le fichier, et que lorsque plusieurs processus créent des vues d’un fichier local à partir du même objet de mappage, les données sont cohérentes (contenu identique au fichier sur le disque).  2 3

  5. Microsoft Learn, FlushViewOfFile function. Sur le fait que FlushViewOfFile déclenche l’écriture sur disque des pages dirty dans la plage de la vue mappée, que cette fonction ne vide pas les métadonnées de fichier et n’attend pas non plus l’achèvement de l’écriture physique depuis le cache disque matériel, et qu’écrire physiquement l’intégralité des pages dirty et des métadonnées nécessite d’appeler FlushFileBuffers après FlushViewOfFile.  2 3

  6. Microsoft Learn, IRPs Are Different From Fast I/O. Sur le fait que le fast I/O est un chemin rapide d’E/S synchrone pour les fichiers en cache, qui appelle directement les points d’entrée du système de fichiers ou du gestionnaire de cache sans générer d’IRP, que les données sont transférées directement du cache vers le tampon utilisateur (ou inversement), et que le chemin habituel basé sur les IRP est utilisé lorsque le fast I/O ne peut pas traiter la requête.  2 3

  7. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. Sur le fait qu’une requête se termine sur-le-champ avec TRUE lorsque la donnée est déjà dans le cache, que le cache de Windows est implémenté via un mappage de fichiers, et que, en l’absence de mécanisme asynchrone de traitement des défauts de page lorsqu’une page est absente, une lecture asynchrone en cache activé peut être traitée de façon synchrone. 

  8. Microsoft Learn, FileStream.Flush method (.NET). Sur le fait que Flush() écrit le tampon interne du flux vers l’OS, et que Flush(true) vide en plus tous les tampons de fichier intermédiaires (les tampons de l’OS). 

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.

Au moment où WriteFile retourne un succès, la donnée est-elle déjà écrite sur le disque ?
Par défaut, non. Le cache de fichiers de Windows fonctionne en écriture différée (write-back), et WriteFile retourne un succès dès que la donnée a été copiée dans le cache système de fichiers. L'écriture sur le disque est effectuée plus tard par le lazy writer, déclenché chaque seconde par le gestionnaire de cache. Ce qui compte, c'est la différence selon le type de défaillance. Même si le processus de l'application plante, les données déjà dans le cache ne sont pas perdues : elles seront écrites plus tard tant que l'OS reste en vie. En revanche, en cas de défaillance de l'OS lui-même (coupure de courant, écran bleu), le cache dirty pas encore écrit est perdu. Il est exact de comprendre que « WriteFile a réussi » signifie « remis à l'OS », et non « rendu persistant ».
Comment écrire de façon garantie sur le disque ?
Il existe trois outils. D'abord, FlushFileBuffers, qui écrit intégralement sur le périphérique les données et métadonnées mises en tampon du fichier (en .NET, FileStream.Flush(true) équivaut à cela). Ensuite, FILE_FLAG_WRITE_THROUGH, qui écrit dans le cache à chaque écriture tout en écrivant aussi immédiatement sur le disque. Enfin, FILE_FLAG_NO_BUFFERING, qui ne passe pas du tout par le cache. La documentation Microsoft indique qu'appeler FlushFileBuffers à chaque écriture est inefficace, et que si une persistance garantie est nécessaire lors d'écritures fréquentes, il vaut mieux combiner FILE_FLAG_NO_BUFFERING et FILE_FLAG_WRITE_THROUGH. Chacun de ces outils ralentit d'autant qu'il renonce aux bénéfices du cache ; en pratique, l'astuce consiste donc à ne pas « tout équiper », mais à réserver leur usage aux écritures de données qu'on ne peut pas se permettre de perdre.
Quelle est la différence entre FILE_FLAG_WRITE_THROUGH et FILE_FLAG_NO_BUFFERING ?
WRITE_THROUGH signifie « écrire dans le cache, mais aussi sur le disque avant que l'opération ne se termine ». La lecture continue de bénéficier du cache : seul le délai de l'écriture différée est supprimé. NO_BUFFERING signifie que « la lecture et l'écriture ne passent pas par le cache système », et deviennent à chaque fois une E/S vers le périphérique disque (mais ce qui est court-circuité s'arrête au cache de Windows ; cela ne saute pas jusqu'au cache d'écriture interne au périphérique). En contrepartie, des contraintes strictes s'appliquent : la taille et le décalage de lecture/écriture doivent être des multiples entiers de la taille de secteur du volume, et l'adresse du tampon doit aussi être alignée sur une frontière de secteur physique. De plus, même avec NO_BUFFERING, les métadonnées du système de fichiers continuent d'être mises en cache, donc écrire de façon garantie même les métadonnées nécessite de combiner FlushFileBuffers ou WRITE_THROUGH. C'est typiquement utilisé par des logiciels qui gèrent eux-mêmes leurs tampons, comme les moteurs de base de données ; pour une application ordinaire, il est plus raisonnable d'envisager d'abord WRITE_THROUGH ou FlushFileBuffers.
Si le Gestionnaire des tâches affiche peu de mémoire disponible, est-ce à cause du cache de fichiers ?
C'est souvent le cas, et c'est même un comportement normal. Windows utilise activement la mémoire physique libre comme cache de fichiers ; copier un gros fichier ou effectuer de nombreuses lectures/écritures fait gonfler d'autant le cache, ce qui donne l'impression que l'utilisation de la mémoire augmente. Cependant, la plupart des pages utilisées par le cache sont d'un type relativement vite cédé dès qu'une application demande de la mémoire, ce qu'il faut distinguer d'un état où « la mémoire est réellement engloutie et insuffisante ». Pour diagnostiquer un manque de mémoire, il est plus pertinent de ne pas se fier uniquement à la capacité disponible apparente, mais d'examiner aussi des indicateurs comme la mémoire engagée (commit) ou la fréquence des hard faults.
En manipulant le même fichier à la fois via un fichier mappé en mémoire et via ReadFile/WriteFile, le contenu peut-il diverger ?
Non, pas avec une E/S classique en cache activé. Le cache de Windows lui-même est implémenté via un mappage de fichiers, et pour un même fichier local, la vue mappée et le cache partagent les mêmes données : une modification de l'un est donc visible depuis l'autre. Même lorsque plusieurs processus créent des vues à partir du même objet de mappage de fichiers, les données restent cohérentes. Cependant, les lectures/écritures sur un handle ouvert avec FILE_FLAG_NO_BUFFERING ne passent pas par le cache et se trouvent donc hors de cette cohérence. Par ailleurs, pour écrire de façon garantie les modifications d'une vue mappée sur le disque, FlushViewOfFile seul ne suffit pas — il n'écrit pas les métadonnées et n'attend pas non plus le cache matériel — il faut donc appeler FlushFileBuffers après FlushViewOfFile.

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