16 votes

En C, comment faire en sorte qu'un chargement de mémoire ne soit effectué qu'une seule fois ?

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.

3voto

Serge Ballesta Points 12850

Vous devez informer le compilateur que la mémoire partagée peut être modifiée à tout moment par la fonction volatile modificateur.

volatile int *pshared;
...
int private = *pshared; // pshared points to the message in shared memory
...
if (is_valid(private)) {
  ...
  handle(private);
}

Comme *pshared est déclarée comme étant volatile, le compilateur ne peut plus supposer que la variable *pshared et private garder la même valeur.


Selon votre modification, il est maintenant clair que nous savons tous qu'un modificateur volatile sur la mémoire partagée est suffisant pour garantir que le compilateur respectera la temporalité de tous les accès à cette mémoire partagée.

Quoi qu'il en soit, le projet N1256 pour C99 est explicite à ce sujet dans 5.1.2.3 Exécution du programme (c'est moi qui souligne)

2 Accès à un objet volatile la modification d'un objet, la modification d'un fichier ou l'appel d'une fonction qui effectue l'une de ces opérations sont tous des effets secondaires qui sont des changements dans l'état de l'environnement d'exécution. L'évaluation d'une expression peut produire des effets secondaires. À certains points précis de la séquence d'exécution appelés points de séquence, tous les effets secondaires des évaluations précédentes doivent être terminés et aucun effet secondaire des évaluations suivantes ne doit avoir eu lieu ne doit avoir eu lieu .

5 Les exigences minimales d'une mise en œuvre conforme sont les suivantes :
- Aux points de séquence, les objets volatils sont stables dans le sens où les accès précédents sont sont terminés et les accès suivants n'ont pas encore eu lieu
- A la fin du programme, toutes les données écrites dans les fichiers doivent être identiques au résultat que l'exécution du programme selon la sémantique abstraite aurait produit.

Que laisser penser que même si pshared n'est pas qualifiée de volatile, private doit avoir été chargé à partir de *pshared avant l'évaluation de is_valid et comme la machine abstraite n'a aucune raison de le modifier avant l'évaluation de handle une implémentation conforme ne doit pas le modifier. Tout au plus pourrait-elle supprimer l'appel à handle s'il ne contenait pas de effets secondaires ce qui est peu probable


Quoi qu'il en soit, il ne s'agit là que d'une discussion académique, car je ne peux pas imaginer un cas d'utilisation réel où la mémoire partagée n'aurait pas besoin de l'outil de gestion de la mémoire. volatile modificateur. Si vous ne l'utilisez pas, le compilateur est libre de croire que la valeur précédente est toujours valide, donc au deuxième accès, vous obtiendrez toujours la première valeur. Donc même si la réponse à este la question est ce n'est pas nécessaire vous devez toujours utiliser volatile int *pshared; .

0voto

Matt McNabb Points 14273

Il est difficile de répondre à votre question telle qu'elle est posée. Notez que vous debe utiliser un objet de synchronisation pour empêcher les accès concurrents, sauf si vous ne lisez que des unités qui sont atomiques sur la plate-forme.

Je suppose que vous avez l'intention de poser une question sur (pseudocode) :

lock_shared_area();
int private = *pshared;
unlock_shared_area();

if (is_valid(private))

et que l'autre processus utilise également le même verrou. (Si ce n'est pas le cas, il serait bon de mettre à jour votre question pour être un peu plus précis sur votre synchronisation).

Ce code garantit la lecture *pshared au maximum une fois. En utilisant le nom private signifie lire la variable private et non l'objet *pshared . Le compilateur "sait" que l'appel pour déverrouiller la zone agit comme une barrière mémoire et il ne réordonnera pas les opérations au-delà de la barrière.

0voto

Hurkyl Points 1718

Comme le C n'a aucun concept de communication interprocessus, il n'y a rien que vous puissiez faire pour informer le compilateur qu'un autre processus pourrait modifier la mémoire.

Ainsi, je crois qu'il n'y a aucun moyen d'empêcher un système de construction suffisamment intelligent, malveillant, mais conforme, d'invoquer la règle du "comme si" pour lui permettre de faire la mauvaise chose.

Pour obtenir quelque chose dont le fonctionnement est "garanti", vous devez utiliser les garanties données par votre compilateur spécifique et/ou la bibliothèque de mémoire partagée que vous utilisez.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X