J'ai lu l'article suivant par Antony Williams et comme je l'ai compris en plus du nombre atomique partagé dans std::shared_ptr
dans std::experimental::atomic_shared_ptr
le pointeur réel sur l'objet partagé est également atomique?
Mais quand j'ai lu la version comptée par référence de lock_free_stack
décrit dans le livre d'Antony sur C++ Concurrency il me semble que la même chose s'applique aussi pour std::shared_ptr
, car des fonctions comme std::atomic_load
, std::atomic_compare_exchnage_weak
sont appliqués aux instances de std::shared_ptr
.
template <class T>
class lock_free_stack
{
public:
void Push(const T& data)
{
const std::shared_ptr<node> new_node = std::make_shared<node>(data);
new_node->next = std::atomic_load(&head_);
while (!std::atomic_compare_exchange_weak(&head_, &new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
std::shared_ptr<node> old_head = std::atomic_load(&head_);
while(old_head &&
!std::atomic_compare_exchange_weak(&head_, &old_head, old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>();
}
private:
struct node
{
std::shared_ptr<T> data;
std::shared_ptr<node> next;
node(const T& data_) : data(std::make_shared<T>(data_)) {}
};
private:
std::shared_ptr<node> head_;
};
Quelle est la différence exacte entre ces deux types de pointeurs intelligents et si le pointeur dans std::shared_ptr
l'instance n'est pas atomique, pourquoi est-il possible que l'implémentation de pile sans verrou ci-dessus soit possible?
La "chose" atomique dans shared_ptr
n'est pas le pointeur partagé lui-même, mais le bloc de contrôle vers lequel il pointe. ce qui signifie que tant que vous ne modifiez pas le shared_ptr
sur plusieurs threads, ça va. notez que copie a shared_ptr
mute uniquement le bloc de contrôle, et non le shared_ptr
lui-même.
std::shared_ptr<int> ptr = std::make_shared<int>(4);
for (auto i =0;i<10;i++){
std::thread([ptr]{ auto copy = ptr; }).detach(); //ok, only mutates the control block
}
La mutation du pointeur partagé lui-même, comme l'affectation de valeurs différentes à plusieurs threads, est une course aux données, par exemple:
std::shared_ptr<int> ptr = std::make_shared<int>(4);
std::thread threadA([&ptr]{
ptr = std::make_shared<int>(10);
});
std::thread threadB([&ptr]{
ptr = std::make_shared<int>(20);
});
Ici, nous mutons le bloc de contrôle (ce qui est correct) mais aussi le pointeur partagé lui-même, en le faisant pointer vers des valeurs différentes de plusieurs threads. Ce n'est pas ok.
Une solution à ce problème consiste à envelopper le shared_ptr
avec un verrou, mais cette solution n'est pas si évolutive dans certains cas, et dans un sens, perd la sensation automatique du pointeur partagé standard.
Une autre solution consiste à utiliser les fonctions standard que vous avez citées, telles que std::atomic_compare_exchange_weak
. Cela rend le travail de synchronisation des pointeurs partagés manuel, ce que nous n'aimons pas.
C'est là que le pointeur partagé atomique vient jouer. Vous pouvez muter le pointeur partagé de plusieurs threads sans craindre une course aux données et sans utiliser de verrous. Les fonctions autonomes seront celles des membres, et leur utilisation sera beaucoup plus naturelle pour l'utilisateur. Ce type de pointeur est extrêmement utile pour les structures de données sans verrouillage.
N4162(pdf), la proposition de pointeurs intelligents atomiques, a une bonne explication. Voici une citation de la partie pertinente:
Cohérence . Pour autant que je sache, les fonctions [util.smartptr.shared.atomic] sont les seules opérations atomiques de la norme qui ne sont pas disponibles via un type
atomic
. Et pour tous les types en plus deshared_ptr
, Nous enseignons aux programmeurs à utiliser des types atomiques en C++, pas des fonctions de style Catomic_*
. Et c'est en partie à cause de ...Exactitude . L'utilisation des fonctions gratuites rend le code sujet aux erreurs et racé par défaut. Il est de loin supérieur d'écrire
atomic
une fois sur la déclaration de variable elle-même et de savoir que tous les accès seront atomiques, au lieu de devoir se rappeler d'utiliser l'opérationatomic_*
Sur tous utilisation de l'objet, même des lectures apparemment simples. Ce dernier style est sujet aux erreurs; par exemple, "mal faire" signifie simplement écrire des espaces blancs (par exemple,head
au lieu deatomic_load(&head)
), de sorte que dans ce style, chaque utilisation de la variable est "mauvaise par défaut". Si vous oubliez d'écrire l'appelatomic_*
À un seul endroit, votre code continuera à être compilé avec succès sans erreurs ni avertissements, il "semblera fonctionner", y compris probablement la plupart des tests, mais contiendra toujours une course silencieuse avec un comportement indéfini qui apparaît généralement sous la forme de défaillances intermittentes difficiles à reproduire, souvent/généralement sur le terrain, et je m'attends également à des vulnérabilités exploitables dans certains cas. Ces classes d'erreurs sont éliminées en déclarant simplement la variableatomic
, car alors c'est sûr par défaut et pour écrire le même ensemble de bogues, il faut du code explicite non blanc (parfois des arguments explicitesmemory_order_*
, Et généralementreinterpret_cast
ing).Performances .
atomic_shared_ptr<>
En tant que type distinct a un avantage d'efficacité important sur les fonctions de [util.smartptr.shared.atomic] - il peut simplement stocker unatomic_flag
Supplémentaire (ou similaire) pour le verrou tournant interne comme habituel pouratomic<bigstruct>
. En revanche, les fonctions autonomes existantes doivent être utilisables sur tout objetshared_ptr
Arbitraire, même si la grande majorité desshared_ptr
Ne seront jamais utilisées de manière atomique. Cela rend les fonctions libres intrinsèquement moins efficaces; par exemple, l'implémentation peut nécessiter que chaqueshared_ptr
transporte le surdébit d'une variable spinlock interne (meilleure concurrence, mais surdébit significatif parshared_ptr
), sinon la bibliothèque doit conserver une structure de données de côté pour stocker les informations supplémentaires pourshared_ptr
s qui sont réellement utilisés de manière atomique, ou (pire et apparemment courant dans la pratique), la bibliothèque doit utiliser un verrou tournant global.
Appeler std::atomic_load()
ou std::atomic_compare_exchange_weak()
sur un shared_ptr
Est fonctionnellement équivalent à appeler atomic_shared_ptr::load()
ou atomic_shared_ptr::atomic_compare_exchange_weak()
. Il ne devrait pas y avoir de différence de performances entre les deux. L'appel de std::atomic_load()
ou std::atomic_compare_exchange_weak()
sur un atomic_shared_ptr
Serait redondant syntaxiquement et pourrait ou non entraîner une baisse des performances.
atomic_shared_ptr
est un raffinement d'API. shared_ptr
prend déjà en charge les opérations atomiques, mais uniquement lors de l'utilisation des fonctions atomiques non membres . Ceci est sujet aux erreurs, car les opérations non atomiques restent disponibles et sont trop faciles à invoquer par accident pour un programmeur imprudent. atomic_shared_ptr
est moins sujet aux erreurs car il n'expose aucune opération non atomique.
shared_ptr
et atomic_shared_ptr
expose différentes API, mais elles n'ont pas nécessairement besoin d'être implémentées différemment; shared_ptr
prend déjà en charge toutes les opérations exposées par atomic_shared_ptr
. Cela dit, les opérations atomiques de shared_ptr
n'est pas aussi efficace qu'il pourrait l'être, car il doit également prendre en charge les opérations non atomiques. Il existe donc des raisons de performances pour lesquelles atomic_shared_ptr
pourrait être implémenté différemment. Cela est lié au principe de responsabilité unique. "Une entité avec plusieurs objectifs disparates ... offre souvent des interfaces paralysées pour l'un de ses objectifs spécifiques, car le chevauchement partiel entre les différents domaines de fonctionnalité brouille la vision nécessaire pour une implémentation précise de chacun." (Sutter & Alexandrescu 2005, Normes de codage C++ )