Introduction aux fichiers mappés en mémoire
Les fichiers mappés en mémoire (Memory-Mapped Files, ou MMF) constituent une technique système permettant de projeter le contenu d'un fichier directement dans l'espace d'adressage virtuel d'un processus. Cette approche facilite les opérations d'entrée/sortie hautement efficaces et permet le partage de données entre plusieurs processus distincts.
En s'appuyant sur des appels système comme mmap sous UNIX/Linux, les processus interagissent avec la zone mémoire projetée comme s'il s'agissait de RAM standard. Cela élimine les copies de données redondantes entre l'espace noyau et l'espace utilisateur, s'inscrivant ainsi dans le paradigme du Zero-Copy.
Le système d'exploitation n'initialise pas le chargement intégral du fichier en mémoire vive. Il procède par pages (généralement de 4 Ko) à la demande. Ce mécanisme de pagination rend les MMF extrêmement performants pour la gestion de volumes de données massifs, une architecture d'ailleurs exploitée par des moteurs de messagerie comme Apache Kafka ou RocketMQ.
Manipulation des MMF dans l'écosystème .NET
Initialisation et création
L'espace de noms System.IO.MemoryMappedFiles fournit l'API nécessaire. Bien que plusieurs méthodes existent, CreateFromFile offre le meilleur contrôle et une compatibilité multiplateforme optimale.
using System.IO.MemoryMappedFiles;
const string targetPath = "ipc_data_store.dat";
// Configuration du flux de fichier avec partage en lecture/écriture
using var fileHandle = new FileStream(
targetPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.ReadWrite);
// Projection en mémoire
using var memoryMap = MemoryMappedFile.CreateFromFile(
fileHandle,
mapName: "GlobalIPCRegion",
capacity: 2 * 1024 * 1024, // 2 Mo
access: MemoryMappedFileAccess.ReadWrite,
inheritability: HandleInheritability.None,
leaveOpen: true);
La capacité allouée n'impacte pas immédiatement la mémoire physique disponible, grâce au chargement paresseux (lazy loading) du système d'exploitation.
Accès direct via ViewAccessor (Structures)
Pour des données de taille fixe, CreateViewAccessor génère un MemoryMappedViewAccessor permettant de lire et écrire directement des types valeur (structs) sans sérialisation coûteuse.
using System.IO.MemoryMappedFiles;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct TelemetryPacket
{
public int SequenceId;
public double MetricValue;
public unsafe fixed byte TagName[64]; // 32 caractères Unicode
}
// ... (code de création du memoryMap) ...
int packetSize = Unsafe.SizeOf<TelemetryPacket>();
using var accessor = memoryMap.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite);
// Écriture
for (int idx = 0; idx < 5; idx++)
{
var packet = new TelemetryPacket { SequenceId = idx, MetricValue = idx * 1.5 };
string tag = $"Sensor_{idx}";
unsafe
{
fixed (char* srcPtr = tag)
{
Unsafe.CopyBlock(packet.TagName, srcPtr, (uint)(tag.Length * sizeof(char)));
}
}
accessor.Write(idx * packetSize, ref packet);
}
// Lecture
for (int idx = 0; idx < 5; idx++)
{
accessor.Read(idx * packetSize, out TelemetryPacket readPacket);
unsafe
{
string decodedTag = new string((sbyte*)readPacket.TagName, 0, 64, System.Text.Encoding.Unicode).TrimEnd('\0');
Console.WriteLine($"ID: {readPacket.SequenceId}, Tag: {decodedTag}");
}
}
Accès séquentiel via ViewStream (Objets complexes)
Pour des données de taille variable, CreateViewStream retourne un MemoryMappedViewStream. Contrairement à l'accessor, le stream maintient un curseur de position interne qui avance automatiquement après chaque opération.
using System.Buffers;
using System.IO.MemoryMappedFiles;
using System.Text.Json;
public class SystemEvent
{
public int EventCode { get; set; }
public string Description { get; set; } = string.Empty;
}
// ... (code de création du memoryMap) ...
using var viewStream = memoryMap.CreateViewStream(0, 0, MemoryMappedFileAccess.ReadWrite);
// Écriture avec sérialisation
for (int i = 0; i < 5; i++)
{
var evt = new SystemEvent { EventCode = i, Description = $"Event_{i}" };
byte[] jsonPayload = JsonSerializer.SerializeToUtf8Bytes(evt);
// Écriture de l'en-tête (taille) puis du contenu
viewStream.Write(BitConverter.GetBytes(jsonPayload.Length));
viewStream.Write(jsonPayload);
}
// Lecture
viewStream.Seek(0, SeekOrigin.Begin);
Span<byte> sizeBuffer = stackalloc byte[sizeof(int)];
for (int i = 0; i < 5; i++)
{
viewStream.ReadExactly(sizeBuffer);
int payloadLength = BitConverter.ToInt32(sizeBuffer);
byte[] rentedBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
Span<byte> payloadSpan = rentedBuffer.AsSpan(0, payloadLength);
viewStream.ReadExactly(payloadSpan);
var decodedEvt = JsonSerializer.Deserialize<SystemEvent>(payloadSpan);
Console.WriteLine($"Code: {decodedEvt.EventCode}, Desc: {decodedEvt.Description}");
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
Architecture de files d'attente IPC
En combinant ces primitives, il est possible de concevoir des files d'attente persistantes et ultra-rapides pour la communication inter-processus.
Approche 1 : File d'attente à structures fixes
Cette architecture est optimisée pour la vitesse brute. Les données sont divisées en segments (fichiers) contenant des messages de taille constante.
- Message : Composé du Payload (données) et d'un EndMarker.
- Segment : Fichier nommé selon l'offset de son premier message (ex:
000000000000000000000). La taille est ajustée pour contenir un nombre entier de messages.
Les offsets des producteurs et consommateurs sont stockés dans des fichiers séparés, eux-mêmes mappés en mémoire.
public interface IFixedProducer<T> where T : struct
{
long CurrentOffset { get; }
void Enqueue(ref T item);
}
public interface IFixedConsumer<T> where T : struct
{
long CurrentOffset { get; }
void ShiftOffset(long newOffset);
void Dequeue(out T item);
void Acknowledge();
}
Lorsqu'aucune donnée n'est disponible, le consommateur utilise une boucle active (spin-wait) configurable, avant de basculer en sommeil pour économiser le CPU.
Approche 2 : File d'attente à flux variables
Cette variante accepte n'importe quel type via des interfaces de sérialisation custom. Chaque message intègre un en-tête de 4 octets indiquant la taille du payload, suivi des données, et terminé par un marqueur de fin de 1 octet (0xFF).
Si l'espace restant dans un segment est insuffisant pour un nouveau message, un marqueur de fin de segment (0xEE) est écrit, et un nouveau fichier segment est créé.
public interface IStreamProducer
{
long CurrentOffset { get; }
void Enqueue(ReadOnlySpan<byte> rawData);
void Enqueue<T>(T payload, IPayloadSerializer<T> serializer);
}
public interface IStreamConsumer
{
long CurrentOffset { get; }
void ShiftOffset(long newOffset);
ReadOnlySpan<byte> Dequeue();
T Dequeue<T>(IPayloadDeserializer<T> deserializer);
void Acknowledge();
}
Pour minimiser les allocations, le consommateur s'appuie sur ArrayPool<T>. Le Span<byte> retourné par Dequeue() ne doit être consommé qu'avant l'appel à Acknowledge().
Analyse des performances (Benchmark)
Des tests comparatifs ont été menés avec BenchmarkDotNet sur 10 millions d'opérations, en évaluant les structures fixes, ainsi que les flux variables sérialisés via System.Text.Json et MessagePack.
Suite de test en écriture
[IterationCount(3)]
public class WritePerformanceSuite
{
private const int TotalItems = 10_000_000;
private const int SegmentCapacity = 512 * 1024 * 1024;
// ... Initialisation des queues et nettoyeurs ...
[Benchmark]
public void WriteFixedStruct()
{
var producer = _fixedQueue.Producer;
for (int i = 0; i < TotalItems; i++)
{
var pkt = new TelemetryPacket { SequenceId = i, MetricValue = i * 1.1 };
producer.Enqueue(ref pkt);
}
}
[Benchmark]
public void WriteJsonStream()
{
var producer = _streamQueue.Producer;
for (int i = 0; i < TotalItems; i++)
{
var evt = new SystemEvent { EventCode = i, Description = $"Evt_{i}" };
producer.Enqueue(evt, _jsonSerializer);
}
}
}
Résultats comparatifs
Les mesures révèlent une nette domination de l'approche par structures fixes en termes de latence et d'empreinte mémoire (GC), l'absence de sérialisation réduisant drastiquement les allocations.
Environnement macOS (SSD NVMe)
| Méthode | Moyenne | Erreur | StdDev | Gen0 | Mémoire Allouée |
|---|---|---|---|---|---|
| WriteFixedStruct | 714.9 ms | 1,239.1 ms | 67.92 ms | - | 2.65 KB |
| WriteJsonStream | 2,721.9 ms | 1,383.1 ms | 75.81 ms | 190000.00 | 1.55 GB |
| WriteMsgPackStream | 1,291.4 ms | 105.1 ms | 5.76 ms | 257000.00 | 2.10 GB |
Environnement Windows 11 (SSD NVMe)
| Méthode | Moyenne | Erreur | StdDev | Gen0 | Mémoire Allouée |
|---|---|---|---|---|---|
| WriteFixedStruct | 663.8 ms | 1,422.2 ms | 77.96 ms | - | 2.09 KB |
| WriteJsonStream | 2,445.3 ms | 1,929.8 ms | 105.78 ms | 95000.00 | 1.55 GB |
| WriteMsgPackStream | 1,298.8 ms | 381.4 ms | 20.90 ms | 128000.00 | 2.10 GB |
Environnement Windows 11 (HDD SATA)
| Méthode | Moyenne | Erreur | StdDev | Gen0 | Mémoire Allouée |
|---|---|---|---|---|---|
| WriteFixedStruct | 7.112 s | 54.15 s | 2.96 s | - | 2.01 KB |
| WriteJsonStream | 10.392 s | 52.45 s | 2.87 s | 95000.00 | 1.55 GB |
| WriteMsgPackStream | 5.949 s | 0.33 s | 0.01 s | 128000.00 | 2.10 GB |
L'analyse des disques montre que si le SSD accélère significativement les écritures brutes, les MMF conservent des débits parfaitement viables même sur des disques magnétiques traidtionnels, grâce à la gestion asynchrone de la pagination par le noyau.