Cet article effectue un tour d’horizon des différentes méthodes pour lire un fichier binaire en C# / .NET : les classes File et FileStream, puis une alternative moins connue, les MemoryMappedFile. Plus que de simplement lister les méthodes, on essaiera d’analyser leur performance et leurs profils d’utilisation dans le détail.
Pour les différents tests de cet article, on déroulera un algorithme très simple : on se contente de lire un fichier séquentiellement, 8 octets par 8 octets, en interprêtant ces derniers comme des entiers signés 64bits (le type long en C#), et on en calcule la somme.
Il ne sera pas question ici de scénarios plus complexes comme la sérialisation d’objets binaires, qui est généralement effectuée avec des librairies de plus haut niveau telles que l’excellente protobuf-net, ou l’affreux BinaryFormatter.
Ces test ont été faits avec le Framework .NET en version 4.7.2. On ne parlera pas ici de .NET Core, dont l’implémentaton est complètement différente : d’une part les performances restent proches (pas nécessairement meilleures), d’autre part il convient d’attendre la sortie officielle de .NET 5, qui devrait être plus mature.
Certains liens de cet article référencent https://referencesource.microsoft.com, très utile pour explorer le code source du framework .NET (4.8) lui-même. L’équivalent pour .NET Core est https://source.dot.net/
FileStream
La classe FileStream est une abstraction de flux (voir la classe Stream) sur un fichier, permettant d’en lire le contenu de manière séquentielle.
FileStream charge les données petit à petit via un buffer interne, ce qui permet de parcourir des fichiers de taille arbitraire en consommant une quantité fixe de RAM (quelques Ko).
FileStream.Read()
FileStream propose la méthode de lecture Read() qui lit un nombre arbitraire d’octets dans un tableau de type byte[].
Voici une implémentation typique :
using (FileStream fileStream = new FileStream(fileName, FileMode.Open))
{
// ouvre un fichier existant en lecture/écriture, qui restera ouvert jusqu'à la sortie du bloc using.
// le curseur de lecture interne (FileStream.Position) est placé en début de fichier : il vaut 0.
// le fichier de test fait 577 Mo.
byte[] buffer = new byte[8];
// alloue un buffer de 8 octets, réutilisé à chaque lecture pour gagner du temps
while (fileStream.Position < fileSize)
{
// fileSize est la taille du fichier d'entrée,
// arrondie au multiple de 8 inférieur pour éviter un dépassement : 590880176
fileStream.Read(buffer, 0, 8);
// lit les 8 prochains octets du fichier dans le byte[] fourni
// FileStream.Position est implicitement avancé de 8 à chaque appel
long integer = BitConverter.ToInt64(buffer, 0);
// interprête les 8 octets comme un entier 64 bits signé (long)
total += integer;
// on simule un calcul quelconque avec la donnée lue
// cela permet également de vérifier que la lecture est correcte
}
}
Cette méthode est relativement performante : environ 350 Mo/s (45 millions d’entiers lus / s).
Ma machine de test lors de l’écriture de cet article est un portable “gamer” équipé d’un Core i7-8750H (64bits) à 2.2 GHz équipé d’un SSD SATA de 250Go. Les performances sont indicatives et permettent uniquement de comparer les méthodes entre elles ; elles sont notamment affectées par la charge du système, le CPU utilisé, le périphérique de stockage … En particulier il est possible d’obtenir de bien meilleures performances avec un CPU desktop récent cadencé à 4GHz ou plus, et un SSD NVMe, bien plus rapide que les SSD SATA classiques.
La solution a été compilée en mode Release, avec les optimisations activées ; de plus le fichier lu (577 Mo) est déjà mis en RAM par le système au moment des tests : il n’est donc pas lu du disque, qui reste dans tous les cas bien plus lent que la mémoire vive.
Les performances mesurées dépendent beaucoup de la taille du fichier lu : il sera difficile d’observer des différences sur un fichier de quelques dizaines de Mo, lu en quelques millisecondes sur la plupart des systèmes récents.
Fonctionnement interne
La classe FileStream lit les données dans un buffer interne au fur et à mesure que l’on avance dans le fichier ; ce buffer fait 4Ko par défaut. Ce buffer est rempli par des appels successifs à la méthode privée ReadFileNative(), qui elle-même appelle l’API native Win32 ReadFile().
Dans cet exemple, FileStream fait donc :
- tous les 4Ko lus : un appel système et une allocation d’un byte[] interne
- à chaque appel à FileStream.Read() : une copie de données de ce byte[] interne vers le byte[] fourni
BinaryReader.Read()
Une méthode plus lisible est d’utiliser un BinaryReader, qui se construit à partir d’un FileStream et est la généralisation de la méthode précédente :
using (FileStream fileStream = new FileStream(fileName, FileMode.Open))
using (BinaryReader binaryReader = new BinaryReader(fileStream))
{
while (fileStream.Position < fileSize)
{
long integer = binaryReader.ReadInt64();
// voir aussi ReadInt16(), ReadInt32(), ReadString(), etc.
total += integer;
}
}
Les performances sont similaires sur ma machine : environ 350 Mo/s (45 millions d’entiers lus / s).
Fonctionnement interne
Chaque appel à ReadInt64() déclenche en interne un appel à Stream.Read() (BinaryReader peut être utilisé sur n’importe quelle implémentation de Stream), via un code assez similaire à l’exemple précédent ; sauf qu’il n’utilise pas BitConverter pour la conversion.
On notera que la méthode de lecture FillBuffer du BinaryReader effectue une boucle pour appeler Stream.Read(), contrairement à notre exemple : la raison à cela est que l’abstraction de Stream.Read() ne garantit pas que le nombre exact d’octets demandés sera lu, seulement qu’au maximum N octets seront renvoyés.
FileStream.Read(buffer, 0, 8) devrait donc normalement renvoyer entre 1 et 8 octets ; mais dans le cas de la lecture de fichiers .NET renvoie toujours le nombre d’octets demandé. Ce n’est pas nécessairement le cas si le Stream est basé sur un pipe, une socket réseau, etc. Pour plus de détails voir le commentaire dans FileStream.Read : https://referencesource.microsoft.com/#mscorlib/system/io/filestream.cs,f54c954df34d7a92
Augmenter la taille du buffer interne
Une amélioration possible lors de la lecture de gros fichiers est d’augmenter la taille du buffer de lecture du FileStream, ce qui réduit le nombre d’appels systèmes et d’allocations.
Cela se fait assez simplement en modifiant le paramètre bufferSize du constructeur :
using (FileStream fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096000))
// on demande un buffer interne de 4Mo au lieu des 4Ko par défaut
using (BinaryReader binaryReader = new BinaryReader(fileStream))
{
while (fileStream.Position < fileSize)
{
long integer = binaryReader.ReadInt64();
total += integer;
}
}
Le gain en performance variera énormément en fonction de la taille de buffer choisie et de la taille du fichier lue ; il convient de tester avec des cas précis pour trouver la taille idéale. Passé une certaine taille, on ne constate plus aucun gain, car le temps est perdu ailleurs (i.e. la lecture des entiers).
En utilisant un buffer de 4Mo j’ai pu atteindre environ 420 Mo/s (55 millions d’entiers lus / s).
Lire le fichier en entier
Une autre possibilité est de lire le fichier en une seule fois dans un unique byte[] : le nombre d’octets demandé étant largement supérieur au le buffer interne de 4Ko, FileStream lit en une seule fois toutes les données. Il suffit ensuite de parcourir le byte[] pour interprêter les données :
using (FileStream fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096000))
{
byte[] fileData = new byte[fileSize];
fileStream.Read(fileData, 0, fileData.Length);
// on lit la totalité du fichier dans fileData
for (int i = 0; i < fileData.Length; i += 8)
{
long integer = BitConverter.ToInt64(fileData, i);
// contrairement aux exemples précédents, on doit spécifier
// à chaque lecture l'offset avec le début des données ;
// l'emplacement de lecture n'avance plus implicitement
total += integer;
}
}
Le gain est très significatif : on passe à 1100 Mo/s (145 millions d’entiers lus / s).
Cela s’explique notamment par le fait qu’on n’utilise plus du tout le buffer interne de FileStream, qui n’a plus à copier les données vers notre propre byte[], 8 octets par 8 octets.
Cette méthode a cependant deux défauts majeurs :
- la consommation de RAM dépend de la taille du fichier : plus le fichier est plus volumineux, plus l’on consomme de RAM.
- elle est limitée à des fichiers de 2Go maximum :
- la CLR (machine virtuelle) de .NET autorise aux maximum 2Go par objet, donc le byte[] ne peut dépasser cette taille.
Conclusion
Les Streams en C# sont très versatiles, et peuvent être consommés par d’autres classes, comme par exemple le StreamReader, utile pour lire des fichiers textes ligne par ligne ; le GZipStream pour compresser ou décompresser des données, etc.
Les FileStream sont donc la méthode la plus générale pour lire des fichiers (binaires ou non), et fonctionnent dans toutes les situations.
File
Une méthode de lecture plus simple est la classe File, qui permet entre autres de lire directement tout le contenu d’un fichier en mémoire :
- ReadAllBytes() lit des données binaires dans un tableau d’octets (byte[]), qui peut ensuite être converti/interprêté dans d’autres types de données, par exemple avec la classe BitConverter.
- ReadAllText() et ReadAllLines() lisent des données texte dans une unique string, ou un tableau de string (une ligne par élément du tableau).
File.ReadAllBytes() + BitConverter
L’approche la plus directe est d’utiliser ReadAllBytes(), puis de convertir les données binaires avec la classe BitConverter :
byte[] fileData = File.ReadAllBytes(fileName);
for (int i = 0; i < fileSize; i += 8)
{
long integer = BitConverter.ToInt64(fileData, i);
total += integer;
}
Cette méthode est assez performante : environ 1100 Mo/s (145 millions d’entiers lus / s), soit la même performance que la méthode précédente.
En lisant son code source, on se rends compte que File est en fait une sorte de wrapper sur d’autres classes du Framework .NET :
- ReadAllBytes utilise un FileStream pour lire toutes les données en une seule fois : https://referencesource.microsoft.com/#mscorlib/system/io/file.cs,4b24188ee62795aa
- ReadAllLines utilise un StreamReader lui-même basé sur un FileStream : https://referencesource.microsoft.com/#mscorlib/system/io/file.cs,675b2259e8706c26
File.ReadAllBytes est donc équivalente à la méthode “Lire le fichier en entier” vue au-dessus, à tout point de vue : elle souffre donc de la même limitation des 2 Go maximum, et d’une consommation mémoire énorme comparée à un simple FileStream.
De plus, la classe File elle-même lèvera une exception si la taille du fichier dépasse Int32.MaxValue (~2 milliards d’octets / 2 Go) : https://referencesource.microsoft.com/#mscorlib/system/io/file.cs,4b24188ee62795aa
Arithmétique de pointeur unsafe
Une autre possibilité permettant d’éviter l’appel à BitConverter est d’utiliser du code unsafe pour récupérer un pointeur vers le byte[].
On peut ensuite utiliser l’arithmétique de pointeurs pour lire les différents entiers. Il faut pour cela compiler avec le flag unsafe et utiliser le mot-clé fixed :
byte[] fileData = File.ReadAllBytes(fileName);
// ou fileStream.Read(fileData, 0, fileSize);
fixed (byte* ptr = fileData)
// "fixed" empêche le GC de déplacer fileData ailleurs dans la mémoire
// (cela peut notamment arriver lors d'une collection GC)
// récupère en même temps un pointeur vers ce tableau
{
for (long i = 0; i < fileSize; i += 8)
{
long integer = *(long*)(ptr + i);
// on ajoute i au pointeur (qui part toujours du début du tableau) pour se décaler au bon endroit
// on cast ensuite ce pointeur de byte* vers long*
// on récupère enfin la valeur au bout de ce pointeur via l'opérateur *
total += integer;
}
}
Cette méthode est nettement plus rapide : environ 1700 Mo/s (220 millions d’entiers lus / s).
On gagne largement en vitesse car on évite les vérifications supplémentaires effectuées par BitConverter.ToInt64, notamment celle sur la représentation des entiers ou endianness : notre processeur est little-endian et ne lira pas correctement d’éventuels entiers stockés en big-endian.
On évite également un appel de fonction potentiellement “coûteux”, dans le contexte de centaines de millions d’appels par seconde : chaque appel à BitConverter ne prends individuellement que quelques nanosecondes.
En mesurant plus finement, on se rends compte que dans ce code environ 90% du temps passé est dans File.ReadAllBytes() ; la boucle de décodage des entiers ne peut donc plus être accélérée significativement. A ce stade, on peut considérer que le code est I/O bound : le code passe la majeure partie de son temps à attendre les données.
Conclusion
En terme d’implémentation, File en .NET n’est qu’une simplification de FileStream, utile pour certains cas simples. On peut en revanche gagner beaucoup de performance en accédant directement aux données via du code unsafe.
Memory Map
Une alternative aux File et FileStream est d’utiliser les memory map, qui se basent sur un mécanisme plus bas niveau implémenté dans la plupart des OS récents, notamment pour charger les fichiers du disque vers la RAM.
Une memory map représente une zone mémoire nommée, qui peut être de deux types :
-
soit être un espace mémoire indépendant de l’application, pouvant être accédé par plusieurs processus en parallèle : c’est le principe de la Shared Memory. Cela permet notamment de communiquer entre plusieurs processus ou de mettre en commun des données.
-
soit être liée au contenu d’un fichier présent sur le disque, ce qui permet ensuite d’en lire ou modifier une partie directement à partir d’une adresse mémoire.
Une memory map basée sur un fichier crée simplement une association entre l’adresse physique du contenu et une adresse mémoire manipulable par l’application ; lorsque l’application souhaite accéder à une partie du fichier, l’OS se charge de faire la correspondance avec le fichier physique et d’en charger le contenu en RAM via un système de pages.
Les données sont chargées du disque vers la RAM page par page, en arrière-plan : cela n’est pas visible de l’application, qui n’a pas besoin de faire d’appel explicite au système comme c’est le cas avec les FileStream.
La mémoire RAM occupée par ces pages est de la mémoire native, non visible de l’application ni du GC : elle n’est donc jamais “collectée” par le GC, contrairement aux byte[] alloués par File et FileStream. En revanche le système peut décider de désallouer certaines pages de la RAM à n’importe quel moment, pour d’autres besoins : cela sera invisible pour l’application.
Les memory map sont accessibles en .NET via la classe MemoryMappedFile, qui pour le cas des fichiers proposent deux types de lecture :
-
MemoryMappedViewStream propose une abstraction de Stream sur la memory map, et s’utilise donc de la même manière qu’un FileStream.
-
MemoryMappedViewAccessor permet de créer une “vue” sur une portion ou la totalité du fichier, puis d’accéder à n’importe quel emplacement en spécifiant un offset en octets à partir du début de la vue. C’est l’implémentation native originale.
On peut voir la quantité de RAM allouée par le système à des memory map via un outil comme RAMMap.
MemoryMappedViewStream
Une première idée est de simplement remplacer le FileStream par un MemoryMappedViewStream :
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
// crée une memory map basée sur le fichier 'fileName'
using (MemoryMappedViewStream mmapViewStream = mmapFile.CreateViewStream())
// crée un flux sur cette memory map, qui va permettre de parcourir la totalité du fichier
using (BinaryReader binaryReader = new BinaryReader(mmapViewStream))
// il est possible d'utiliser un BinaryReader ou n'importe quel autre consommateur de Stream
{
for (long i = 0; i < fileSize; i += 8)
{
long integer = binaryReader.ReadInt64();
total += integer;
}
}
// le bloc using() ou un appel à Dispose() est très important :
// sans lui, le système conserve en RAM toutes les données lues par cette memory map,
// potentiellement tout le fichier !
La performance est très mauvaise : environ 96 Mo/s (12 millions d’entiers lus / s).
SafeBuffer.Read()
Je n’ai pas trouvé de moyen plus efficace d’utiliser MemoryMappedViewStream, on bascule donc sur l’autre classe, MemoryMappedViewAccessor. Une instance de cette classe s’obtient simplement à partir d’un MemoryMappedFile.
SafeBuffer.Read() est une méthode qui permet de lire n’importe quelle donnée scalaire en passant un offset en paramètre : cet offset correspond au décalage en octets avec le début de la vue (ici mappée sur le début du fichier).
On peut obtenir une instance de cette classe via la propriété SafeMemoryMappedViewHandle d’un MemoryMappedViewAccessor.
Par exemple Read<long>(0)
va lire les 8 premiers octets du fichier (un long occupe 8 octets), Read<long>(10)
va lire 8 octets à partir du 10e octet du fichier, etc.
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
// crée une vue de capacité '0', c'est à dire mappée sur la totalité du fichier
using (MemoryMappedViewAccessor mmapViewAccessor = mmapFile.CreateViewAccessor())
{
for (long i = 0; i < fileSize; i += 8)
{
long integer = mmapViewAccessor.SafeMemoryMappedViewHandle.Read<long>(i);
// on lit les 8 octets à partir de l'offset i, et on les interprètent comme un long
// en arrière-plan, l'OS charge du disque les pages correspondantes aux offsets demandés
total += integer;
}
}
Cette méthode n’est malheureusement pas bien plus rapide : environ 112 Mo/s (14 millions d’entiers lus / s).
MemoryMappedViewAccessor.Read()
L’objet MemoryMappedViewAccessor dont on a besoin pour accéder au contenu du fichier contient lui-même des fonctions de lecture, notamment ReadInt16(), ReadInt32(), ReadInt64() … :
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
using (MemoryMappedViewAccessor mmapViewAccessor = mmapFile.CreateViewAccessor())
{
for (long i = 0; i < fileSize; i += 8)
{
long integer = mmapViewAccessor.ReadInt64(i);
total += integer;
}
}
Plus lisible, cette méthode est légèrement plus rapide : environ 152 Mo/s (19 millions d’entiers lus / s).
Marshal.Copy()
Ces techniques, bien que “sûres” et portables, ne sont pas très intéressantes au vu de la performance.
Pour aller plus loin, il est nécessaire d’utiliser directement le pointeur natif vers la memory map fourni par l’OS : ceci s’effectue via la fonction AcquirePointer() du SafeMemoryMappedViewHandle.
Cette fonction utilisant des pointeurs, nous avons donc besoin à partir d’ici de compiler avec le flag unsafe.
Marshal est une classe utilitaire bas niveau spécialisée dans la manipulation de la mémoire non managée, elle permet notamment de lire ou de copier des données à partir de pointeurs. Elle ne manipule pas de pointeurs bruts, mais plutôt une structure intermédiaire IntPtr.
Une des méthodes les plus génériques est Marshal.Copy(), qui permet de copier des données d’un pointeur brut/non-managé vers de la mémoire managée (byte[]), et vice-versa.
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
using (MemoryMappedViewAccessor mmapViewAccessor = mmapFile.CreateViewAccessor())
{
byte* mmapPtr = null; // initialize un pointeur à NULL
mmapViewAccessor.SafeMemoryMappedViewHandle.AcquirePointer(ref mmapPtr);
// copie l'adresse mémoire de la memory map dans mmapPtr
try
{
byte[] buffer = new byte[8];
for (ulong i = 0; i < fileSize; i += 8)
{
Marshal.Copy(new IntPtr(mmapPtr + i), buffer, 0, 8);
// on copie les 8 octets localisés à l'offset i dans le buffer
long integer = BitConverter.ToInt64(buffer, 0);
// on interprète les données du buffer vers un long
total += integer;
}
}
finally
{
mmapViewAccessor.SafeMemoryMappedViewHandle.ReleasePointer();
// il faut s'assurer d'appeler ReleasePointer() une fois le pointeur utilisé ;
// le bloc try/finally nous assure que ce code sera appelé même en cas d'exception non gérée
}
}
Marshal.Copy permet d’accélérer significativement les performances : environ 450 Mo/s (60 millions d’entiers lus / s).
On évite complètement les allocations de byte[] à chaque lecture d’un entier, de même que les appels implicites de FileStream à l’API Windows ReadFile() tous les 4Ko. En revanche on continue à copier les données (8 octets par 8 octets) de la memory map vers un byte[] managé.
Les données de la memory map elle-même ne sont pas managées ; le Garbage Collector ne “voit” pas cette mémoire, et elle ne compte pas dans la mémoire totale utilisée par le processus. Techniquement les pages de données de la memory map “n’appartiennent” pas à l’application, et le système peut récupérer cette mémoire à n’importe quel moment.
En principe, l’OS doit supprimer certaines pages mappées via une memory map lorsqu’il commence à être à court de RAM ; cependant ce n’est pas le comportement observé sous Windows : au fil des accès la RAM augmente indéfiniment sans être “libérée” par le système de fichier, jusqu’à mettre une pression d’écriture importante sur le fichier swap. Il faut alors “fermer” l’instance de MemoryMappedFile via un appel à Dispose() pour forcer l’OS à libérer toutes les pages actuellement mappées en RAM.
Marshal.ReadInt64()
Une alternative encore plus rapide est d’utiliser Marshal.ReadInt64(), qui pour le cas des entiers ne nécessite pas de copier les données dans un byte[] au préalable : la méthode est capable d’interprêter à la volée les données comme un entier sur 8 octets :
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
using (MemoryMappedViewAccessor mmapViewAccessor = mmapFile.CreateViewAccessor())
{
byte* mmapPtr = null;
mmapViewAccessor.SafeMemoryMappedViewHandle.AcquirePointer(ref mmapPtr);
try
{
for (ulong i = 0; i < fileSize; i += 8)
{
long integer = Marshal.ReadInt64(new IntPtr(mmapPtr + i));
total += integer;
}
}
finally
{
mmapViewAccessor.SafeMemoryMappedViewHandle.ReleasePointer();
}
}
On atteint ici des débits très élevés : environ 1400 Mo/s (185 millions d’entiers lus / s).
La performance est assez proche de la méthode unsafe utilisant fixed avec un FileStream, vue plus haut. En revanche il y a trois différences importantes :
- Cette méthode n’est pas limitée à des fichiers de 2 Go.
- La méthode utilisant FileStream alloue via le GC un byte[] de 160 Mo (la taille du fichier) ; avec la memory map l’application ne fait aucune allocation, c’est le système qui réserve ces 160 Mo (non liés à l’application), page par page, au fur et à mesure de la lecture.
- Cette méthode permet de ne lire qu’une portion arbitraire du fichier, et ne consommera en RAM que ce qui est lu ; contrairement à la méthode FileStream qui consomme toute la RAM dès le départ.
Pointeur natif
Enfin, en regardant le code de Marshal.ReadInt64(), on peut en extraire le code de conversion pour éliminer le if non nécessaire lorsque les données sont dans la même endianness ; cela revient à caster le pointeur vers le type souhaité et en récupérer la valeur :
using (MemoryMappedFile mmapFile = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.ReadWrite))
using (MemoryMappedViewAccessor mmapViewAccessor = mmapFile.CreateViewAccessor())
{
byte* mmapPtr = null;
mmapViewAccessor.SafeMemoryMappedViewHandle.AcquirePointer(ref mmapPtr);
try
{
for (long i = 0; i < fileSize; i += 8)
{
long integer = *(long*)(mmapPtr + i);
// on ajoute i au pointeur mmapPtr pour se décaler au bon endroit
// on cast ensuite ce pointeur de byte* vers long*
// on récupère enfin la valeur au bout de ce pointeur via l'opérateur *
total += integer;
}
}
finally
{
mmapViewAccessor.SafeMemoryMappedViewHandle.ReleasePointer();
}
}
On atteint alors les 2000 Mo/s (260 millions d’entiers lu / s), au moins 5 fois plus rapide que la lecture FileStream classique.
Le code devient très similaire à du code natif en terme de performance et de lisibilité.
Conclusion
Plus qu’une “alternative” réelle aux FileStream, coder avec des memory map représente un changement de paradigme dans la manière de manipuler des fichiers, qui sera abordé plus en détail dans un autre article.
Leur utilisation n’est pas recommandée dans la majorité des cas, notamment car elle nécessite l’ajout de code unsafe et reste globalement plus dangereuse à manipuler.
En revanche, dans certains cas bien déterminés elle peut représenter un gain de performance très important.