Je programme deux processus qui communiquent en s'envoyant des messages dans un segment de mémoire partagée. Bien que l'on n'accède pas aux messages de manière atomique, la synchronisation est réalisée en protégeant les messages avec des objets atomiques partagés auxquels on accède avec des store-releases et des load-acquires.
Mon problème concerne la sécurité. Les processus ne se font pas confiance. À la réception d'un message, un processus ne suppose pas que le message est bien formé ; il copie d'abord le message de la mémoire partagée à la mémoire privée, puis effectue une certaine validation sur cette copie privée et, si elle est valide, procède à la manipulation de cette même copie privée. La réalisation de cette copie privée est cruciale, car elle empêche une attaque TOC/TOU dans laquelle l'autre processus modifierait le message entre la validation et l'utilisation.
Ma question est la suivante : la norme garantit-elle qu'un compilateur C intelligent ne décidera jamais qu'il peut lire l'original au lieu de la copie ? Imaginez le scénario suivant, dans lequel le message est un simple entier :
int private = *pshared; // pshared points to the message in shared memory
...
if (is_valid(private)) {
...
handle(private);
}
Si le compilateur est à court de registres et doit temporairement déverser private
pourrait-il décider, au lieu de le déverser sur la pile, d'écarter simplement sa valeur et de le recharger à partir du fichier *pshared
plus tard, à condition qu'une analyse des alias garantisse que ce fil n'a pas changé *pshared
?
Je pense qu'une telle optimisation du compilateur ne préserverait pas la sémantique du programme source, et serait donc illégale : pshared
ne pointe pas vers un objet qui ne peut être atteint que par ce thread (comme un objet alloué sur la pile dont l'adresse n'a pas fuité), le compilateur ne peut donc pas exclure qu'un autre thread puisse modifier simultanément *pshared
. En revanche, le compilateur peut éliminer les charges redondantes, car l'un des comportements possibles est qu'aucun autre thread ne s'exécute entre les charges redondantes, donc le thread actuel doit être prêt à faire face à ce comportement particulier.
Quelqu'un pourrait-il confirmer ou infirmer cette supposition et éventuellement fournir des références aux parties pertinentes de la norme ?
(Au fait : Je suppose que le type de message n'a pas de représentations de piège, de sorte que les charges sont toujours définies).
UPDATE
Plusieurs affiches ont fait des commentaires sur la nécessité de la synchronisation, ce que je n'avais pas l'intention d'aborder, car je pense que j'en ai déjà parlé. Mais puisque les gens le soulignent, il est juste que je fournisse plus de détails.
J'implémente un système de communication asynchrone de bas niveau entre deux entités qui ne se font pas confiance. J'exécute des tests avec des processus, mais j'envisage de passer à des machines virtuelles sur un hyperviseur. J'ai deux ingrédients de base à ma disposition : la mémoire partagée et un mécanisme de notification (typiquement, l'injection d'une IRQ dans l'autre machine virtuelle).
J'ai implémenté une structure de tampon circulaire générique avec laquelle les entités communicantes peuvent produire des messages, puis envoyer les notifications susmentionnées pour se faire savoir quand il y a quelque chose à consommer. Chaque entité maintient son propre état privé qui suit ce qu'elle a produit/consommé, et il y a un état partagé dans la mémoire partagée composé de créneaux de messages et d'entiers atomiques qui suivent les limites des régions contenant des messages en attente. Le protocole identifie sans ambiguïté quels créneaux de messages doivent être exclusivement accessibles par quelle entité à tout moment. Lorsqu'elle doit produire un message, une entité écrit un message (de manière non atomique) dans le slot approprié, puis effectue un store-release atomique vers l'entier atomique approprié pour transférer la propriété du slot à l'autre entité, puis attend que les écritures en mémoire soient terminées, puis envoie une notification pour réveiller l'autre entité. À la réception de la notification, l'autre entité est censée effectuer un chargement-acquisition atomique sur l'entier atomique approprié, déterminer le nombre de messages en attente, puis les consommer.
La charge de *pshared
dans mon extrait de code n'est qu'un exemple de ce que la consommation d'un produit trivial ( int
) ressemble à un message. Dans un contexte réaliste, le message serait une structure. La consommation d'un message ne nécessite pas d'atomicité ou de synchronisation particulière, puisque, comme le spécifie le protocole, elle ne se produit que lorsque l'entité consommatrice s'est synchronisée avec l'autre et sait qu'elle possède l'emplacement du message. Tant que les deux entités suivent le protocole, tout fonctionne parfaitement.
Maintenant, je ne veux pas que les entités aient à se faire confiance. Leur mise en œuvre doit être robuste contre une entité malveillante qui ignorerait le protocole et écrirait à tout moment sur le segment de mémoire partagée. Si cela devait se produire, la seule chose que l'entité malveillante devrait être en mesure de faire serait de perturber la communication. Pensez à un serveur typique, qui doit être prêt à traiter des requêtes mal formées par un client malveillant, sans que ce mauvais comportement ne provoque de débordements de tampon ou d'accès hors limites.
Ainsi, alors que le protocole repose sur la synchronisation pour un fonctionnement normal, les entités doivent être préparées à ce que le contenu de la mémoire partagée puisse changer à tout moment. Tout ce dont j'ai besoin est un moyen de m'assurer qu'après qu'une entité ait fait une copie privée d'un message, elle valide et utilise cette même copie, et n'accède plus jamais à l'original.
J'ai une implémentation qui copie le message en utilisant une lecture volatile, indiquant ainsi clairement au compilateur que la mémoire partagée n'a pas une sémantique de mémoire ordinaire. Je pense que c'est suffisant ; je me demande si c'est nécessaire.