Pièges de la mémoire partagée et bonnes pratiques concrètes

· Mis à jour le: · · Mémoire partagée, IPC, Concurrence, C++, C#, Développement Windows

Trames d’image, résultats d’inspection, journaux chronologiques, données de carnet d’ordres, énormes buffers. Quand on veut échanger de grandes quantités de données à faible latence au sein d’une même machine, la mémoire partagée est plutôt séduisante.

Le hic, c’est qu’elle s’approche de vous avec le visage de « l’IPC rapide ». En réalité, la mémoire partagée est « une IPC qui réduit les copies, mais qui vous renvoie la responsabilité de la cohérence ».

  • Rapide
  • Flexible
  • Mais le protocole, c’est vous qui le construisez
  • Et quand ça casse, les symptômes sont spectaculaires

C’est à peu près ce lot de quatre éléments qu’on obtient.

Dans cet article, en gardant à l’esprit le file mapping de Windows et shm_open / mmap sous POSIX, nous passons en revue les points de blocage rencontrés en pratique avec la mémoire partagée, ainsi que la conception qui permet de réduire le taux d’incidents. Que ce soit en C/C++ ou avec MemoryMappedFile en C#, l’essentiel reste quasiment identique.1

1. D’abord la conclusion (en une phrase)

Pour formuler les choses de façon assez brute, mais utile en pratique :

  • La mémoire partagée est un mécanisme qui présente la même suite d’octets à plusieurs processus ; ce n’est pas la synchronisation elle-même23
  • Ce qui est rapide, c’est l’échange de données volumineuses au sein d’une même machine. Pour de simples petits messages de contrôle, pipe / socket / named pipe / queue est très souvent plus simple
  • Avec la mémoire partagée, être visible et pouvoir être lu en toute sécurité sont deux problèmes distincts
  • Ne bâtissez pas votre conception sur volatile. L’atomicité, l’ordre et l’attente doivent être traités séparément45
  • Placer des pointeurs bruts, des HANDLE, des descripteurs de fichiers, std::string, std::vector, std::mutex directement en mémoire partagée finit presque toujours par vous faire pleurer
  • Les données placées en mémoire partagée sont plus sûres si elles tendent vers des entiers de largeur fixe + une disposition explicite + un en-tête versionné
  • Le simple fait de placer magic / version / size / state / generation / heartbeat dans un en-tête initial change considérablement la facilité d’investigation en cas d’incident
  • Les difficultés de la mémoire partagée ne concernent pas la vitesse mais l’initialisation, la durée de vie, la récupération, les permissions et l’ABI
  • Sous Windows, le squelette est CreateFileMapping / OpenFileMapping / MapViewOfFile ; sous POSIX, c’est shm_open / ftruncate / mmap63
  • Le point de départ le moins sujet aux incidents est un ring buffer SPSC (single-producer single-consumer) ou un double buffer

En résumé : la mémoire partagée est rapide, mais si on l’utilise sans précaution, on attrape la maladie du « on dirait que ça se synchronise tout seul ». Éviter cela est le premier combat.

2. Ce que la mémoire partagée partage — et ce qu’elle ne partage pas

Pour le dire simplement, la mémoire partagée est un mécanisme qui mappe les mêmes pages physiques dans les espaces d’adressage virtuel de plusieurs processus. Windows utilise un objet file mapping et des vues (views) ; POSIX fait un mmap d’un objet de mémoire partagée.273

Deux points comptent ici.

  1. Ce qui est partagé, c’est le contenu en octets, pas les adresses virtuelles elles-mêmes
  2. Être cohérent et être synchronisé sont deux choses différentes

La documentation Windows indique également que les vues créées à partir du même objet file mapping sont cohérentes à un instant donné. Mais cela ne veut pas dire que le lecteur peut toujours lire un enregistrement cohérent et entièrement à jour.8

Par exemple, même si le writer a l’intention d’écrire

  • length
  • puis payload
  • puis le ready flag

dans cet ordre, un reader qui lit sans aucune synchronisation peut voir le nouveau length combiné à l’ancien payload. La mémoire partagée ne corrige pas cela automatiquement pour vous.

Donc ce que partage la mémoire partagée, ce sont des octets. Ce qu’elle ne partage pas, ce sont le sens, l’ordre, la notification d’achèvement et la politique de récupération. Tout cela, c’est à vous de le concevoir.

3. Où la mémoire partagée est adaptée — et où elle ne l’est pas

Situation Adéquation Raison
Faire transiter de grandes trames ou buffers au sein d’une même machine Adapté Facile de réduire le nombre de copies
Valeurs de capteurs à haute fréquence, images, audio, données de carnet d’ordres, etc. Adapté Facile de viser une faible latence et un débit élevé
N’échanger que de petites commandes et réponses Peu adapté Le coût de synchronisation pour le contrôle est relativement lourd
Communiquer avec d’autres machines Non adapté La mémoire partagée suppose fondamentalement un hôte unique
Coexistence à long terme de langages et de versions différents Difficile Nécessite une conception d’ABI et de versionnement
Persistance également nécessaire Selon l’objectif Les mappings adossés à un fichier (file-backed) sont viables, mais les responsabilités de persistance et d’IPC ont tendance à se mélanger

En pratique, la séparation contrôle par messagerie, données par mémoire partagée est très solide. Par exemple :

  • Le processus UI notifie le processus worker « utilise la prochaine trame » via un event / pipe / socket
  • La trame elle-même réside en mémoire partagée

Cette configuration a tendance à être paisible.

4. Les quatre choses à décider en premier

Lors de la conception d’une mémoire partagée, les quatre premières choses à décider sont les suivantes.

4.1 Séparer le plan de contrôle (control plane) et le plan de données (data plane)

Décidez à l’avance ce qui va dans la mémoire partagée.

  • data plane : images, audio, séquences d’enregistrements, données en masse
  • control plane : démarrage, arrêt, erreurs, reconnexion, réinitialisation, notifications

Le simple fait de séparer ces deux éléments simplifie considérablement le design côté mémoire partagée.

4.2 Restreindre le modèle de concurrence

  • SPSC : 1 producteur / 1 consommateur
  • MPSC : plusieurs writers / 1 consommateur
  • SPMC : 1 writer / plusieurs readers
  • MPMC : plusieurs writers / plusieurs readers

La difficulté augmente à peu près dans cet ordre. Se lancer directement en MPMC est plutôt téméraire. En général, les spectres de l’ordre mémoire finissent par apparaître.

4.3 Décider du propriétaire et de la durée de vie

  • Qui crée
  • Qui initialise
  • Qui supprime
  • Qui restaure quand un participant tombe en cours de route

Si ce point reste flou, l’air devient trouble à chaque ordre de démarrage ou redémarrage.

4.4 Décider de l’ABI et du versionnement

  • Disposition (layout)
  • Taille des types
  • Alignement
  • Zones réservées
  • Version / feature flags
  • Existence d’une compatibilité

La mémoire partagée n’est pas une question d’API mais d’ABI (interface binaire). Traiter cela à la légère mène au type d’incident désagréable où la compatibilité source existe, mais où seule l’exécution finit par casser.

5. Pièges courants

5.1 Ne pas synchroniser

C’est le plus fréquent.

« On regarde la même mémoire, donc si j’écris, ça doit pouvoir se lire. »

Ça peut effectivement se lire. Mais cela ne veut pas dire que ce sera lu au bon moment, dans la bonne unité, dans le bon ordre.

Que ce soit sous Windows ou POSIX, l’accès à la mémoire partagée est censé être combiné à un autre moyen de synchronisation. La documentation Windows précise elle aussi que l’accès à une vue partagée doit être coordonné via des mutex / sémaphores / events.2 La documentation POSIX indique elle aussi que l’accès à la mémoire partagée nécessite une synchronisation.9

5.2 Essayer de s’en sortir avec volatile

volatile n’est pas un sortilège qui sauve la conception d’une mémoire partagée. Au minimum, l’atomicité et l’exclusion mutuelle sont des problèmes distincts.45

Par exemple, une conception qui place volatile bool ready; et le surveille par busy loop :

  • gaspille le CPU
  • rend floue la garantie d’ordre entre le payload et le flag ready
  • n’est pas portable
  • capte facilement des états intermédiaires

Et il n’en sort à peu près rien de bon.

De plus, WaitOnAddress sous Windows est destiné aux threads au sein d’un même processus. Il est plus prudent de ne pas le considérer comme un mécanisme d’attente inter-processus.10

5.3 Laisser lire des états intermédiaires

Quand la mémoire partagée casse, l’allure du problème est assez banale.

  • Seul l’en-tête est nouveau
  • Seul le payload est ancien
  • Seule la longueur est mise à jour
  • La paire de deux champs est incohérente

Si vous vous contentez de mettre à jour atomiquement un seul scalaire, les choses restent relativement simples, mais si vous publiez un enregistrement composé de plusieurs champs, il faut une procédure de commit.

Typiquement, l’une des solutions suivantes :

  • Protéger l’ensemble avec un mutex
  • Utiliser un double buffer et basculer en dernier « l’index du buffer actuellement valide »
  • Utiliser un ring buffer avec un state / sequence par slot
  • Pour 1 writer / plusieurs readers, prendre des snapshots via un compteur de séquence

Même « poser le flag ready en dernier » reste une conception immature tant que vous n’avez pas décidé avec quel ordre mémoire ce flag est écrit et lu. Dans la mémoire partagée, le moment de publication est lui-même le protocole.

5.4 Placer des pointeurs ou des objets complexes tels quels

C’est un autre schéma fréquent.

  • Pointeurs bruts
  • HANDLE
  • Descripteurs de fichiers
  • std::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_SECTION

Placer ces éléments directement en mémoire partagée et essayer de les utiliser depuis un autre processus. Un petit enfer commence en général.

La raison est simple : les adresses virtuelles et les ressources propres à un processus n’ont de sens que dans le contexte de ce processus. Pour les vues Windows aussi, mapper le même mapping dans un autre processus ne garantit pas que les adresses virtuelles coïncident.711

Donc, si des références sont nécessaires, l’approche de base consiste à les conserver comme des offsets depuis l’adresse de base.

typedef struct ShmRef {
    uint64_t offset;   // position relative au début du segment
    uint32_t length;
    uint32_t kind;
} ShmRef;

Ainsi, chaque processus peut résoudre base + offset vers sa propre adresse.

5.5 Casser l’ABI

La mémoire partagée est une promesse binaire, pas du code source. Autrement dit, chacune des différences suivantes compte.

  • Taille de int / long
  • Représentation de bool
  • Type sous-jacent des enum
  • Taille de wchar_t
  • Différences 32 bits / 64 bits
  • #pragma pack
  • Différences de compilateur / de langage
  • Alignement / padding
  • Little-endian / big-endian

Au sein d’un même hôte, l’endianness est en général cohérente, mais il suffit d’ajouter un support ARM64 ou une toolchain mixte pour que des décalages parfaitement ordinaires apparaissent.

C’est pourquoi, pour les structures placées en mémoire partagée, nous recommandons fortement :

  • Des entiers de largeur fixe comme uint32_t / uint64_t
  • Des zones de padding / reserved explicites
  • Un en-tête avec version, header_size, record_size, total_size
  • static_assert(sizeof(...)) si nécessaire
  • Aucun objet non trivial

5.6 Races d’initialisation

La mémoire partagée casse facilement sur l’hypothèse que « celui qui l’a créée l’a forcément initialisée ».

Sous Windows, si CreateFileMapping tombe sur un nom existant, elle retourne l’objet existant, et GetLastError() indique ERROR_ALREADY_EXISTS. Les pages initiales d’un mapping adossé au pagefile démarrent à zéro.8 Sous POSIX, un nouvel objet de mémoire partagée commence avec une longueur de 0, et obtient sa taille via ftruncate. Les octets nouvellement alloués sont initialisés à zéro. La création avec O_CREAT | O_EXCL est atomique.3

Sans connaître cette différence, si vous :

  • l’utilisez immédiatement après l’ouverture
  • n’avez pas de flag d’initialisation terminée
  • laissez les participants s’initialiser en même temps
  • ne vérifiez pas les incohérences de version

alors ça casse selon l’ordre de démarrage.

Au minimum, il vaut mieux placer ces états dans l’en-tête initial :

  • INITIALIZING
  • READY
  • BROKEN

Et seul le créateur initialise ; les rejoignants (joiners) attendent READY. Cette seule discipline apaise considérablement les choses.

5.7 Ne pas penser à la récupération après crash

Que faire si le writer meurt en cours de mise à jour des données partagées ? Livrer en production avec ce point non défini, et le visage de tout le monde devient soudain grave lors d’un incident.

Un mutex Windows devient abandoned quand le thread propriétaire se termine sans le libérer, et les waiters reçoivent WAIT_ABANDONED. Cela signifie que la ressource partagée peut être dans un état indéterminé.12 Avec les mutex robustes POSIX aussi, quand le propriétaire meurt, EOWNERDEAD est retourné, et après avoir réparé l’état, on appelle pthread_mutex_consistent().1314

L’important, ici, c’est de ne pas « continuer tant bien que mal ». La récupération nécessite au moins l’un des éléments suivants :

  • Un numéro de génération
  • La dernière séquence commit avec succès
  • Un heartbeat
  • Un flag dirty / clean
  • Un commit en deux phases de type journal
  • Une procédure de réinitialisation complète en cas de corruption

5.8 False sharing et contention de ligne de cache

On dit souvent que la mémoire partagée est rapide. Mais si des compteurs très sollicités (hot) sont tassés dans la même ligne de cache, celle-ci fait des allers-retours entre CPU, et les performances se dégradent allègrement.

L’exemple classique :

  • Le producer met à jour write_index
  • Le consumer met à jour read_index
  • Les deux se trouvent sur la même ligne de cache

Dans ce cas :

  • Séparer les champs hot sur des lignes de cache distinctes
  • Séparer les champs mis à jour fréquemment de ceux mis à jour rarement
  • Viser un writer par ligne de cache

Cela seul change déjà beaucoup les choses. On entend souvent parler d’alignement sur 64 octets ; considérez cela comme « 64 octets est une valeur courante sur de nombreux CPU, pas une loi absolue ».

5.9 Prendre à la légère les noms, permissions et la sécurité

La mémoire partagée nommée est pratique, mais des noms et permissions traités à la légère provoquent des incidents.

Sous Windows :

  • Il existe les espaces de noms Global\ et Local\
  • Créer un file mapping Global\ depuis l’extérieur de la session 0 nécessite SeCreateGlobalPrivilege
  • Les noms d’objets partagent un espace de noms avec les events / sémaphores / mutex / waitable timers / jobs

Voilà ses particularités.1582

Autrement dit :

  • Vous le nommez "Global\\MyApp" en pensant que le service et l’application de bureau pourront le partager
  • Mais ça échoue sur les permissions
  • Et par-dessus le marché, un mutex portant le même nom a été créé avant, et vous obtenez ERROR_INVALID_HANDLE

Le genre de boue bien typique de Windows qui remonte à la surface.

Côté POSIX aussi, traiter à la légère le mode ou l’umask de shm_open rend les choses visibles de façon inutilement large, ou au contraire impossibles à ouvrir.3

La mémoire partagée n’est pas « juste de la mémoire, donc sûre ». Depuis n’importe quel processus disposant de la permission de lecture, elle est plutôt visible sans détour. Si vous y placez des informations confidentielles, il faut y réfléchir dans le même contexte que la mémoire ordinaire : paging / swap / dumps / permissions.

5.10 Redimensionner et faire évoluer à la légère

« J’aimerais l’agrandir un peu plus tard » est une demande plutôt dangereuse pour la mémoire partagée.

  • Un objet mapping Windows a une taille fixée à sa création8
  • Sous POSIX aussi, sans garder ftruncate et mmap cohérents, la longueur mappée par les participants finit par ne plus correspondre316

En pratique, il est plus sûr de rendre la taille immuable au sein d’une génération. Si une extension est nécessaire :

  1. Créer un segment avec une nouvelle version / un nouveau nom / une nouvelle génération
  2. Basculer les participants
  3. Fermer l’ancien segment

Cela réduit le taux d’incidents.

5.11 Faire tout passer, y compris les notifications, par la mémoire partagée

Un schéma courant :

  • Écrire ready = 1 dans la mémoire partagée
  • L’autre côté fait while (!ready) Sleep(1);

Ça marche au début. Mais ensuite, cela revient sous la forme de :

  • CPU gaspillé
  • Latence qui varie à cause de Sleep(1)
  • Mises à jour manquées difficiles à remarquer
  • Timeouts et notifications d’arrêt difficiles à écrire proprement

Il vaut mieux orienter la mémoire partagée vers le côté données, et déporter la notification vers des primitives que l’on peut attendre.

  • Windows : event / sémaphore / mutex / named pipe, etc.217
  • POSIX : sémaphores / mutex process-shared + condvar, etc.1819

5.12 Penser « avec ça, je peux aussi partager avec d’autres machines »

Il y a un moment où l’on est tenté de penser : si j’utilise un mapping adossé à un fichier et que je mappe un fichier partagé via le réseau, je pourrai peut-être aussi faire de la mémoire partagée entre machines.

C’est dangereux.

La documentation de CreateFileMapping sous Windows indique elle aussi que la cohérence n’est pas garantie pour les fichiers distants. Si deux machines mappent la même page en écriture, chacune ne voit que ses propres écritures, et rien n’est fusionné lors de la mise à jour du disque.8

La mémoire partagée est fondamentalement un mécanisme à hôte unique. Pour franchir plusieurs machines, choisir directement socket / RPC / message broker préserve bien mieux votre santé mentale.

6. Bonnes pratiques

6.1 Séparer le plan de contrôle et le plan de données

Placez uniquement les données en masse dans la mémoire partagée ; déportez notifications et transitions d’état vers un canal séparé.

  • Mémoire partagée : frame, sample, batch, snapshot
  • Event / sémaphore / pipe / socket : ready, consumed, stop, error, reconnect

Cette séparation améliore la clarté de la conception, avant même d’améliorer la performance.

6.2 Placer un en-tête fixe en tête

Au minimum, nous recommandons fortement un en-tête initial comme celui-ci.

typedef struct SharedHeader {
    uint32_t magic;
    uint16_t abi_version;
    uint16_t header_size;

    uint32_t state;          // 0=initializing, 1=ready, 2=broken
    uint32_t flags;

    uint64_t total_size;
    uint64_t generation;
    uint64_t heartbeat_ns;

    uint64_t payload_offset;
    uint64_t payload_size;

    uint64_t write_seq;
    uint64_t read_seq;

    uint8_t  reserved[64];
} SharedHeader;

Les points clés sont :

  • magic rejette les segments étrangers ou non initialisés
  • abi_version et header_size rejettent les différences de disposition (layout)
  • state rejette les états en cours d’initialisation
  • generation détecte les recréations
  • heartbeat surveille la vivacité
  • reserved laisse une porte de sortie pour les extensions futures

Ce qui est pénible avec la mémoire partagée, c’est qu’« on voit mal ce qui se passe ». C’est précisément pour cela qu’on lui donne dès le départ des métadonnées d’observabilité.

6.3 Utiliser des références par offset

Conservez les références sous forme d’offsets, pas de pointeurs.

  • Résoudre via base + offset
  • Ajouter une vérification de plage sur offset + length
  • Définir une valeur sentinelle pour les valeurs invalides

Cela seul réduit considérablement les incidents liés à une inadéquation d’adresse.

6.4 Restreindre le modèle de concurrence

La mémoire partagée devient brutalement plus difficile à mesure que les writers se multiplient. Donc, au départ, l’une de ces deux options est solide :

  • Ring buffer SPSC
  • Snapshot 1 writer / plusieurs readers

S’il faut plusieurs writers, les choses se passent en général mieux en réduisant le nombre de points de responsabilité de la cohérence, par exemple :

  • Seul l’enqueue est lock-free / atomique
  • Les mises à jour réelles des données sont concentrées sur un seul consumer

6.5 Expliciter le protocole de commit

Une conception où vous ne pouvez pas expliquer en mots « à partir de quel instant peut-on lire en toute sécurité » est dangereuse.

Pour un double buffer, par exemple, on définit le rituel de publication :

  1. Écrire dans le buffer non publié
  2. Finaliser le checksum et la longueur
  3. Basculer l’index du buffer actif avec une sémantique release
  4. Le reader lit l’index actif avec une sémantique acquire
  5. Après lecture, vérifier que l’index n’a pas changé

6.6 Fixer la taille par génération

Plutôt que de redimensionner en place, découper en générations comme

  • name = MyShm.v3
  • abi_version = 3
  • generation = 42

est plus facile à maintenir.

La mémoire partagée ne fait pas de « vérification de type à l’appel » comme le ferait une API. C’est pourquoi il est si important de ne pas casser un ABI une fois qu’il a été fixé.

6.7 Intégrer l’observabilité

Au minimum, disposer de ces éléments aide beaucoup.

  • Heure de la dernière mise à jour
  • Dernière séquence réussie
  • Nombre de drops / d’overwrites
  • Nombre d’incohérences de version
  • Nombre d’attach / detach
  • Dernier code d’erreur
  • Heartbeat

Quand la mémoire partagée casse, les logs sont en général maigres. Ajouter vos propres compteurs rend la réponse aux incidents considérablement plus facile.

6.8 Écrire d’abord les tests de scénarios anormaux

Le chemin nominal seul ne suffit pas. Il faut au minimum examiner ceci :

  • Terminaison forcée du writer en cours de mise à jour
  • Le ring déborde à cause d’un reader qui accuse du retard
  • Connexion avec une incohérence de version
  • Mélange 32 bits / 64 bits
  • Ouverture à travers des sessions différentes
  • Permissions insuffisantes
  • Un processus précédent redémarre en conservant une ancienne génération
  • Cache miss / effets NUMA lors d’un transfert continu de données volumineuses

Pour la mémoire partagée, les tests de casse valent plus que les tests du chemin nominal.

7. Points à vérifier entre Windows et POSIX

Aspect Windows POSIX
Création / ouverture CreateFileMapping / OpenFileMapping / MapViewOfFile 6 shm_open / ftruncate / mmap 3
Partage sans fichier disque Mapping adossé au pagefile avec INVALID_HANDLE_VALUE 68 Objet de mémoire partagée POSIX + mmap 3
Valeurs initiales Les pages adossées au pagefile sont initialisées à zéro 8 Les nouveaux objets démarrent avec une longueur de 0. Les octets nouvellement alloués sont initialisés à zéro 3
Synchronisation mutex / sémaphore / event / interlocked, etc. 25 Mutex / condvar / sémaphore process-shared 2018
À ne pas utiliser entre processus CRITICAL_SECTION, WaitOnAddress 2110 Mutex / condvar laissés en PTHREAD_PROCESS_PRIVATE 2019
Mort du propriétaire WAIT_ABANDONED 12 Mutex robuste + EOWNERDEAD / pthread_mutex_consistent() 1314
Suppression du nom Disparaît quand le dernier handle / la dernière vue est libéré 28 shm_unlink supprime le nom. L’objet persiste tant qu’il reste des références 2223
Espace de noms / permissions Global\ / Local\, ACL, SeCreateGlobalPrivilege 1524 mode, umask, espace de noms, O_CREAT|O_EXCL 3

Le MemoryMappedFile de C# est aussi, pour l’essentiel, un wrapper au-dessus du file mapping Windows. Les bases ne changent donc pas :

  • Ouvrir sous le même nom
  • Utiliser un mutex / event séparé
  • Lire les vues avec une disposition explicite
  • Ne jamais placer directement de références d’objets

1

8. La checklist à consulter en premier

  • La mémoire partagée est-elle vraiment nécessaire ? S’agit-il de données volumineuses sur le même hôte ?
  • Le plan de contrôle et le plan de données sont-ils séparés ?
  • Le modèle de concurrence peut-il être ramené à SPSC / 1 writer, plusieurs readers ?
  • L’en-tête initial contient-il magic / version / size / state / generation / heartbeat ?
  • Placez-vous un pointeur / HANDLE / descripteur de fichier / objet STL / std::mutex ?
  • Existe-t-il un protocole de commit garantissant que les readers ne voient jamais d’états intermédiaires ?
  • Un seul initialisateur est-il désigné ?
  • Existe-t-il une procédure de récupération en cas de terminaison anormale ?
  • Les noms et permissions sont-ils explicites ?
  • Global\ est-il vraiment nécessaire ?
  • Supposez-vous un redimensionnement en place ?
  • Avez-vous testé la mort du writer / le blocage du reader / l’incohérence de version / des permissions insuffisantes ?

9. Conclusion

Bien utilisée, la mémoire partagée est véritablement puissante. En particulier pour de grandes quantités de données au sein d’une seule machine, telles que :

  • Images
  • Audio
  • Flux de capteurs
  • Gros batches
  • Snapshots à haute fréquence

elle est vraiment efficace.

Cependant, l’essence de la mémoire partagée est moins la « vitesse » qu’un transfert de responsabilité. En échange de moins de copies et de moins de messagerie médiée par le noyau, vous prenez en charge :

  • La synchronisation
  • La visibilité
  • L’initialisation
  • L’ABI
  • La récupération
  • Les permissions
  • L’observabilité

Ainsi, pour votre première implémentation, la forme sûre est :

  • Un ring buffer SPSC ou un double buffer
  • Un en-tête initial fixe
  • Des références par offset
  • Une notification sur un canal séparé
  • Avec version / generation / heartbeat
  • Avec des tests de scénarios anormaux

En partant de cette forme, la mémoire partagée devient un outil plutôt bien élevé. La traiter dès le premier jour comme « une mémoire commune rapide où tout est permis », et elle cesse peu à peu d’être une application pour devenir de l’archéologie.

10. Ressources de référence

  • Windows : bases du file mapping et de la mémoire partagée nommée682
  • Windows : espace de noms / sécurité / synchronisation1524512
  • POSIX : shm_open, shm_unlink, mmap, synchronisation process-shared / robuste322162013
  • .NET : présentation de MemoryMappedFile1
  1. Microsoft Learn, “Memory-Mapped Files” / Microsoft Learn, “MemoryMappedFile Class”  2 3

  2. Microsoft Learn, “Sharing Files and Memory”  2 3 4 5 6 7 8

  3. man7.org, “shm_open(3)”  2 3 4 5 6 7 8 9 10 11

  4. Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)”  2

  5. Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function”  2 3 4

  6. Microsoft Learn, “Creating Named Shared Memory”  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory”  2

  8. Microsoft Learn, “CreateFileMappingA function”  2 3 4 5 6 7 8 9

  9. man7.org, “POSIX Shared Memory”, diapositives de formation 

  10. Microsoft Learn, “WaitOnAddress function”  2

  11. Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” 

  12. Microsoft Learn, “Mutex Objects”  2 3

  13. man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)”  2 3

  14. man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)”  2

  15. Microsoft Learn, “Kernel object namespaces”  2 3

  16. man7.org, “mmap(2)”  2

  17. Microsoft Learn, “Using Mutex Objects” 

  18. man7.org, “sem_init(3)” / man7.org, “sem_init(3p)”  2

  19. man7.org, “pthread_condattr_setpshared(3p)” / man7.org, “pthread_condattr_getpshared(3p)”  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” / man7.org, “pthread_mutexattr_getpshared(3p)”  2 3

  21. Microsoft Learn, “Critical Section Objects” 

  22. Microsoft Learn, “File Mapping Security and Access Rights”  2

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.

Une valeur écrite dans la mémoire partagée peut-elle être lue immédiatement et correctement par un autre processus ?
Non, ce sont deux problèmes distincts. La mémoire partagée est un mécanisme qui présente la même suite d'octets à plusieurs processus ; ce n'est pas la synchronisation elle-même. Même si l'écrivain (writer) a l'intention d'écrire dans l'ordre length, payload puis ready flag, un lecteur (reader) qui lit sans aucune synchronisation peut voir un nouveau length combiné à un ancien payload. Que ce soit sous Windows ou POSIX, l'accès à la mémoire partagée est censé être combiné à un moyen de synchronisation tel qu'un mutex, un sémaphore ou un event.
Peut-on placer des pointeurs, des std::string ou des HANDLE dans la mémoire partagée ?
Mieux vaut s'en abstenir. Les adresses virtuelles et les ressources propres à un processus n'ont de sens que dans le contexte de ce processus, et mapper le même mapping dans un autre processus ne garantit pas que les adresses virtuelles coïncident. Il en va de même pour std::vector, std::mutex ou CRITICAL_SECTION. S'il faut des références, il est plus sûr de les conserver sous forme d'offset depuis l'adresse de base, et de faire tendre les données placées en mémoire partagée vers des entiers de largeur fixe, une disposition explicite et un en-tête versionné.
L'utilisation de volatile rend-elle la synchronisation de la mémoire partagée inutile ?
Non. volatile n'est pas un sortilège qui sauve la conception de la mémoire partagée : l'atomicité et l'exclusion mutuelle restent, au minimum, des problèmes distincts. Une conception qui place un volatile bool et le surveille par busy loop gaspille le CPU, rend floue la garantie d'ordre entre le payload et le ready flag, et capte facilement des états intermédiaires. De plus, WaitOnAddress sous Windows est destiné aux threads au sein d'un même processus ; il est plus sûr de ne pas le considérer comme un mécanisme d'attente inter-processus. Il vaut mieux déporter la notification vers des primitives que l'on peut attendre, comme un event ou un sémaphore.
Que faut-il décider en premier lors de la conception d'une mémoire partagée ?
Il y en a quatre. Séparer le plan de contrôle et le plan de données : le contrôle (démarrage, arrêt, notification) passe par la messagerie, tandis que les données elles-mêmes vont dans la mémoire partagée. Restreindre le modèle de concurrence (au départ, un ring buffer SPSC ou un double buffer est le moins sujet aux incidents). Décider du propriétaire et de la durée de vie : qui crée, qui initialise, qui supprime, qui restaure. Et concevoir l'ABI, y compris la disposition et la version. Le simple fait de placer magic, version, size, state, generation et heartbeat dans l'en-tête initial change considérablement la facilité d'investigation en cas d'incident.

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