Conception d’une classe copiable thread-safe

Le moyen le plus simple de créer une classe threadsafe consiste à append un atsortingbut mutex et à verrouiller le mutex dans les méthodes d’access.

class cMyClass { boost::mutex myMutex; cSomeClass A; public: cSomeClass getA() { boost::mutex::scoped_lock lock( myMutex ); return A; } }; 

Le problème est que cela rend la classe non copiable.

Je peux faire fonctionner les choses en rendant le mutex statique. Cependant, cela signifie que chaque instance de la classe est bloquée lorsqu’une autre instance est en cours d’access, car elles partagent le même mutex.

Je me demande s’il y a un meilleur moyen?

Ma conclusion est qu’il n’y a pas de meilleur moyen. Créer une classe thread-safe avec un atsortingbut statique privé mutex est ce qu’il y a de mieux: – c’est simple, ça marche et ça cache les détails embarrassants.

 class cMyClass { static boost::mutex myMutex; cSomeClass A; public: cSomeClass getA() { boost::mutex::scoped_lock lock( myMutex ); return A; } }; 

L’inconvénient est que toutes les instances de la classe partagent le même mutex et se bloquent donc inutilement. Cela ne peut pas être corrigé en rendant l’atsortingbut mutex non statique (afin de donner à chaque instance son propre mutex) car les complexités de la copie et de l’affectation sont cauchemardesques, si elles sont correctement effectuées.

Les mutex individuels, si nécessaire, doivent être gérés par un singleton externe non copiable, avec des liens établis vers chaque instance lors de la création.


Merci pour toutes les réponses.

Plusieurs personnes ont mentionné l’écriture de mon propre constructeur de copie et de mon propre opérateur. J’ai essayé ça. Le problème est que ma vraie classe possède de nombreux atsortingbuts qui changent constamment au cours du développement. La maintenance à la fois du constructeur de copie et de l’opérateur assignmet est fastidieuse et source d’erreurs, des erreurs créant des bogues difficiles à trouver. Laisser le compilateur les générer pour une classe complexe représente un gain de temps considérable et une réduction des erreurs.


De nombreuses réponses s’inquiètent de rendre le constructeur de copie et l’opérateur d’affectation thread-safe. Cette exigence ajoute encore plus de complexité à l’ensemble! Heureusement pour moi, je n’en ai pas besoin puisque toute la copie est faite lors de l’installation dans un seul thread.


Je pense maintenant que la meilleure approche serait de construire une petite classe pour ne contenir qu’un mutex et les atsortingbuts critiques. Ensuite, je peux écrire un constructeur de petite copie et un opérateur d’affectation pour la classe critique et laisser le compilateur se charger de tous les autres atsortingbuts de la classe principale.

 class cSafe { boost::mutex myMutex; cSomeClass A; public: cSomeClass getA() { boost::mutex::scoped_lock lock( myMutex ); return A; } (copy constructor) (assignment op ) }; class cMyClass { cSafe S; ( ... other atsortingbutes ... ) public: cSomeClass getA() { return S.getA(); } }; 

Le simple fait est que vous ne pouvez pas sécuriser un fil de classe en crachant des mutex sur le problème. La raison pour laquelle vous ne pouvez pas faire ce travail est parce que cela ne fonctionne pas, pas parce que vous faites cette technique mal. C’est ce que tout le monde a remarqué quand le multithreading est arrivé et a commencé à abattre les implémentations de chaînes COW.

La conception des threads se produit au niveau de l’application, pas par classe. Seules les classes de gestion de ressources spécifiques doivent avoir une sécurité des threads à ce niveau et vous devez quand même écrire des opérateurs de copie / opérateurs explicites.

Vous pouvez définir votre propre constructeur de copie (et votre opérateur d’affectation de copie). Le constructeur de copie ressemblerait probablement à quelque chose comme ceci:

 cMyClass(const cMyClass& x) : A(x.getA()) { } 

Notez que getA() doit être qualifié de manière getA() pour que cela fonctionne, ce qui signifie que le mutex doit pouvoir être mutable ; vous pouvez faire du paramètre une référence non constante, mais vous ne pouvez pas ensuite copier des objects temporaires, ce qui n’est généralement pas souhaitable.

En outre, considérez que le locking à un niveau aussi bas n’est pas toujours une bonne idée: si vous verrouillez le mutex dans les fonctions d’accesseur et de mutateur, vous perdrez beaucoup de fonctionnalités. Par exemple, vous ne pouvez pas effectuer de comparaison-échange parce que vous ne pouvez pas obtenir et définir la variable membre avec un seul verrou du mutex, et si vous avez plusieurs membres de données contrôlés par le mutex, vous ne pouvez pas accéder à plus d’un d’entre eux avec le mutex verrouillé.

Aussi simple que puisse être la question, bien faire les choses n’est pas si simple. Pour commencer, nous pouvons travailler avec le constructeur de copie facile:

 // almost pseudo code, mutex/lock/data types are synthetic class test { mutable mutex m; data d; public: test( test const & rhs ) { lock l(m); // Lock the rhs to avoid race conditions, // no need to lock this object. d = rhs.d; // perform the copy, data might be many members } }; 

Maintenant, créer un opérateur d’affectation est plus complexe. La première chose qui me vient à l’esprit est de faire la même chose, mais dans ce cas, verrouiller à la fois les lhs et les rhs:

 class test { // wrong mutable mutex m; data d; public: test( test const & ); test& operator=( test const & rhs ) { lock l1( m ); lock l2( rhs.m ); d = rhs.d; return *this; } }; 

Assez simple et faux. Alors que nous garantissons un access à un seul thread aux objects (les deux) pendant l’opération, et que nous n’obtenons aucune condition de concurrence, nous avons un blocage potentiel:

 test a, b; // thr1 // thr2 void foo() { void bar() { a = b; b = a; } } 

Et ce n’est pas le seul blocage potentiel, le code n’est pas sûr pour une auto-affectation (la plupart des mutex ne sont pas récursifs et essayer de verrouiller le même mutex deux fois bloquera le thread). La chose simple à résoudre est l’auto-affectation:

 test& test::operator=( test const & rhs ) { if ( this == &rhs ) return *this; // nothing to do // same (invalid) code here } 

Pour l’autre partie du problème, vous devez imposer un ordre d’acquisition des mutex. Cela pourrait être traité de différentes manières (stockage d’un identifiant unique par object, comparaison …)

 test & test::operator=( test const & rhs ) { mutex *first, *second; if ( unique_id(*this) < unique_id(rhs ) { first = &m; second = &rhs.m; } else { first = &rhs.m; second = &rhs.m; } lock l1( *first ); lock l2( *second ); d = rhs.d; } 

L'ordre spécifique n'est pas aussi important que le fait que vous deviez respecter le même ordre pour toutes les utilisations, sinon vous risqueriez de bloquer les threads. Comme cela est assez courant, certaines bibliothèques (y compris la prochaine norme c ++) ont un support spécifique pour cela:

 class test { mutable std::mutex m; data d; public: test( const test & ); test& operator=( test const & rhs ) { if ( this == &rhs ) return *this; // avoid self deadlock std::lock( m, rhs.m ); // acquire both mutexes or wait std::lock_guard l1( m, std::adopt_lock ); // use RAII to release locks std::lock_guard l2( rhs.m, std::adopt_lock ); d = rhs.d; return *this; } }; 

La fonction std::lock acquiert tous les verrous passés en argument et veille à ce que l'ordre d'acquisition soit identique, garantissant que si tout le code devant acquérir ces deux mutex le fait au moyen de std::lock il y aura pas d'impasse. (Vous pouvez toujours bloquer en les verrouillant manuellement ailleurs séparément). Les deux lignes suivantes stockent les verrous dans des objects implémentant RAII afin qu'en cas d'échec de l'affectation (une exception est levée), les verrous sont libérés.

Cela peut être épelé différemment en utilisant std::unique_lock au lieu de std::lock_guard :

 std::unique_lock l1( m, std::defer_lock ); // store in RAII, but do not lock std::unique_lock l2( rhs.m, std::defer_lock ); std::lock( l1, l2 ); // acquire the locks 

Je viens de penser à une approche beaucoup plus simple, différente, que je dessine ici. La sémantique est légèrement différente, mais peut être suffisante pour de nombreuses applications:

 test& test::operator=( test copy ) // pass by value! { lock l(m); swap( d, copy.d ); // swap is not thread safe return *this; } 

}

Il existe une différence sémantique dans les deux approches, dans la mesure où celle utilisant un idiome copie-échange présente une condition de concurrence potentielle (qui pourrait ou non affecter votre application, mais que vous devriez être au courant). Étant donné que les deux verrous ne sont jamais tenus en même temps, les objects peuvent changer entre le moment où le premier verrou est libéré (la copie de l'argument est terminée) et le deuxième verrou est acquis dans operator= .

Pour un exemple d'échec possible, considérons que les data sont un entier et que deux objects sont initialisés avec la même valeur entière. Un thread acquiert les deux verrous et incrémente les valeurs, tandis qu'un autre thread copie l'un des objects dans l'autre:

 test a(0), b(0); // ommited constructor that initializes the ints to the value // Thr1 void loop() { // [1] while (true) { std::unique_lock la( am, std::defer_lock ); std::unique_lock lb( bm, std::defer_lock ); std::lock( la, lb ); ++ad; ++bd; } } // Thr1 void loop2() { while (true) { a = b; // [2] } } // [1] for the sake of simplicity, assume that this is a friend // and has access to members 

Avec les implémentations de operator= qui effectuent des verrous simultanés sur les deux objects, vous pouvez affirmer à tout moment (le filer en toute sécurité en acquérant les deux verrous) que a et b sont identiques, ce qui semble être attendu par une lecture rapide de le code. Cela ne tient pas si operator= est implémenté en termes d'idiome copier-échanger. Le problème est que dans la ligne marquée comme [2], b est verrouillé et copié dans un fichier temporaire, puis le verrou est relâché. Le premier thread peut alors acquérir les deux verrous en même temps et incrémenter a et b avant a soit verrouillé par le second thread dans [2]. Ensuite, a est écrasé avec la valeur que b avait avant l'incrément.