web-dev-qa-db-fra.com

Comprendre std :: atomic :: compare_exchange_weak () en C ++ 11

bool compare_exchange_weak (T& expected, T val, ..);

compare_exchange_weak() est l'une des primitives d'échange de comparaison fournies en C++ 11. C'est ( faible en ce sens qu'il retourne faux même si la valeur de l'objet est égale à expected . Cela est dû à échec parasite sur certaines plates-formes où une séquence d'instructions (au lieu d'une comme sur x86) est utilisée pour Mettre en œuvre. Sur de telles plateformes, le changement de contexte, le rechargement de la même adresse (ou ligne de cache) par un autre thread, etc. peut échouer la primitive. C'est spurious car ce n'est pas la valeur de l'objet (pas égale à expected) qui échoue l'opération. Au lieu de cela, c'est une sorte de problème de timing.

Mais ce qui me laisse perplexe, c'est ce qui est dit dans la norme C++ 11 (ISO/IEC 14882),

29.6.5 .. Une conséquence de l'échec fallacieux est que presque toutes les utilisations de comparaison et d'échange faibles seront dans une boucle.

Pourquoi doit-il être en boucle dans presque toutes les utilisations ? Est-ce à dire que nous allons boucler en cas de défaillance à cause de fausses pannes? Si c'est le cas, pourquoi prenons-nous la peine d'utiliser compare_exchange_weak() et d'écrire la boucle nous-mêmes? Nous pouvons simplement utiliser compare_exchange_strong() qui, je pense, devrait nous débarrasser des échecs parasites. Quels sont les cas d'utilisation courants de compare_exchange_weak()?

Une autre question était liée. Dans son livre "C++ Concurrency In Action", Anthony dit:

//Because compare_exchange_weak() can fail spuriously, it must typically
//be used in a loop:

bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

//In this case, you keep looping as long as expected is still false,
//indicating that the compare_exchange_weak() call failed spuriously.

Pourquoi !expected Est-il dans la condition de boucle? Est-ce là pour éviter que tous les threads ne meurent de faim et ne progressent pendant un certain temps?

Modifier: (une dernière question)

Sur les plateformes où aucune instruction CAS matérielle n'existe, la version faible et la version forte sont implémentées à l'aide de LL/SC (comme ARM, PowerPC, etc.). Y a-t-il donc une différence entre les deux boucles suivantes? Pourquoi, le cas échéant? (Pour moi, ils devraient avoir des performances similaires.)

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_weak(..))
{ .. }

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_strong(..)) 
{ .. }

Je viens avec cette dernière question, vous mentionnez tous qu'il y a peut-être une différence de performance à l'intérieur d'une boucle. Il est également mentionné par la norme C++ 11 (ISO/IEC 14882):

Quand une comparaison et un échange sont en boucle, la version faible donnera de meilleures performances sur certaines plates-formes.

Mais comme analysé ci-dessus, deux versions dans une boucle devraient donner les mêmes performances/similaires. Quelle est la chose qui me manque?

76
Eric Z

J'essaie de répondre à cela moi-même, après avoir parcouru diverses ressources en ligne (par exemple, celui-ci et celui-ci ), la norme C++ 11, ainsi que le réponses données ici.

Les questions connexes sont fusionnées (par exemple, " pourquoi! Attendu?" est fusionné avec "pourquoi mettre compare_exchange_weak () dans une boucle?") et les réponses sont données en conséquence.


Pourquoi compare_exchange_weak () doit-il être en boucle dans presque toutes les utilisations?

Schéma type A

Vous devez réaliser une mise à jour atomique basée sur la valeur de la variable atomique. Un échec indique que la variable n'est pas mise à jour avec notre valeur souhaitée et nous voulons la réessayer. Notez que nous ne nous soucions pas vraiment s'il échoue en raison d'une écriture simultanée ou d'un échec parasite. Mais nous nous en soucions c'est nous qui font ce changement.

expected = current.load();
do desired = function(expected);
while (!current.compare_exchange_weak(expected, desired));

Un exemple réel est pour plusieurs threads d'ajouter simultanément un élément à une liste liée individuellement. Chaque thread charge d'abord le pointeur de tête, alloue un nouveau nœud et ajoute la tête à ce nouveau nœud. Enfin, il essaie d'échanger le nouveau nœud avec la tête.

Un autre exemple est d'implémenter mutex en utilisant std::atomic<bool>. Tout au plus, un thread peut entrer dans la section critique à la fois, en fonction du thread qui définit d'abord current sur true et quitte la boucle.

Schéma type B

C'est en fait le modèle mentionné dans le livre d'Anthony. Contrairement au modèle A, vous voulez que la variable atomique soit mise à jour une fois, mais peu vous importe qui le fait. Tant qu'elle n'est pas mise à jour, vous essayez à nouveau. Ceci est généralement utilisé avec des variables booléennes. Par exemple, vous devez implémenter un déclencheur pour qu'une machine à états puisse continuer. Quel que soit le thread qui tire sur la gâchette.

expected = false;
// !expected: if expected is set to true by another thread, it's done!
// Otherwise, it fails spuriously and we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

Notez que nous ne pouvons généralement pas utiliser ce modèle pour implémenter un mutex. Sinon, plusieurs threads peuvent se trouver à l'intérieur de la section critique en même temps.

Cela dit, il devrait être rare d'utiliser compare_exchange_weak() en dehors d'une boucle. Au contraire, il existe des cas où la version forte est utilisée. Par exemple.,

bool criticalSection_tryEnter(lock)
{
  bool flag = false;
  return lock.compare_exchange_strong(flag, true);
}

compare_exchange_weak N'est pas approprié ici car lorsqu'il revient en raison d'une défaillance parasite, il est probable que personne n'occupe encore la section critique.

Fil affamé?

Un point qui mérite d'être mentionné est que ce qui se passe si des pannes parasites continuent de se produire, affaiblissant ainsi le fil? Théoriquement, cela pourrait se produire sur les plates-formes lorsque compare_exchange_XXX() est implémenté comme une séquence d'instructions (par exemple, LL/SC). Un accès fréquent à la même ligne de cache entre LL et SC produira des échecs parasites continus. Un exemple plus réaliste est dû à une planification stupide où tous les threads simultanés sont entrelacés de la manière suivante.

Time
 |  thread 1 (LL)
 |  thread 2 (LL)
 |  thread 1 (compare, SC), fails spuriously due to thread 2's LL
 |  thread 1 (LL)
 |  thread 2 (compare, SC), fails spuriously due to thread 1's LL
 |  thread 2 (LL)
 v  ..

Cela peut-il arriver?

Cela n'arrivera pas éternellement, heureusement, grâce à ce que requiert C++ 11:

Les implémentations doivent garantir que les opérations de comparaison et d'échange faibles ne retournent pas systématiquement false, sauf si l'objet atomique a une valeur différente de celle attendue ou s'il y a des modifications simultanées de l'objet atomique.

Pourquoi prenons-nous la peine d'utiliser compare_exchange_weak () et d'écrire la boucle nous-mêmes? Nous pouvons simplement utiliser compare_exchange_strong ().

Ça dépend.

Cas 1: lorsque les deux doivent être utilisés dans une boucle. C++ 11 dit:

Quand une comparaison et un échange sont en boucle, la version faible donnera de meilleures performances sur certaines plates-formes.

Sur x86 (au moins actuellement. Peut-être qu'il aura recours à un schéma similaire à LL/SC un jour pour les performances lorsque davantage de cœurs seront introduits), les versions faible et forte sont essentiellement les mêmes car elles se résument toutes deux à l'instruction unique cmpxchg. Sur certaines autres plates-formes où compare_exchange_XXX() n'est pas implémenté atomiquement (ce qui signifie ici qu'il n'y a pas de primitive matérielle unique), la version faible à l'intérieur de la boucle peut gagner la bataille parce que la forte devra gérer les échecs parasites et réessayer en conséquence.

Mais,

rarement, nous pouvons préférer compare_exchange_strong() à compare_exchange_weak() même en boucle. Par exemple, lorsqu'il y a beaucoup de choses à faire entre la variable atomique est chargée et une nouvelle valeur calculée est échangée (voir function() ci-dessus). Si la variable atomique elle-même ne change pas fréquemment, nous n'avons pas besoin de répéter le calcul coûteux pour chaque défaillance parasite. Au lieu de cela, nous pouvons espérer que compare_exchange_strong() "absorbe" ces échecs et nous ne répétons le calcul qu'en cas d'échec en raison d'un changement de valeur réel.

Cas 2: Lorsque seulement compare_exchange_weak() doit être utilisé dans une boucle. C++ 11 dit également:

Quand une comparaison et un échange faibles nécessiteraient une boucle et qu'une boucle forte ne le serait pas, la plus forte est préférable.

C'est généralement le cas lorsque vous bouclez juste pour éliminer les pannes parasites de la version faible. Vous réessayez jusqu'à ce que l'échange réussisse ou échoue en raison d'une écriture simultanée.

expected = false;
// !expected: if it fails spuriously, we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

Au mieux, c'est réinventer les roues et faire la même chose que compare_exchange_strong(). Pire? Cette approche ne parvient pas à tirer pleinement parti des machines qui fournissent une comparaison et un échange non parasites dans le matériel .

Enfin, si vous bouclez pour d'autres choses (par exemple, voir "Modèle A typique" ci-dessus), il y a de fortes chances que compare_exchange_strong() soit également mis en boucle, ce qui nous ramène au cas précédent .

14
Eric Z

Pourquoi faire des échanges en boucle?

Habituellement, vous voulez que votre travail soit fait avant de continuer, vous mettez donc compare_exchange_weak dans une boucle afin qu'il essaie d'échanger jusqu'à ce qu'il réussisse (c'est-à-dire qu'il renvoie true).

Notez également que compare_exchange_strong est souvent utilisé en boucle. Il n'échoue pas en raison d'une défaillance parasite, mais il échoue en raison d'écritures simultanées.

Pourquoi utiliser weak au lieu de strong?

Assez facile: une défaillance parasite ne se produit pas souvent, ce n'est donc pas un gros problème de performances. En revanche, tolérer un tel échec permet une implémentation beaucoup plus efficace de la version weak (par rapport à strong) sur certaines plates-formes: strong doit toujours vérifier les erreurs parasites et le masquer. C'est cher.

Ainsi, weak est utilisé car il est beaucoup plus rapide que strong sur certaines plateformes

Quand faut-il utiliser weak et quand strong?

Le référence indique des conseils quand utiliser weak et quand utiliser strong:

Quand une comparaison et un échange sont en boucle, la version faible donnera de meilleures performances sur certaines plates-formes. Quand une comparaison et un échange faibles nécessiteraient une boucle et qu'une boucle forte ne le serait pas, la plus forte est préférable.

La réponse semble donc assez simple à retenir: si vous devez introduire une boucle uniquement à cause d'une défaillance parasite, ne le faites pas; utilisez strong. Si vous avez quand même une boucle, utilisez weak.

Pourquoi est-ce !expected dans l'exemple

Cela dépend de la situation et de sa sémantique souhaitée, mais généralement il n'est pas nécessaire pour l'exactitude. L'omission produirait une sémantique très similaire. Seulement dans le cas où un autre thread pourrait réinitialiser la valeur à false, la sémantique pourrait devenir légèrement différente (pourtant je ne trouve pas d'exemple significatif où vous voudriez cela). Voir le commentaire de Tony D. pour une explication détaillée.

C'est simplement une voie rapide lorsque un autre le thread écrit true: Ensuite, nous abandonnons au lieu d'essayer d'écrire à nouveau true.

À propos de votre dernière question

Mais comme analysé ci-dessus, deux versions dans une boucle devraient donner les mêmes performances/similaires. Quelle est la chose qui me manque?

De Wikipedia :

Les implémentations réelles de LL/SC ne réussissent pas toujours s'il n'y a pas de mises à jour simultanées de l'emplacement mémoire en question. Tout événement exceptionnel entre les deux opérations, comme un changement de contexte, un autre lien de chargement ou même (sur de nombreuses plates-formes) une autre opération de chargement ou de stockage, entraînera l'échec intempestif du stockage conditionnel. Les implémentations plus anciennes échoueront si des mises à jour sont diffusées sur le bus mémoire.

Ainsi, LL/SC échouera faussement lors du changement de contexte, par exemple. Maintenant, la version forte apporterait sa "propre petite boucle" pour détecter cette défaillance parasite et la masquer en réessayant. Notez que cette propre boucle est également plus compliquée qu'une boucle CAS habituelle, car elle doit faire la distinction entre une défaillance parasite (et la masquer) et une défaillance due à un accès simultané (ce qui entraîne un retour avec la valeur false). La version faible n'a pas une telle boucle.

Puisque vous fournissez une boucle explicite dans les deux exemples, il n'est tout simplement pas nécessaire d'avoir la petite boucle pour la version forte. Par conséquent, dans l'exemple avec la version strong, la vérification de l'échec est effectuée deux fois; une fois par compare_exchange_strong (ce qui est plus compliqué car il faut distinguer les pannes parasites et les accès simultanés) et une fois par votre boucle. Cette vérification coûteuse n'est pas nécessaire et la raison pour laquelle weak sera plus rapide ici.

Notez également que votre argument (LL/SC) est juste une possibilité de l'implémenter. Il y a plus de plates-formes qui ont même des jeux d'instructions différents. De plus (et surtout), notez que std::atomic doit prendre en charge toutes les opérations pour tous les types de données possibles, donc même si vous déclarez une structure de dix millions d'octets, vous pouvez utiliser compare_exchange sur ce. Même quand sur un CPU qui a CAS, vous ne pouvez pas CAS dix millions d'octets, donc le compilateur générera d'autres instructions (probablement acquisition de verrouillage, suivie d'une comparaison et d'un échange non atomique, suivie d'une libération de verrouillage). Maintenant, pensez à combien de choses peuvent se produire en échangeant dix millions d'octets. Ainsi, alors qu'une erreur parasite peut être très rare pour les échanges de 8 octets, elle peut être plus courante dans ce cas.

Donc, en un mot, C++ vous donne deux sémantiques, une "au mieux" (weak) et une "Je le ferai à coup sûr, peu importe combien de mauvaises choses pourraient se produire entre" "une (strong). La façon dont ceux-ci sont mis en œuvre sur différents types de données et plates-formes est un sujet totalement différent. Ne liez pas votre modèle mental à l'implémentation sur votre plateforme spécifique; la bibliothèque standard est conçue pour fonctionner avec plus d'architectures que vous n'en pensez. La seule conclusion générale que nous pouvons tirer est que garantir le succès est généralement plus difficile (et peut donc nécessiter un travail supplémentaire) que de simplement essayer et laisser la place à un échec possible.

63
gexicide

Pourquoi doit-il être en boucle dans presque toutes les utilisations?

Parce que si vous ne bouclez pas et qu'il échoue faussement, votre programme n'a rien fait d'utile - vous n'avez pas mis à jour l'objet atomique et vous ne savez pas quelle est sa valeur actuelle (Correction: voir le commentaire ci-dessous de Cameron). Si l'appel ne fait rien d'utile, quel est l'intérêt de le faire?

Est-ce à dire que nous allons boucler en cas de défaillance à cause de fausses pannes?

Oui.

Si c'est le cas, pourquoi prenons-nous la peine d'utiliser compare_exchange_weak() et d'écrire la boucle nous-mêmes? Nous pouvons simplement utiliser compare_exchange_strong () qui, je pense, devrait nous débarrasser des échecs parasites. Quels sont les cas d'utilisation courants de compare_exchange_weak ()?

Sur certaines architectures compare_exchange_weak est plus efficace, et les échecs parasites devraient être assez rares, il pourrait donc être possible d'écrire des algorithmes plus efficaces en utilisant la forme faible et une boucle.

En général, il est probablement préférable d'utiliser la version forte à la place si votre algorithme n'a pas besoin de boucler, car vous n'avez pas à vous soucier des pannes parasites. S'il doit de toute façon boucler même pour la version forte (et de nombreux algorithmes doivent quand même boucler), l'utilisation de la forme faible peut être plus efficace sur certaines plates-formes.

Pourquoi est-ce !expected là dans la condition de boucle?

La valeur aurait pu être définie sur true par un autre thread, donc vous ne voulez pas continuer à boucler en essayant de la définir.

Modifier:

Mais comme analysé ci-dessus, deux versions dans une boucle devraient donner les mêmes performances/similaires. Quelle est la chose qui me manque?

Il est certainement évident que sur les plates-formes où une défaillance parasite est possible, l'implémentation de compare_exchange_strong doit être plus compliqué, pour rechercher les pannes parasites et réessayer.

La forme faible revient juste en cas d'échec fallacieux, elle ne réessaye pas.

15
Jonathan Wakely

D'accord, j'ai donc besoin d'une fonction qui effectue un décalage gauche atomique. Mon processeur n'a pas d'opération native pour cela, et la bibliothèque standard n'a pas de fonction pour cela, il semble donc que j'écris la mienne. Voici:

void atomicLeftShift(std::atomic<int>* var, int shiftBy)
{
    do {
        int oldVal = std::atomic_load(var);
        int newVal = oldVal << shiftBy;
    } while(!std::compare_exchange_weak(oldVal, newVal));
}

Maintenant, il y a deux raisons pour lesquelles la boucle peut être exécutée plusieurs fois.

  1. Quelqu'un d'autre a changé la variable pendant que je faisais mon décalage gauche. Les résultats de mon calcul ne devraient pas être appliqués à la variable atomique, car cela effacerait effectivement l'écriture de quelqu'un d'autre.
  2. Mon processeur a roté et le faible CAS a échoué.

Honnêtement, je ne me soucie pas lequel. Le décalage vers la gauche est suffisamment rapide pour que je puisse tout aussi bien le refaire, même si l'échec était faux.

Ce qui est moins rapide, cependant, est le code supplémentaire que le CAS fort doit entourer le CAS faible pour être fort. Ce code ne fait pas grand-chose quand le CAS faible réussit ... mais quand il échoue, le CAS fort doit faire un travail de détective pour déterminer s'il s'agissait du cas 1 ou du cas 2. Ce travail de détective prend la forme d'une deuxième boucle, efficacement dans ma propre boucle. Deux boucles imbriquées. Imaginez que votre professeur d'algorithmes vous regarde en ce moment.

Et comme je l'ai mentionné précédemment, je me fiche du résultat de ce travail de détective! De toute façon, je vais refaire le CAS. Donc, utiliser un CAS fort ne me rapporte rien, et me fait perdre une petite mais mesurable efficacité.

En d'autres termes, un CAS faible est utilisé pour implémenter des opérations de mise à jour atomique. Le CAS fort est utilisé lorsque vous vous souciez du résultat du CAS.

12
Sneftel