web-dev-qa-db-fra.com

std :: mutex vs std :: recursive_mutex en tant que membre de la classe

J'ai vu des gens détester recursive_mutex:

http://www.zaval.org/resources/library/butenhof1.html

Mais quand on réfléchit à la façon d'implémenter une classe qui est thread-safe (mutex protected), il me semble atrocement difficile de prouver que chaque méthode qui devrait être mutex protégée est mutex protégée et que mutex est verrouillé au plus une fois.

Par conséquent, pour la conception orientée objet, std::recursive_mutex par défaut et std::mutex considéré comme une optimisation des performances dans le cas général à moins qu'il ne soit utilisé qu'à un seul endroit (pour protéger une seule ressource)?

Pour clarifier les choses, je parle d'un mutex privé non statique. Ainsi, chaque instance de classe n'a qu'un seul mutex.

Au début de chaque méthode publique:

{
    std::scoped_lock<std::recursive_mutex> sl;
43
NoSenseEtAl

La plupart du temps, si vous pensez avoir besoin d'un mutex récursif, votre conception est incorrecte, donc ce ne devrait certainement pas être la valeur par défaut.

Pour une classe avec un seul mutex protégeant les membres de données, le mutex doit être verrouillé dans toutes les fonctions membres public et toutes les fonctions membres private doivent supposer que le mutex est déjà verrouillé.

Si une fonction membre public doit appeler une autre fonction membre public, divisez la seconde en deux: une fonction d'implémentation private qui fait le travail et une fonction public fonction membre qui verrouille simplement le mutex et appelle private one. La première fonction membre peut alors également appeler la fonction d'implémentation sans avoir à se soucier du verrouillage récursif.

par exemple.

class X {
    std::mutex m;
    int data;
    int const max=50;

    void increment_data() {
        if (data >= max)
            throw std::runtime_error("too big");
        ++data;
    }
public:
    X():data(0){}
    int fetch_count() {
        std::lock_guard<std::mutex> guard(m);
        return data;
    }
    void increase_count() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
    } 
    int increase_count_and_return() {
        std::lock_guard<std::mutex> guard(m);
        increment_data();
        return data;
    } 
};

Ceci est bien sûr un exemple artificiel trivial, mais le increment_data La fonction est partagée entre deux fonctions de membre public, dont chacune verrouille le mutex. Dans le code monothread, il pourrait être inséré dans increase_count, et increase_count_and_return pourrait appeler cela, mais nous ne pouvons pas le faire en code multithread.

Il s'agit simplement d'une application de bons principes de conception: les fonctions de membre public assument la responsabilité de verrouiller le mutex et délèguent la responsabilité d'effectuer le travail à la fonction de membre privé.

Cela a l'avantage que les fonctions membres public n'ont à gérer que lorsque la classe est dans un état cohérent: le mutex est déverrouillé, et une fois verrouillé, tous les invariants tiennent. Si vous appelez les fonctions membres public les unes des autres, elles doivent gérer le cas où le mutex est déjà verrouillé et que les invariants ne tiennent pas nécessairement.

Cela signifie également que des choses comme les attentes de variables de condition fonctionneront: si vous passez un verrou sur un mutex récursif à une variable de condition, alors (a) vous devez utiliser std::condition_variable_any car std::condition_variable ne fonctionnera pas, et (b) un seul niveau de verrouillage est libéré, vous pouvez donc toujours maintenir le verrou, et donc le blocage car le thread qui déclencherait le prédicat et ferait la notification ne peut pas acquérir le verrou.

J'ai du mal à penser à un scénario où un mutex récursif est nécessaire.

68
Anthony Williams

devrait std::recursive_mutex par défaut et std::mutex considéré comme une optimisation des performances?

Non, pas vraiment. L'avantage d'utiliser des verrous non récursifs est pas juste une optimisation des performances, cela signifie que votre code vérifie automatiquement que les opérations atomiques au niveau feuille sont vraiment au niveau feuille, elles n'appellent pas autre chose qui utilise le verrou.

Il y a une situation assez courante où vous avez:

  • une fonction qui implémente une opération qui doit être sérialisée, donc elle prend le mutex et le fait.
  • une autre fonction qui implémente une opération sérialisée plus importante et souhaite appeler la première fonction pour effectuer une étape de celle-ci, tout en maintenant le verrou pour l'opération plus importante.

Pour un exemple concret, peut-être que la première fonction supprime atomiquement un nœud d'une liste, tandis que la deuxième fonction supprime atomiquement deux nœuds d'une liste (et vous ne voulez jamais qu'un autre thread voie la liste avec un seul des deux nœuds retiré).

Vous n'avez pas besoin mutex récursifs pour cela. Par exemple, vous pouvez refactoriser la première fonction en tant que fonction publique qui prend le verrou et appelle une fonction privée qui effectue l'opération "en toute sécurité". La deuxième fonction peut alors appeler la même fonction privée.

Cependant, il est parfois pratique d'utiliser à la place un mutex récursif. Il y a toujours un problème avec cette conception: remove_two_nodes appels remove_one_node à un point où un invariant de classe ne tient pas (la deuxième fois qu'il l'appelle, la liste est précisément dans l'état que nous ne voulons pas exposer). Mais en supposant que nous savons que remove_one_node ne s'appuie pas sur cet invariant, ce n'est pas une faute mortelle dans la conception, c'est juste que nous avons rendu nos règles un peu plus complexes que l'idéal "tous les invariants de classe tiennent toujours chaque fois qu'une fonction publique est entrée".

Donc, l'astuce est parfois utile et je ne déteste pas les mutex récursifs dans la mesure où cet article le fait. Je n'ai pas les connaissances historiques pour affirmer que la raison de leur inclusion dans Posix est différente de ce que dit l'article, "pour démontrer les attributs mutex et les extensions de threads". Je ne les considère certainement pas par défaut, cependant.

Je pense qu'il est prudent de dire que si dans votre conception, vous n'êtes pas sûr d'avoir besoin d'un verrou récursif ou non, votre conception est incomplète. Vous regretterez plus tard le fait que vous écrivez du code et que vous ne sais pas quelque chose de si fondamentalement important que le verrouillage soit déjà autorisé ou non. Ne mettez donc pas de verrou récursif "au cas où".

Si vous savez que vous en avez besoin, utilisez-en un. Si vous savez que vous n'en avez pas besoin, l'utilisation d'un verrou non récursif n'est pas seulement une optimisation, elle aide à appliquer une contrainte de conception. Il est plus utile que le deuxième verrou échoue, que pour qu'il réussisse et masque le fait que vous ayez accidentellement fait quelque chose qui, selon votre conception, ne devrait jamais se produire. Mais si vous suivez votre conception et ne double-verrouillez jamais le mutex, vous ne saurez jamais s'il est récursif ou non, et donc un mutex récursif n'est pas directement nuisible.

Cette analogie peut échouer, mais voici une autre façon de voir les choses. Imaginez que vous aviez le choix entre deux types de pointeurs: un qui abandonne le programme avec une trace de pile lorsque vous déréférencer un pointeur nul, et un autre qui renvoie 0 (ou pour l'étendre à plus de types: se comporte comme si le pointeur se réfère à un objet initialisé par une valeur). Un mutex non récursif est un peu comme celui qui abandonne, et un mutex récursif est un peu comme celui qui renvoie 0. Ils ont tous deux potentiellement leur utilité - les gens vont parfois jusqu'à certaines longueurs pour implémenter un "pas-a silencieux -value "valeur. Mais dans le cas où votre code est conçu pour ne jamais déréférencer un pointeur nul, vous ne voulez pas utiliser par défaut la version qui permet silencieusement que cela se produise.

25
Steve Jessop

Je ne vais pas directement peser sur le débat mutex contre recursive_mutex, mais j'ai pensé qu'il serait bon de partager un scénario où recursive_mutex'es sont absolument critiques pour la conception.

Lorsque vous travaillez avec Boost :: asio, Boost :: coroutine (et probablement des choses comme NT Fibers bien que je les connaisse moins), il est absolument essentiel que vos mutex soient récursifs même sans le problème de conception de la rentrée.

La raison en est que l'approche basée sur la coroutine, par sa conception même, suspendra l'exécution à l'intérieur d'une routine et la reprendra ensuite. Cela signifie que deux méthodes de niveau supérieur d'une classe peuvent "être appelées en même temps sur le même thread" sans qu'aucun appel secondaire ne soit effectué.

7
MB.