Barrières de locking et de mémoire

J’ai une question à propos de l’exemple de code suivant ( m_value n’est pas volatile et chaque thread s’exécute sur un processeur séparé).

void Foo() // executed by thread #1, BEFORE Bar() is executed { Interlocked.Exchange(ref m_value, 1); } bool Bar() // executed by thread #2, AFTER Foo() is executed { return m_value == 1; } 

L’utilisation d’ Interlocked.Exchange dans Foo () garantit-elle que lorsque Bar () sera exécuté, je verrai la valeur “1”? (même si la valeur existe déjà dans un registre ou une ligne de cache?) ou dois-je placer une barrière de mémoire avant de lire la valeur de m_value ?

Aussi (sans rapport avec la question initiale), est-il légal de déclarer un membre instable et de le transmettre par référence aux méthodes InterlockedXX ? (Le compilateur met en garde contre le passage de volatiles par référence, alors devrais-je ignorer l’avertissement dans ce cas?)

Veuillez noter que je ne cherche pas de “meilleures façons de faire les choses”, alors s’il vous plaît, ne postez pas de réponses qui suggèrent des façons complètement différentes de faire les choses (“utilisez un verrou à la place” etc.), cette question relève d’un intérêt pur ..

Le modèle habituel d’utilisation de la barrière de mémoire correspond à ce que vous metsortingez dans la mise en œuvre d’une section critique, mais divisé en paires pour le producteur et le consommateur. A titre d’exemple, l’implémentation de votre section critique serait généralement de la forme:

 while (! pShared-> lock.testAndSet_Acquire ());
 // (cette boucle doit inclure tous les éléments de la section critique normale tels que
 // essore, gaspille, 
 // instructions pause (), et dernier recours, abandon et blocage sur une ressource 
 // jusqu'à ce que le verrou soit disponible.)

 // Accès à la mémoire partagée.

 pShared-> foo = 1 
 v = pShared-> goo

 pShared-> lock.clear_Release ()

L’obtention de la barrière de mémoire ci-dessus permet de s’assurer que tous les chargements (pShared-> goo) pouvant avoir été démarrés avant la modification de locking réussie sont à lancer, à redémarrer si nécessaire.

La barrière de libération de la mémoire garantit que la charge de goo dans la variable (dis locale) v est terminée avant que le mot de locking protégeant la mémoire partagée ne soit effacé.

Vous avez un schéma similaire dans le scénario typique du drapeau atomique des producteurs et des consommateurs (il est difficile de dire à votre échantillon si c’est ce que vous faites, mais cela devrait illustrer l’idée).

Supposons que votre producteur utilise une variable atomique pour indiquer qu’un autre état est prêt à être utilisé. Vous voudrez quelque chose comme ça:

 pShared-> goo = 14

 pShared-> atomic.setBit_Release ()

Sans barrière “d’écriture” chez le producteur, vous n’avez aucune garantie que le matériel ne parviendra pas au magasin atomique avant que le magasin goo l’ait traversé les files d’attente du magasin cpu, et dans la hiérarchie de la mémoire où il est visible. (même si vous avez un mécanisme qui garantit que le compilateur ordonne les choses comme vous le souhaitez).

Chez le consommateur

 if (pShared-> atomic.compareAndSwap_Acquire (1,1))
 {
    v = pShared-> goo 
 }

Sans une barrière de “lecture” ici, vous ne saurez pas que le matériel n’est pas parti pour vous avant que l’access atomique soit terminé. L’atome (c’est-à-dire: la mémoire manipulée avec les fonctions Interlocked faisant des choses comme lock cmpxchg), n’est “atomique” que par rapport à lui-même, pas avec une autre mémoire.

La dernière chose à mentionner est que les constructions de barrière sont très impossibles à porter. Votre compilateur fournit probablement des variations _acquire et _release pour la plupart des méthodes de manipulation atomique, et voici le genre de façons dont vous les utiliseriez. Selon la plate-forme que vous utilisez (par exemple: ia32), il peut très bien s’agir de ce que vous obtiendriez sans les suffixes _acquire () ou _release (). Les plates-formes où cela compte sont: ia64 (mort, sauf sur HP, où il est encore légèrement secoué), et powerpc. ia64 avait des modificateurs d’instruction .acq et .rel sur la plupart des instructions de chargement et de stockage (y compris les instructions atomiques telles que cmpxchg). powerpc a des instructions séparées pour cela (isync et lwsync vous donnent respectivement les barrières en lecture et en écriture).

À présent. Ayant dit tout cela. Avez-vous vraiment une bonne raison d’aller dans cette voie? Faire tout cela correctement peut être très difficile. Préparez-vous à beaucoup de doute sur vous-même et d’insécurité dans les revues de code et assurez-vous que vous avez beaucoup de tests de simultanéité élevés avec toutes sortes de scénarios de chronométrage aléatoires. Utilisez une section critique à moins d’avoir une très bonne raison de l’éviter et n’écrivez pas cette section critique vous-même.

Les barrières de mémoire ne vous aident pas particulièrement. Ils spécifient un ordre entre les opérations de mémoire, dans ce cas, chaque thread n’a qu’une seule opération de mémoire, donc cela n’a pas d’importance. Un scénario typique consiste à écrire de manière non atomique dans des champs d’une structure, une barrière de mémoire, puis à publier l’adresse de la structure dans d’autres threads. La barrière garantit que les écritures dans les structures membres sont vues par tous les processeurs avant qu’ils n’en reçoivent l’adresse.

Ce dont vous avez vraiment besoin, ce sont des opérations atomiques, c’est-à-dire. Fonctions interlockedXXX ou variables volatiles en C #. Si la lecture dans Bar était atomique, vous pourriez garantir que ni le compilateur, ni le cpu, ne réalisent les optimisations l’empêchant de lire la valeur avant l’écriture dans Foo ou après l’écriture dans Foo en fonction de celle qui sera exécutée en premier. Puisque vous dites que vous “savez”, l’écriture de Foo se produit avant celle de Bar, alors Bar retournera toujours vrai.

Sans que la lecture dans Bar soit atomique, il pourrait lire une valeur partiellement mise à jour (c.-à-d. Garbage) ou une valeur mise en cache (du compilateur ou de la CPU), ce qui pourrait empêcher Bar de renvoyer true comme il se doit.

Les lectures alignées sur les mots garantis de la plupart des processeurs modernes sont atomiques, le vrai truc est donc de dire au compilateur que la lecture est atomique.

Je ne suis pas tout à fait sûr, mais je pense qu’Interlocked.Exchange utilisera de toute façon la fonction InterlockedExchange de l’API Windows .

Cette fonction génère une barrière de mémoire complète (ou une barrière) pour garantir que les opérations de mémoire sont effectuées dans l’ordre.

Les échanges interverrouillés garantissent une barrière de mémoire.

Les fonctions de synchronisation suivantes utilisent les barrières appropriées pour assurer l’ordre de la mémoire:

  • Fonctions qui entrent ou sortent des sections critiques

  • Fonctions qui signalent des objects de synchronisation

  • Fonctions d’attente

  • Fonctions verrouillées

(Source: lien )

Mais vous n’avez pas de chance avec les variables de registre. Si m_value est dans un registre dans Bar, vous ne verrez pas le changement en m_value. Pour cette raison, vous devez déclarer les variables partagées «volatiles».

Si m_value n’est pas marquée comme volatile , il n’y a aucune raison de penser que la valeur lue dans Bar est protégée. Les optimisations du compilateur, la mise en cache ou d’autres facteurs peuvent réorganiser les lectures et les écritures. L’échange interconnecté n’est utile que lorsqu’il est utilisé dans un écosystème de références de mémoire correctement isolées. C’est tout l’intérêt de marquer un champ volatile . Le modèle de mémoire .Net n’est pas aussi simple que certains pourraient s’y attendre.

Interlocked.Exchange () doit garantir que la valeur est correctement appliquée à tous les processeurs – elle fournit sa propre barrière de mémoire.

Je suis surpris que le compilateur se plaint de passer une variable volatile dans Interlocked.Exchange () – le fait que vous utilisiez Interlocked.Exchange () devrait presque obliger une variable volatile.

Le problème que vous pourriez voir est que si le compilateur optimise fortement Bar () et réalise que rien ne change la valeur de m_value, il peut optimiser votre contrôle. C’est ce que ferait le mot clé volatile – cela indiquerait au compilateur que cette variable peut être modifiée en dehors de l’affichage de l’optimiseur.

Si vous ne dites pas au compilateur ou à l’exécution que m_value ne doit pas être lu avant Bar (), il peut et peut mettre en cache la valeur de m_value avant Bar() et utiliser simplement la valeur mise en cache. Si vous voulez vous assurer qu’il voit la “dernière” version de m_value , m_value -le dans un Thread.MemoryBarrier() ou utilisez Thread.VolatileRead(ref m_value) . Ce dernier est moins coûteux qu’une barrière de mémoire complète.

Dans l’idéal, vous pouvez utiliser un ReadBarrier, mais le CLR ne semble pas le supporter directement.

EDIT: Une autre façon de penser est qu’il existe en réalité deux types de barrières de mémoire: les barrières de mémoire du compilateur qui indiquent au compilateur comment séquencer les lectures et les écritures et les barrières de mémoire du processeur qui indiquent au processeur comment séquencer les lectures et les écritures. Les fonctions Interlocked utilisent des barrières de mémoire CPU. Même si le compilateur les traitait comme des barrières de mémoire pour le compilateur, cela importait peu, car dans ce cas particulier, Bar() aurait pu être compilé séparément et ne connaissait pas les autres utilisations de m_value qui nécessiteraient une barrière de mémoire pour le compilateur.