3.3 Outils alternatifs pour la protection des données partagées

Outils alternatifs pour la protection des données partagées

Bien que les mutex constituent le mécanisme le plus général, ils ne constituent pas la seule option en matière de protection des données partagées ; il existe des alternatives qui peuvent fournir une protection plus appropriée dans des situations spécifiques.

Un cas particulièrement extrême (mais assez courant) est celui où les données partagées nécessitent uniquement une protection contre les accès simultanés lors de l'initialisation, mais ne nécessitent pas de synchronisation explicite par la suite. Cela peut être dû au fait que les données sont en lecture seule une fois créées, il n'y a donc aucun problème de synchronisation possible, ou au fait que la protection nécessaire est effectuée implicitement dans le cadre des opérations sur les données. Dans les deux cas, le verrouillage du mutex après l'initialisation des données sert uniquement à protéger l'initialisation, ce qui est inutile et nuit inutilement aux performances. Pour cette raison, la norme C++ fournit un mécanisme uniquement destiné à protéger les données partagées lors de l'initialisation.

Protéger les données partagées lors de l'initialisation

Supposons que vous disposiez d’une ressource partagée très coûteuse à construire et que vous souhaitiez la faire uniquement lorsque vous en avez réellement besoin. Peut-être que cela ouvre une connexion à la base de données ou alloue beaucoup de mémoire. Une initialisation paresseuse comme celle-ci est courante dans le code monothread - chaque opération qui demande une ressource vérifie d'abord si elle a été initialisée et, dans le cas contraire, l'initialise avant de l'utiliser.

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
    
    
     if(!resource_ptr)
     {
    
    
         resource_ptr.reset(new some_resource); //❶
     }
     resource_ptr->do_something();
}

Si la ressource partagée elle-même est sécurisée pour un accès simultané, la seule partie qui doit être protégée lors de sa conversion en code multithread est l'initialisation ❶, mais une conversion naïve comme celle du Listing 3.11 amènera les threads utilisant la ressource à générer des générations inutiles. sérialisation. En effet, chaque thread doit attendre que le mutex vérifie si la ressource a été initialisée.

//清单3.11 使用互斥元进行线程安全的延迟初始化
#include <memory>
#include <mutex>

struct some_resource
{
    
    
    void do_something()
    {
    
    }
    
};


std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
    
    
    std::unique_lock<std::mutex> lk(resource_mutex); //所有的线程在这里被序列化
    if(!resource_ptr)
    {
    
    
        resource_ptr.reset(new some_resource); //只有初始化需要被保护
    }
    lk.unlock();
    resource_ptr->do_something();
}

int main()
{
    
    
    foo();
}

Ce code est courant et le problème de la sérialisation inutile est suffisamment important pour que de nombreuses personnes aient essayé de trouver une meilleure façon de le faire, y compris le fameux modèle de verrouillage à double vérification . Lisez le pointeur pour la première fois sans acquérir le verrou ❶ (dans le code ci-dessous), et n'acquérez le verrou que si ce pointeur est NULL. Une fois le verrou acquis, le pointeur est à nouveau vérifié (c'est la deuxième partie de vérification) pour empêcher un autre thread de terminer l'initialisation entre la première vérification et l'acquisition du verrou par ce thread.

void undefined_behaviour_with_double_checked_locking()
{
    
    
     if(!resource_ptr) //❶
     {
    
       
         std::lock_guard<std::mutex> lk(resource_mutex);
         if(!resource_ptr) //❷
         {
    
    
              resource_ptr.reset(new some_resource); //❸
         }
     }
     resource_ptr->do_something(); //❹
}

Malheureusement, ce modèle est notoire pour une raison. Cela peut potentiellement créer une mauvaise condition de concurrence car les lectures ❶ à l'extérieur du verrou ne sont pas synchronisées avec les écritures ❸ effectuées par un autre thread à l'intérieur du verrou. Cela crée donc une condition de concurrence critique qui couvre non seulement le pointeur lui-même, mais également l'objet vers lequel il pointe. Même si un thread voit le pointeur écrit par un autre thread, il peut ne pas voir l'instance some_resource nouvellement créée, ce qui entraîne l'exécution de l'appel à do_something()❹ sur une valeur incorrecte. Il s'agit d'un exemple de condition de concurrence, qui est définie par la norme C++ comme une course aux données et a donc un comportement indéfini . C’est donc définitivement quelque chose à éviter.

Le comité des normes C++ a également trouvé qu'il s'agissait d'un scénario important, c'est pourquoi la bibliothèque standard C++ fournit std::once_flag et std::call_once pour gérer cette situation. Au lieu de verrouiller le mutex et de vérifier explicitement le pointeur, chaque thread peut utiliser std::call_once, et au moment où std::call_once reviendra, le pointeur sera initialisé par un thread (de manière totalement synchrone), donc c'est sûr. L'utilisation de std::call_once entraîne généralement une surcharge inférieure à l'utilisation explicite d'un mutex, en particulier lorsque l'initialisation est terminée, donc std::call_once doit être utilisé en premier lorsqu'il répond aux fonctionnalités requises. L'exemple suivant montre la même opération que le listing 3.11, modifié pour utiliser std::call_once. Dans ce cas, l'initialisation se fait en appelant une fonction, mais l'initialisation pourrait tout aussi bien se faire via une instance de classe avec un opérateur d'appel de fonction. Comme la plupart des fonctions de la bibliothèque standard qui acceptent des fonctions ou des assertions comme arguments, std::call_once peut fonctionner avec n'importe quelle fonction ou objet appelable.

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; //❶

void int_resource()
{
    
    
    resource_ptr.reset(new some_resource);
}
void foo()
{
    
    
    std::call_once(resource_flag, init_resource); //初始化会被正好调用一次
    resource_ptr->do_something();
}

Dans cet exemple, std::once_flag❶ et les données initialisées sont tous deux des objets de portée d'espace de noms, mais std::call_once() peut facilement être utilisé pour une initialisation paresseuse des membres de la classe, comme le montre le listing 3.12.

//清单3.12 使用std::call_one的线程安全的类成员延迟初始化
#include <mutex>

struct connection_info
{
    
    };

struct data_packet
{
    
    };

struct connection_handle
{
    
    
    void send_data(data_packet const&)
    {
    
    }
    data_packet receive_data()
    {
    
    
        return data_packet();
    }
};

struct remote_connection_manager
{
    
    
    connection_handle open(connection_info const&)
    {
    
    
        return connection_handle();
    }
} connection_manager;


class X
{
    
    
private:
    connection_info connection_details;
    connection_handle connection;
    std::once_flag connection_init_flag;

    void open_connection()
    {
    
    
        connection=connection_manager.open(connection_details);
    }
public:
    X(connection_info const& connection_details_):
        connection_details(connection_details_)
    {
    
    }
    void send_data(data_packet const& data) //❶
    {
    
    
        std::call_once(connection_init_flag,&X::open_connection,this); //❷
        connection.send_data(data);
    }
    data_packet receive_data() //❸
    {
    
    
        std::call_once(connection_init_flag,&X::open_connection,this);
        return connection.receive_data();
    }
};

int main()
{
    
    }

Dans cet exemple, l'initialisation se fait par le premier appel à send_data()❶ ou par le premier appel à contain_data(). Utilisez la fonction membre open_connection() pour initialiser les données, et vous devez également transmettre ce pointeur dans la fonction. Comme d'autres fonctions de la bibliothèque standard qui acceptent les objets appelables, tels que les constructeurs de std::thread et std::bind(), ceci est accompli en passant un argument supplémentaire à std::call_once() ❷ .

Il convient de noter que les instances de std::mutex et std::once_flag ne peuvent pas être copiées ou déplacées, donc si vous souhaitez les utiliser comme membres de classe comme celui-ci, vous devez définir explicitement les fonctions membres spéciales dont vous avez besoin.

Un scénario dans lequel il peut y avoir une condition de concurrence critique pendant le processus d'initialisation consiste à déclarer les variables locales comme statiques. L'initialisation d'une telle variable est définie pour se produire lorsque le contrôle temporel passe pour la première fois sur sa déclaration. Pour plusieurs threads appelant la fonction, cela signifie qu'il peut y avoir une condition de concurrence critique pour la définition de « première fois ». Sur de nombreux compilateurs antérieurs à C++11, cette condition de concurrence critique est problématique en pratique, car plusieurs threads peuvent penser qu'ils sont les premiers et essayer d'initialiser la variable, ou des threads peuvent être en train de s'initialiser. Une tentative a été faite pour l'utiliser alors qu'elle était démarrée le un autre fil mais n'est pas encore terminé. En C++11, ce problème est résolu. L'initialisation est définie pour se produire uniquement sur un thread, et les autres threads ne peuvent pas continuer tant que l'initialisation n'est pas terminée, donc la condition de concurrence critique concerne uniquement le thread qui effectuera l'initialisation, et rien de plus. Cela peut être utilisé en remplacement de std::call_once où une seule instance globale est requise.

class my_class;
my_class& get_my_class_instance()
{
    
    
     static my_class instance; //❶初始化保证线程是安全的
     return instance;
}

Plusieurs threads peuvent continuer à appeler en toute sécurité get_my_class_instance()❶ sans se soucier des conditions de concurrence lors de l'initialisation.

La protection des données utilisées uniquement pour l'initialisation est un cas particulier du scénario plus général des structures de données rarement mises à jour. La plupart du temps, une telle structure de données est en lecture seule et peut être lue simultanément par plusieurs threads sans problème, mais il peut arriver que la structure de données doive être mise à jour. Ce qu’il faut ici, c’est un mécanisme de protection qui reconnaisse ce fait.

Protéger les structures de données rarement mises à jour

Supposons qu'il existe une table utilisée pour stocker un cache d'entrées DNS, qui est utilisée pour résoudre les noms de domaine en adresses P correspondantes. Souvent, une entrée DNS donnée restera inchangée pendant une longue période – dans de nombreux cas, les entrées DNS resteront inchangées pendant des années. Bien que de nouvelles entrées puissent être ajoutées au tableau de temps à autre à mesure que les utilisateurs visitent différents sites Web, ces données resteront essentiellement inchangées tout au long de leur durée de vie. Il est important de vérifier régulièrement la validité des entrées du cache, mais de ne les mettre à jour que si les détails ont réellement changé.

Bien que les mises à jour soient rares, elles se produisent toujours, et si ce cache est accessible à partir de plusieurs threads, il doit être correctement protégé lors des mises à jour pour garantir que tous les threads ne voient pas de corruption lors de la lecture de la structure des données du cache.

En l'absence d'une structure de données dédiée, entièrement conforme à son utilisation prévue et conçue spécifiquement pour des mises à jour et des lectures simultanées, de telles mises à jour nécessitent que le thread effectuant la mise à jour ait un accès exclusif à la structure de données jusqu'à ce qu'il termine l'opération. Une fois la mise à jour terminée, la structure de données est sécurisée pour un accès simultané par plusieurs threads. Utiliser std::mutex pour protéger la structure des données est donc trop pessimiste, car cela éliminera la possibilité de lecture simultanée de la structure des données lorsque la structure des données n'est pas modifiée. Ce dont nous avons besoin, c'est d'un autre mutex. Ce nouveau mutex est souvent appelé mutex lecteur-écrivain car il permet deux utilisations différentes : un accès exclusif ou un partage par un seul thread « d'écriture », et une utilisation simultanée par un accès à plusieurs threads « de lecture ».

La nouvelle bibliothèque standard C++ ne fournit pas directement un tel mutex, bien qu'il ait été proposé au comité des standards. Cette suggestion n'ayant pas été acceptée, les exemples de cette section utilisent l'implémentation fournie par la bibliothèque Boost, qui est basée sur cette suggestion. Comme vous le verrez plus tard, l'utilisation d'un tel mutex n'est pas une panacée et les performances dépendent du nombre de processeurs et de la charge de travail relative des threads de lecture et de mise à jour. Par conséquent, il est important d’analyser les performances du code sur le système cible pour s’assurer que la complexité supplémentaire présente de réels avantages.

Vous pouvez utiliser une instance de boost :: shared_mutex pour réaliser la synchronisation au lieu d'une instance de std :: mutex. Pour les opérations de mise à jour, std::lock_guard<boost::shared_mutex>et std::unique_lock<boost::shared_mutex>peut être utilisé pour le verrouillage, en remplaçant la spécialisation std::mutex correspondante. Cela garantit un accès exclusif, tout comme le fait std::mutex. Les threads qui n'ont pas besoin de mettre à jour la structure des données peuvent l'utiliser boost::shared_lock<boost::shared_mutex>pour obtenir un accès partagé. C'est exactement la même chose que std :: unique_lock, sauf que plusieurs threads peuvent avoir des verrous partagés sur le même boost :: share_mutex en même temps. La seule restriction est que si un thread possède un verrou partagé, le thread essayant d'acquérir le verrou exclusif sera bloqué jusqu'à ce que tous les autres threads révoquent leurs verrous. De même, si un thread possède un verrou exclusif, aucun autre thread ne peut acquérir le verrou partagé. .lock ou verrou exclusif jusqu'à ce que le premier thread révoque son verrou.

Le listing 3.13 montre un cache DNS simple comme décrit précédemment, utilisant std::map pour sauvegarder les données du cache et boost::share_mutex pour la protection.

//清单3.13 使用boost::share_mutex保护数据结构
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>

class dns_entry
{
    
    };

class dns_cache
{
    
    
    std::map<std::string,dns_entry> entries;
    boost::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain)
    {
    
    
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex); //❶
        std::map<std::string,dns_entry>::const_iterator const it=
            entries.find(domain);
        return (it==entries.end())?dns_entry():it->second;
    }
    void update_or_add_entry(std::string const& domain,
                             dns_entry const& dns_details)
    {
    
    
        std::lock_guard<boost::shared_mutex> lk(entry_mutex); //❷
        entries[domain]=dns_details;
    }
};

int main()
{
    
    }

Dans le listing 3.13, find_entry() utilise une boost::share_lock<>instance pour la protéger pour un accès partagé en lecture seule : plusieurs threads peuvent ainsi appeler find_entry() simultanément sans aucun problème. update_or_add_entry(), d'autre part, utilise une std::lock_guard<>instance qui fournit un accès exclusif pendant la mise à jour de la table ; non seulement les autres threads ne peuvent pas se mettre à jour pendant l'appel à update_or_add_entry(), mais le thread appelant find_entry() est également bloqué.

verrouillage récursif

Lors de l'utilisation de std::mutex, c'est une erreur qu'un thread tente de verrouiller un mutex qu'il possède déjà, et tenter de le faire entraînera un comportement indéfini . Cependant, dans certains cas, il est souhaitable qu'un thread réacquière le même mutex plusieurs fois sans le libérer au préalable. A cet effet, la bibliothèque standard C++ fournit std::recursive_mutex. C'est comme std::mutex, sauf que vous pouvez acquérir plusieurs verrous sur une seule instance dans le même thread. Vous devez libérer tous les verrous avant que le mutex puisse être verrouillé par un autre thread, donc si vous appelez lock() trois fois, vous devez également appeler unlock() trois fois. Utilisez-le correctement std::lock_guard<std::recursive_mutex>et std::unique_lock<std::recursive_mutex>il sera pris en charge pour vous.

La plupart du temps, si vous sentez que vous avez besoin d’un mutex récursif, vous devrez peut-être modifier votre conception. Les mutex récursifs sont souvent utilisés lorsqu'une classe est conçue pour être accessible simultanément par plusieurs threads, elle dispose donc d'un mutex pour protéger les données des membres. Chaque fonction membre publique verrouille le mutex, fait son travail, puis déverrouille le mutex. Cependant, il est parfois souhaitable qu’une fonction membre publique appelle une autre fonction dans le cadre de son fonctionnement. Dans ce cas, la deuxième fonction membre tentera également de verrouiller le mutex, provoquant un comportement indéfini. La solution brute consiste à changer le mutex en un mutex récursif. Cela permettra au verrouillage du mutex dans la deuxième fonction membre de réussir et à la fonction de continuer.

Cependant, une telle utilisation n’est pas recommandée car elle peut conduire à une réflexion bâclée et à une mauvaise conception. En particulier, les invariants de classe sont généralement corrompus lorsque le verrou est maintenu, ce qui signifie que la deuxième fonction membre doit fonctionner même si elle est appelée avec l'invariant corrompu. Il est généralement préférable d'extraire une nouvelle fonction membre privée appelée à partir des deux fonctions membres qui ne verrouille pas le mutex (il pense que le mutex est déjà verrouillé). Ensuite, vous pouvez réfléchir aux circonstances dans lesquelles cette nouvelle fonction peut être appelée et à l'état des données dans ces circonstances.

おすすめ

転載: blog.csdn.net/qq_36314864/article/details/132202824