web-dev-qa-db-fra.com

La nécessité d'un modificateur volatil dans le verrouillage à double vérification dans .NET

Plusieurs textes indiquent que lors de l'implémentation d'un verrouillage à double vérification dans .NET, le champ sur lequel vous vous verrouillez devrait avoir un modificateur volatile appliqué. Mais pourquoi exactement? Considérant l'exemple suivant:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

pourquoi "lock (syncRoot)" n'atteint-il pas la cohérence de mémoire nécessaire? N'est-il pas vrai qu'après une instruction "lock", la lecture et l'écriture seraient volatiles et donc la cohérence nécessaire serait accomplie?

79
Konstantin

La volatilité n'est pas nécessaire. Eh bien, en quelque sorte **

volatile est utilisé pour créer une barrière de mémoire * entre les lectures et les écritures sur la variable.
lock, lorsqu'il est utilisé, crée des barrières mémoire autour du bloc à l'intérieur du lock, en plus de limiter l'accès au bloc à un thread.
Les barrières de mémoire font en sorte que chaque thread lit la valeur la plus récente de la variable (pas une valeur locale mise en cache dans certains registres) et que le compilateur ne réorganise pas les instructions. L'utilisation de volatile n'est pas nécessaire ** car vous avez déjà un verrou.

Joseph Albahari explique ce genre de choses mieux que jamais.

Et assurez-vous de consulter le guide de Jon Skeet pour implémenter le singleton en C #


mise à jour:
* volatile fait que les lectures de la variable sont VolatileReads et les écritures sont VolatileWrites, qui sur x86 et x64 sur CLR, sont implémentées avec un MemoryBarrier. Ils peuvent être plus fins sur d'autres systèmes.

** ma réponse n'est correcte que si vous utilisez le CLR sur des processeurs x86 et x64. Cela pourrait être vrai dans d'autres modèles de mémoire, comme sur Mono (et d'autres implémentations), Itanium64 et le futur matériel. C'est ce à quoi Jon fait référence dans son article dans le "gotchas" pour le verrouillage à double vérification.

Faire l'une des {marquer la variable comme volatile, la lire avec Thread.VolatileRead, ou en insérant un appel à Thread.MemoryBarrier} peut être nécessaire pour que le code fonctionne correctement dans une situation de modèle de mémoire faible.

D'après ce que je comprends, sur le CLR (même sur IA64), les écritures ne sont jamais réorganisées (les écritures ont toujours une sémantique de version). Cependant, sur IA64, les lectures peuvent être réorganisées avant les écritures, sauf si elles sont marquées volatiles. Malheureusement, je n'ai pas accès au matériel IA64 pour jouer, donc tout ce que j'en dirais serait de la spéculation.

j'ai également trouvé ces articles utiles:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
article de vance morrison (tout y est lié, il parle de verrouillage à double vérification)
article de chris brumme (tout y est lié)
Joe Duffy: Variantes brisées du verrouillage à double vérification

la série de Luis Abreu sur le multithreading donne également une belle vue d'ensemble des concepts
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http://msmvps.com/blogs/luisabreu/archive/2009/07/03/multithreading-introducing-memory-fences.aspx

59
dan

Il existe un moyen de l'implémenter sans champ volatile. Je vais l'expliquer ...

Je pense que c'est un accès à la mémoire réorganisé à l'intérieur du verrou qui est dangereux, de sorte que vous pouvez obtenir une instance non complètement initialisée à l'extérieur du verrou. Pour éviter cela, je fais ceci:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Comprendre le code

Imaginez qu'il existe du code d'initialisation à l'intérieur du constructeur de la classe Singleton. Si ces instructions sont réorganisées après que le champ est défini avec l'adresse du nouvel objet, alors vous avez une instance incomplète ... imaginez que la classe a ce code:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Imaginez maintenant un appel au constructeur en utilisant le nouvel opérateur:

instance = new Singleton();

Cela peut être étendu à ces opérations:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

Et si je réorganise ces instructions comme ceci:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

Est-ce que cela fait une différence? NON si vous pensez à un seul thread. OUI si vous pensez à plusieurs threads ... et si le thread est interrompu juste après set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

C'est ce que la barrière de la mémoire évite, en ne permettant pas la réorganisation de l'accès à la mémoire:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

Bon codage!

32
Miguel Angelo

Je ne pense pas que quiconque ait réellement répondu à la question, donc je vais essayer.

Le volatile et le premier if (instance == null) ne sont pas "nécessaires". Le verrou rendra ce code thread-safe.

La question est donc: pourquoi voudriez-vous ajouter la première if (instance == null)?

La raison est probablement d'éviter d'exécuter inutilement la section verrouillée du code. Pendant que vous exécutez le code à l'intérieur du verrou, tout autre thread qui essaie également d'exécuter ce code est bloqué, ce qui ralentira votre programme si vous essayez d'accéder fréquemment au singleton à partir de nombreux threads. Selon la langue/la plate-forme, il peut également y avoir des frais généraux à partir du verrou lui-même que vous souhaitez éviter.

Ainsi, la première vérification nulle est ajoutée comme un moyen très rapide de voir si vous avez besoin du verrou. Si vous n'avez pas besoin de créer le singleton, vous pouvez éviter complètement le verrou.

Mais vous ne pouvez pas vérifier si la référence est nulle sans la verrouiller d'une manière ou d'une autre, car en raison de la mise en cache du processeur, un autre thread pourrait la changer et vous liriez une valeur "périmée" qui vous amènerait à entrer le verrou inutilement. Mais vous essayez d'éviter un verrou!

Ainsi, vous rendez le singleton volatile pour vous assurer de lire la dernière valeur, sans avoir besoin d'utiliser un verrou.

Vous avez toujours besoin du verrou intérieur, car volatile ne vous protège que lors d'un seul accès à la variable - vous ne pouvez pas le tester et le régler en toute sécurité sans utiliser de verrou.

Maintenant, est-ce vraiment utile?

Eh bien, je dirais "dans la plupart des cas, non".

Si Singleton.Instance pourrait provoquer une inefficacité en raison des verrous, alors pourquoi l'appelez-vous si souvent que ce serait un problème important? L'intérêt d'un singleton est qu'il n'y en a qu'un, donc votre code peut lire et mettre en cache la référence singleton une fois.

Le seul cas où je peux penser où cette mise en cache ne serait pas possible serait lorsque vous avez un grand nombre de threads (par exemple, un serveur utilisant un nouveau thread pour traiter chaque demande pourrait créer des millions de threads à très court terme, chacun de qui devrait appeler Singleton.Instance une fois).

Je soupçonne donc que le verrouillage à double vérification est un mécanisme qui a une vraie place dans des cas très spécifiques aux performances critiques, et puis tout le monde a grimpé sur le mouvement "c'est la bonne façon de le faire" sans vraiment penser à ce qu'il fait et s'il sera en fait nécessaire dans le cas où ils l'utilisent.

7
Jason Williams

AFAIK (et - prenez cela avec prudence, je ne fais pas beaucoup de choses simultanées) non. Le verrou vous donne simplement une synchronisation entre plusieurs prétendants (threads).

volatile d'autre part dit à votre machine de réévaluer la valeur à chaque fois, afin de ne pas tomber sur une valeur mise en cache (et erronée).

Voir http://msdn.Microsoft.com/en-us/library/ms998558.aspx et notez la citation suivante:

En outre, la variable est déclarée volatile pour garantir que l'affectation à la variable d'instance se termine avant d'accéder à la variable d'instance.

Une description de volatile: http://msdn.Microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

3
Benjamin Podszun

Vous devez utiliser volatile avec le motif de verrouillage à double vérification.

La plupart des gens pointent cet article comme preuve que vous n'avez pas besoin de produits volatils: https://msdn.Microsoft.com/en-us/magazine/cc163715.aspx#S1

Mais ils ne parviennent pas à lire jusqu'au bout: " Un dernier mot d'avertissement - je ne fais que deviner le modèle de mémoire x86 à partir du comportement observé sur les processeurs existants. Ainsi, les techniques de faible verrouillage sont également fragiles car le matériel et les compilateurs peuvent devenir plus agressifs au fil du temps. Voici quelques stratégies pour minimiser l'impact de cette fragilité sur votre code. D'abord, dans la mesure du possible, évitez les techniques de faible verrouillage. (...) Enfin, supposez le modèle de mémoire le plus faible possible, utiliser des déclarations volatiles au lieu de s'appuyer sur des garanties implicites. "

Si vous avez besoin de plus de conviction, lisez cet article sur la spécification ECMA qui sera utilisée pour les autres plates-formes: msdn.Microsoft.com/en-us/magazine/jj863136.aspx

Si vous avez besoin de convaincre davantage, lisez cet article plus récent sur les optimisations pouvant l'empêcher de fonctionner sans volatilité: msdn.Microsoft.com/en-us/magazine/jj883956.aspx

En résumé, cela "pourrait" fonctionner pour vous sans volatile pour le moment, mais ne risquez pas qu'il écrive le code approprié et utilise soit volatile soit les méthodes volatileread/write. Les articles qui suggèrent de faire autrement omettent parfois certains des risques possibles d'optimisations JIT/compilateur qui pourraient avoir un impact sur votre code, ainsi que les optimisations futures qui pourraient se produire et qui pourraient casser votre code. De même, comme mentionné dans le dernier article, les hypothèses précédentes de travail sans volatilité peuvent ne pas tenir sur ARM.

3
user2685937

lock est suffisant. La spécification du langage MS (3.0) elle-même mentionne ce scénario exact au §8.12, sans aucune mention de volatile:

Une meilleure approche consiste à synchroniser l'accès aux données statiques en verrouillant un objet statique privé. Par exemple:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
2
Marc Gravell

Je pense que j'ai trouvé ce que je cherchais. Les détails sont dans cet article - http://msdn.Microsoft.com/en-us/magazine/cc163715.aspx#S1 .

Pour résumer, le modificateur volatil .NET n'est en effet pas nécessaire dans cette situation. Cependant, dans les modèles à mémoire plus faible, les écritures effectuées dans le constructeur d'un objet lancé paresseusement peuvent être retardées après l'écriture dans le champ, de sorte que d'autres threads peuvent lire une instance non nulle corrompue dans la première instruction if.

2
Konstantin