Conception de files d'attente IPC haute performance via les fichiers mappés en mémoire avec .NET

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.

Étiquettes: .NET Memory-Mapped-Files IPC CSharp BenchmarkDotNet

Publié le 9 juillet à 08h48