5

J'ai donc vu beaucoup d'articles affirmant que le verrouillage double C++, couramment utilisé pour empêcher plusieurs threads d'initialiser un singleton créé paresseusement, est cassé. Code de verrouillage revérifié normale se lit comme suit:Qu'est-ce qui ne va pas avec ce correctif pour un double verrouillage?

class singleton { 
private: 
    singleton(); // private constructor so users must call instance() 
    static boost::mutex _init_mutex; 

public: 
    static singleton & instance() 
    { 
     static singleton* instance; 

     if(!instance) 
     { 
      boost::mutex::scoped_lock lock(_init_mutex); 

      if(!instance)   
       instance = new singleton; 
     } 

     return *instance; 
    } 
}; 

Le problème est apparemment la ligne par exemple l'affectation - le compilateur est libre d'affecter l'objet puis attribuez-lui le pointeur, ou pour régler le pointeur à l'endroit où il sera alloué, puis l'allouer. Le dernier cas casse l'idiome - un thread peut allouer la mémoire et assigner le pointeur mais pas exécuter le constructeur du singleton avant qu'il ne soit mis en sommeil - alors le deuxième thread verra que l'instance n'est pas nulle et essaiera de le renvoyer , même si elle n'a pas encore été construite.

I saw a suggestion pour utiliser un thread booléen local et vérifier qu'au lieu de instance. Quelque chose comme ceci:

class singleton { 
private: 
    singleton(); // private constructor so users must call instance() 
    static boost::mutex _init_mutex; 
    static boost::thread_specific_ptr<int> _sync_check; 

public: 
    static singleton & instance() 
    { 
     static singleton* instance; 

     if(!_sync_check.get()) 
     { 
      boost::mutex::scoped_lock lock(_init_mutex); 

      if(!instance)   
       instance = new singleton; 

      // Any non-null value would work, we're really just using it as a 
      // thread specific bool. 
      _sync_check = reinterpret_cast<int*>(1); 
     } 

     return *instance; 
    } 
}; 

De cette façon, chaque fil finit par vérifier si l'instance a été créé une fois, mais arrête après cela, ce qui entraîne une baisse de performance, mais encore loin d'être aussi mauvais que le verrouillage de chaque appel. Mais que se passe-t-il si nous venons d'utiliser un bool statique local?:

class singleton { 
private: 
    singleton(); // private constructor so users must call instance() 
    static boost::mutex _init_mutex; 

public: 
    static singleton & instance() 
    { 
     static bool sync_check = false; 
     static singleton* instance; 

     if(!sync_check) 
     { 
      boost::mutex::scoped_lock lock(_init_mutex); 

      if(!instance)   
       instance = new singleton; 

      sync_check = true; 
     } 

     return *instance; 
    } 
}; 

Pourquoi cela ne fonctionnerait-il pas? Même si sync_check devait être lu par un thread quand il est assigné dans un autre, la valeur du garbage sera toujours non nulle et donc vraie. This Dr. Dobb's article affirme que vous devez vous verrouiller car vous ne remporterez jamais une bataille avec le compilateur en raison d'instructions de réorganisation. Ce qui me fait penser que cela ne doit pas fonctionner pour une raison quelconque, mais je n'arrive pas à comprendre pourquoi. Si les exigences sur les points de séquence sont aussi faibles que l'article de Dr. Dobb me le fait croire, je ne comprends pas pourquoi aucun code après que le verrou n'a pas pu être réorganisé pour être devant le verrou. Ce qui rendrait C++ multithreading période cassée. Je pense que je pourrais voir le compilateur autorisé à réordonner spécifiquement sync_check pour être avant le verrou parce que c'est une variable locale (et même si c'est statique nous ne retournons pas une référence ou un pointeur vers lui) - mais alors pourrait encore être résolu en en faisant un membre statique (effectivement global) à la place.

Alors, cela fonctionnera-t-il ou non? Pourquoi?

+2

Le problème est que la variable peut être affectée avant l'exécution (ou l'achèvement) du constructeur, et non avant l'allocation de l'objet. – kdgregory

+0

Merci, corrigé. Je me suis totalement trompé sur les conditions de course. –

+1

Oui, vous avez raison, le C++ actuel est vraiment "multithread broken period". en considérant la norme seulement. Les vendeurs de compilateurs fournissent habituellement des moyens autour de ceci, donc les résultats pratiques ne sont pas si terribles. – Suma

Répondre

5

Votre correctif ne corrige rien puisque les écritures à sync_check et l'instance peuvent être effectuées dans le désordre sur le CPU. A titre d'exemple, imaginez que les deux premiers appels à l'instance se produisent à peu près en même temps sur deux processeurs différents. Le premier thread va acquérir le verrou, initialiser le pointeur et définir sync_check sur true, dans cet ordre, mais le processeur peut changer l'ordre des écritures en mémoire. Sur l'autre CPU, il est possible que le second thread vérifie sync_check, voir que c'est vrai, mais que l'instance n'est pas encore écrite en mémoire. Voir Lockless Programming Considerations for Xbox 360 and Microsoft Windows pour plus de détails.

La solution sync_check spécifique au thread que vous mentionnez devrait alors fonctionner (en supposant que vous initialisiez votre pointeur sur 0).

+0

Concernant votre dernière phrase: Oui mais, je ne suis pas sûr mais je pense que thread_specific_ptr utilise un mutex en interne. Alors, à quoi cela servirait-il d'utiliser cette solution plutôt que de toujours verrouiller le mutex (pas de double verrouillage)? – n1ckp

1

Il y a une grande lecture à ce sujet (bien qu'il soit .net/C# orienté) ici: http://msdn.microsoft.com/en-us/magazine/cc163715.aspx

Cela revient à dire que vous devez être en mesure de dire la CPU qu'il ne peut pas réordonner votre lecture/écriture pour cet accès aux variables (depuis le Pentium original, le CPU peut réorganiser certaines instructions s'il pense que la logique ne sera pas affectée), et qu'il doit s'assurer que le cache est cohérent (ne pas oublier - nous devs arriver à prétendre que toute la mémoire est juste une ressource plate, mais en réalité, chaque noyau de CPU a un cache, certains non partagés (L1), certains pourraient être partagés parfois (L2)) - votre initizlization pourrait écrire dans la RAM principale, mais un autre noyau peut avoir la valeur non initialisée dans le cache. Si vous n'avez pas de sémantique de concurrence, le processeur peut ne pas savoir que son cache est sale. Je ne connais pas le côté C++, mais dans .net, vous désignez la variable comme volatile afin de protéger l'accès à celle-ci (ou utilisez les méthodes de barrière de lecture/écriture de la mémoire dans System.Threading). En aparté, j'ai lu que dans .net 2.0, le double contrôle est garanti pour fonctionner sans variables "volatiles" (pour tous les lecteurs .net là-bas) - cela ne vous aide pas avec votre C++ code.Si vous voulez être sûr, vous devrez faire l'équivalent C++ de marquer une variable comme volatile dans C#.

+1

Les variables C++ peuvent être déclarées comme volatiles, mais je doute que cela ait exactement la même sémantique que C#. Je me souviens aussi d'avoir lu quelque part que c'était un abus de volatiles, mais je ne me souviens pas pourquoi je ne peux donc pas juger à quel point l'article était raisonnable. –

+0

Dans différentes langues, cela pourrait être un abus (pourrait même être un abus en C#). L'un des aspects les plus difficiles de l'écriture d'un code à faible verrouillage ou sans verrouillage a été la disparité des directives. J'ai passé du temps à lire à ce sujet, et il semble que même au sein de Microsoft, certains des blogueurs semblent se contredire lorsque vous avez besoin d'une barrière de mémoire, et quand vous devriez utiliser volatile. C'est un problème difficile, pour être sûr. – JMarsch

+0

Il n'y a pas d'équivalent de .NET volatile dans le C++ courant (tel que défini par la norme). C'est l'un des domaines à venir C++ 0x standard apportera. En attendant, vous devez utiliser ce que votre compilateur offre (ce qui dans Visual Studio signifie volatile et barrière de mémoire). – Suma

0

"Le dernier cas casse l'idiome - deux threads pourraient finir par créer le singleton." Mais si je comprends bien le code, le premier exemple, vous vérifiez si l'instance existe déjà (peut être exécutée par plusieurs threads en même temps), si un thread ne l'obtient pas pour le verrouiller et qu'il crée le instance - un seul thread peut exécuter la création à ce moment-là. Tous les autres threads se verrouillent et attendent.

Une fois que l'instance est créée et que le mutex est déverrouillé, le prochain thread en attente verrouillera le mutex mais n'essaiera pas de créer une nouvelle instance car la vérification échouera.

La prochaine fois que la variable d'instance sera vérifiée, elle sera définie de sorte qu'aucun thread n'essaiera de créer une nouvelle instance. Je ne suis pas sûr du cas où un thread attribue un nouveau pointeur d'instance à une instance alors qu'un autre thread vérifie la même variable - mais je crois qu'il sera géré correctement dans ce cas.

Ai-je oublié quelque chose ici? Ok, je ne suis pas sûr de la réorganisation des opérations, mais dans ce cas, cela changerait la logique, donc je ne m'attendrais pas à ce que cela se produise - mais je ne suis pas un expert sur ce sujet.

+0

Vous avez raison - je me suis trompé sur la condition de course réelle. Le problème est qu'un second thread peut voir une instance non nulle et essayer de le renvoyer avant que le premier thread l'ait construit. J'ai modifié mon message. –

Questions connexes