L’access à une variable en C # est-il une opération atomique?

J’ai été amené à croire que si plusieurs threads peuvent accéder à une variable, toutes les lectures et écritures sur cette variable doivent être protégées par un code de synchronisation, tel qu’une instruction “lock”, car le processeur peut basculer une écriture

Cependant, je cherchais à travers System.Web.Security.Membership en utilisant Reflector et j’ai trouvé du code comme ceci:

public static class Membership { private static bool s_Initialized = false; private static object s_lock = new object(); private static MembershipProvider s_Provider; public static MembershipProvider Provider { get { Initialize(); return s_Provider; } } private static void Initialize() { if (s_Initialized) return; lock(s_lock) { if (s_Initialized) return; // Perform initialization... s_Initialized = true; } } } 

Pourquoi le champ s_Initialized est-il lu en dehors du verrou? Un autre thread ne pourrait-il pas essayer d’y écrire en même temps? Les lectures et écritures de variables sont-elles atomiques?

Pour la réponse définitive aller à la spécification. 🙂

La partition I, section 12.6.6 de la spécification CLI stipule: “Une interface CLI conforme garantira que l’access en lecture et en écriture à des emplacements mémoire correctement alignés ne soit pas supérieur à la taille du mot natif lorsque tous les access en écriture à un emplacement ont la même taille . ”

Donc, cela confirme que s_Initialized ne sera jamais instable, et que les lectures et écritures sur des types de primitives de taille inférieure à 32 bits sont atomiques.

En particulier, le double et le long ( Int64 et UInt64 ) ne sont pas UInt64 atomiques sur une plate-forme 32 bits. Vous pouvez utiliser les méthodes de la classe Interlocked pour les protéger.

De plus, bien que les lectures et les écritures soient atomiques, il existe une condition de concurrence avec addition, soustraction et incrémentation et décrémentation des types primitifs, car ils doivent être lus, utilisés et réécrits. La classe verrouillée vous permet de les protéger à l’aide des méthodes CompareExchange et Increment .

Le locking crée une barrière de mémoire pour empêcher le processeur de réorganiser les lectures et les écritures. La serrure crée la seule barrière requirejse dans cet exemple.

C’est une (mauvaise) forme du modèle de locking à double vérification qui n’est pas thread-safe en C #!

Il y a un gros problème dans ce code:

s_Initialized n’est pas volatile. Cela signifie que les écritures dans le code d’initialisation peuvent se déplacer après que s_Initialized soit défini sur true et que les autres threads peuvent voir du code non initialisé même si s_Initialized est vrai pour eux. Cela ne s’applique pas à l’implémentation du Framework par Microsoft, car chaque écriture est une écriture volatile.

Mais aussi dans l’implémentation de Microsoft, les lectures des données non initialisées peuvent être réorganisées (c’est-à-dire extraites par le processeur), donc si s_Initialized est vrai, la lecture des données à initialiser peut entraîner la lecture d’anciennes données non Les lectures sont réordonnées.

Par exemple:

 Thread 1 reads s_Provider (which is null) Thread 2 initializes the data Thread 2 sets s\_Initialized to true Thread 1 reads s\_Initialized (which is true now) Thread 1 uses the previously read Provider and gets a NullReferenceException 

Déplacer la lecture de s_Provider avant la lecture de s_Initialized est parfaitement légal car il n’y a pas de lecture volatile nulle part.

Si s_Initialized serait volatil, la lecture de s_Provider ne serait pas autorisée avant la lecture de s_Initialized et l’initialisation du fournisseur n’est pas autorisée à se déplacer après que s_Initialized soit défini sur true et que tout va bien maintenant.

Joe Duffy a également écrit un article sur ce problème: Variantes brisées sur le locking à double vérification

Accrocher – la question qui est dans le titre n’est certainement pas la vraie question que Rory pose.

La question du titre a la réponse simple «non» – mais ce n’est pas du tout utile, quand vous voyez la vraie question – à laquelle personne n’a répondu, à mon avis.

La vraie question posée par Rory est présentée beaucoup plus tard et est plus pertinente pour l’exemple qu’il donne.

Pourquoi le champ s_Initialized est-il lu en dehors du verrou?

La réponse à cette question est également simple, mais n’a aucun rapport avec l’atomicité de l’access variable.

Le champ s_Initialized est lu en dehors du verrou car les verrous sont chers .

Puisque le champ s_Initialized est essentiellement “write once”, il ne retournera jamais de faux positif.

Il est économique de le lire en dehors de la serrure.

Il s’agit d’une activité peu coûteuse avec de fortes chances d’avoir un avantage.

C’est pourquoi il est lu en dehors de la serrure – pour éviter de payer le coût d’utilisation d’une serrure à moins que cela ne soit indiqué.

Si les verrous étaient bon marché, le code serait plus simple et omettrait cette première vérification.

(edit: une réponse agréable de rory suit. Oui, les lectures booléennes sont très atomiques. Si quelqu’un construisait un processeur avec des lectures booléennes non atomiques, elles figureraient sur le DailyWTF.)

La réponse correcte semble être, “Oui, la plupart du temps”.

  1. La réponse de John faisant référence aux spécifications de la CLI indique que les access aux variables ne dépassant pas 32 bits sur un processeur 32 bits sont atomiques.
  2. Confirmation supplémentaire de la spécification C #, section 5.5, Atomicité des références de variables :

    Les lectures et écritures des types de données suivants sont atomiques: bool, char, byte, sbyte, short, ushort, uint, int, float et les types de référence. De plus, les lectures et écritures de types enum avec un type sous-jacent dans la liste précédente sont également atomiques. Les lectures et écritures des autres types, y compris les types long, ulong, double et décimal, ainsi que les types définis par l’utilisateur, ne sont pas nécessairement atomiques.

  3. Le code de mon exemple a été paraphrasé à partir de la classe Membership, telle qu’elle a été écrite par l’équipe ASP.NET. Il était donc toujours prudent de supposer que la façon dont elle accède au champ s_Initialized est correcte. Maintenant, nous soaps pourquoi.

Edit: Comme le souligne Thomas Danecker, même si l’access au champ est atomique, s_Initialized devrait vraiment être marqué comme volatile pour s’assurer que le locking n’est pas rompu par le processeur qui réordonne les lectures et les écritures.

La fonction Initialize est défectueuse. Il devrait ressembler plus à ceci:

 private static void Initialize() { if(s_initialized) return; lock(s_lock) { if(s_Initialized) return; s_Initialized = true; } } 

Sans la deuxième vérification à l’intérieur du verrou, il est possible que le code d’initialisation soit exécuté deux fois. Donc, le premier contrôle est que les performances vous évitent de prendre un verrou inutilement, et la deuxième vérification concerne le cas où un thread exécute le code d’initialisation mais n’a pas encore défini le drapeau s_Initialized et donc un deuxième thread passe la première vérification et attendez à la serrure.

Les lectures et écritures de variables ne sont pas atomiques. Vous devez utiliser les API de synchronisation pour émuler les lectures / écritures atomiques.

Pour une référence impressionnante à ce sujet et bien d’autres problèmes liés à la concurrence, assurez-vous de récupérer une copie du dernier spectacle de Joe Duffy. C’est un ripper!

“L’access à une variable en C # est-il une opération atomique?”

Nan. Et ce n’est pas une chose C #, ni même une chose .net, c’est une chose de processeur.

OJ est sur que Joe Duffy est le gars à qui aller pour ce genre d’information. Et “interlocked” est un excellent terme de recherche à utiliser si vous voulez en savoir plus.

Les “lectures déchirées” peuvent se produire sur toute valeur dont les champs sont supérieurs à la taille d’un pointeur.

@Leon
Je vois votre sharepoint vue – comme je l’ai demandé et commenté, la question permet de la prendre de plusieurs manières.

Pour être clair, je voulais savoir s’il était prudent d’avoir des threads concurrents en lecture et en écriture dans un champ booléen sans code de synchronisation explicite, c’est-à-dire en accédant à une variable booléenne (ou autre primitive).

J’ai ensuite utilisé le code Membership pour donner un exemple concret, mais cela a créé un tas de distractions, comme le double contrôle du locking, le fait que s_Initialized n’est jamais défini qu’une seule fois et que j’ai lui-même commenté le code d’initialisation.

Ma faute.

Vous pouvez également décorer s_Initialized avec le mot-clé volatile et renoncer complètement à l’utilisation du verrou.

Ce n’est pas correct. Vous rencontrerez toujours le problème d’un deuxième thread qui passe la vérification avant que le premier thread n’ait eu la chance de définir l’indicateur, ce qui entraînera plusieurs exécutions du code d’initialisation.

Je pense que vous demandez si s_Initialized peut être dans un état instable lorsqu’il est lu en dehors du verrou. La réponse courte est non. Une simple affectation / lecture se résume à une seule instruction d’assemblage qui est atomique sur chaque processeur auquel je peux penser.

Je ne suis pas sûr que ce soit le cas pour l’affectation à des variables 64 bits, cela dépend du processeur, je suppose que ce n’est pas atomique mais c’est probablement sur les processeurs 32 bits modernes et certainement sur tous les processeurs 64 bits. L’affectation de types de valeurs complexes ne sera pas atomique.

Je pensais qu’ils l’étaient – je ne suis pas sûr du but de la serrure dans votre exemple, sauf si vous faites également quelque chose à s_Provider en même temps – alors le verrou ferait en sorte que ces appels se produisent ensemble.

Est-ce que //Perform initialization couverture de commentaire d’ //Perform initialization créant s_Provider? Par exemple

 private static void Initialize() { if (s_Initialized) return; lock(s_lock) { s_Provider = new MembershipProvider ( ... ) s_Initialized = true; } } 

Sinon, cette propriété-get statique va tout simplement retourner null.

Peut-être que Interlocked donne un indice. Et sinon celui-ci est plutôt bien.

J’aurais deviné que leur pas atomique.

Pour que votre code fonctionne toujours sur des architectures faiblement ordonnées, vous devez placer un MemoryBarrier avant d’écrire s_Initialized.

 s_Provider = new MemershipProvider; // MUST PUT BARRIER HERE to make sure the memory writes from the assignment // and the constructor have been wriitten to memory // BEFORE the write to s_Initialized! Thread.MemoryBarrier(); // Now that we've guaranteed that the writes above // will be globally first, set the flag s_Initialized = true; 

Les écritures de mémoire qui se produisent dans le constructeur MembershipProvider et l’écriture dans s_Provider ne sont pas garanties avant que vous écriviez dans s_Initialized sur un processeur faiblement ordonné.

Beaucoup de reflection sur ce sujet concerne la question de savoir si quelque chose est atomique ou non. Ce n’est pas le problème. Le problème est l’ordre dans lequel les écritures de votre thread sont visibles pour les autres threads . Sur les architectures faiblement ordonnées, les écritures en mémoire ne se produisent pas dans l’ordre et C’EST le vrai problème, pas si une variable tient dans le bus de données.

EDIT: En fait, je mélange des plates-formes dans mes déclarations. Dans C #, la spécification CLR exige que les écritures soient globalement visibles, dans l’ordre (en utilisant des instructions de stockage coûteuses pour chaque magasin si nécessaire). Par conséquent, vous n’avez pas besoin de cette barrière de mémoire. Toutefois, s’il s’agissait de C ou C ++ où il n’existe aucune garantie de visibilité globale et que votre plate-forme cible dispose d’une mémoire faiblement ordonnée et multithread, vous devez vous assurer que les écritures du constructeur sont visibles avant la mise à jour de s_Initialized. , qui est testé en dehors de la serrure.

Un If (itisso) { vérifier un booléen est atomique, mais même si ce n’est pas le cas, il n’est pas nécessaire de verrouiller le premier contrôle.

Si un thread a terminé l’initialisation, ce sera vrai. Peu importe si plusieurs threads vérifient à la fois. Ils auront tous la même réponse et il n’y aura pas de conflit.

La deuxième vérification à l’intérieur du verrou est nécessaire car un autre thread peut avoir saisi le verrou en premier et terminé le processus d’initialisation.

Ce que vous demandez, c’est si l’access à un champ dans une méthode plusieurs fois atomique – auquel la réponse est non.

Dans l’exemple ci-dessus, la routine d’initialisation est défectueuse car elle peut entraîner plusieurs initialisations. Vous devez vérifier l’indicateur s_Initialized à l’intérieur du verrou et à l’extérieur pour éviter une condition de s_Initialized dans laquelle plusieurs threads lisent l’indicateur s_Initialized avant que le code d’initialisation ne soit réellement s_Initialized . Par exemple,

 private static void Initialize() { if (s_Initialized) return; lock(s_lock) { if (s_Initialized) return; s_Provider = new MembershipProvider ( ... ) s_Initialized = true; } } 

Ack, tant pis … comme cela a été souligné, c’est en effet incorrect. Cela n’empêche pas un deuxième thread d’entrer dans la section de code “initialize”. Bah.

Vous pouvez également décorer s_Initialized avec le mot-clé volatile et renoncer complètement à l’utilisation du verrou.