web-dev-qa-db-fra.com

Où est le verrou pour un std :: atomic?

Si une structure de données contient plusieurs éléments, sa version atomique ne peut pas (toujours) être sans verrou. On m'a dit que cela est vrai pour les types plus volumineux, car le processeur ne peut pas modifier les données de manière atomique sans utiliser une sorte de verrou.

par exemple:

#include <iostream>
#include <atomic>

struct foo {
    double a;
    double b;
};

std::atomic<foo> var;

int main()
{
    std::cout << var.is_lock_free() << std::endl;
    std::cout << sizeof(foo) << std::endl;
    std::cout << sizeof(var) << std::endl;
}

la sortie (Linux/gcc) est:

0
16
16

Puisque l'atome et foo ont la même taille, je ne pense pas qu'un verrou est stocké dans l'atome.

Ma question est:
Si une variable atomique utilise un verrou, où est-elle stockée et que cela signifie-t-il pour plusieurs occurrences de cette variable?

67
curiousguy12

Le moyen le plus simple de répondre à de telles questions consiste généralement à regarder l’Assemblée qui en résulte et à partir de là.

Compiler ce qui suit (j'ai agrandi votre structure pour éviter les shenanigans du compilateur astucieux):

#include <atomic>

struct foo {
    double a;
    double b;
    double c;
    double d;
    double e;
};

std::atomic<foo> var;

void bar()
{
    var.store(foo{1.0,2.0,1.0,2.0,1.0});
}

Dans la version 5.0.0, le résultat suivant est inférieur à -O3: voir sur godbolt

bar(): # @bar()
  sub rsp, 40
  movaps xmm0, xmmword ptr [rip + .LCPI0_0] # xmm0 = [1.000000e+00,2.000000e+00]
  movaps xmmword ptr [rsp], xmm0
  movaps xmmword ptr [rsp + 16], xmm0
  movabs rax, 4607182418800017408
  mov qword ptr [rsp + 32], rax
  mov rdx, rsp
  mov edi, 40
  mov esi, var
  mov ecx, 5
  call __atomic_store

Génial, le compilateur délègue un élément intrinsèque (__atomic_store), Cela ne nous dit pas ce qui se passe réellement ici. Cependant, comme le compilateur est open source, nous pouvons facilement trouver l’implémentation de l’intrinsèque (je l’ai trouvée dans https://github.com/llvm-mirror/compiler-rt/blob/master/lib/builtins /atomic.c ):

void __atomic_store_c(int size, void *dest, void *src, int model) {
#define LOCK_FREE_ACTION(type) \
    __c11_atomic_store((_Atomic(type)*)dest, *(type*)dest, model);\
    return;
  LOCK_FREE_CASES();
#undef LOCK_FREE_ACTION
  Lock *l = lock_for_pointer(dest);
  lock(l);
  memcpy(dest, src, size);
  unlock(l);
}

Il semble que la magie se passe dans lock_for_pointer(), alors regardons-le:

static __inline Lock *lock_for_pointer(void *ptr) {
  intptr_t hash = (intptr_t)ptr;
  // Disregard the lowest 4 bits.  We want all values that may be part of the
  // same memory operation to hash to the same value and therefore use the same
  // lock.  
  hash >>= 4;
  // Use the next bits as the basis for the hash
  intptr_t low = hash & SPINLOCK_MASK;
  // Now use the high(er) set of bits to perturb the hash, so that we don't
  // get collisions from atomic fields in a single object
  hash >>= 16;
  hash ^= low;
  // Return a pointer to the Word to use
  return locks + (hash & SPINLOCK_MASK);
}

Et voici notre explication: L'adresse de l'atome est utilisée pour générer une clé de hachage afin de sélectionner un verrou pré-alloué.

46
Frank

L’implémentation habituelle est une table de hachage de mutex (ou même de simples spinlocks sans repli sur le mode veille/réveil assisté par OS), en utilisant l’adresse de l’objet atomique comme clé . La fonction de hachage peut être aussi simple que d'utiliser simplement les bits bas de l'adresse sous forme d'index dans un tableau de taille 2, mais la réponse de @ Frank montre que l'implémentation std :: atomic de LLVM ne fait XOR = dans certains bits plus élevés, de sorte que vous n'obtenez pas automatiquement un alias lorsque les objets sont séparés par une grande puissance de 2 (ce qui est plus courant que tout autre arrangement aléatoire).

Je pense (mais je ne suis pas sûr) que g ++ et clang ++ sont compatibles ABI; c'est-à-dire qu'ils utilisent la même fonction de hachage et la même table, ils conviennent donc du verrou qui sérialise l'accès à quel objet. Le verrouillage est tout fait dans libatomic, cependant, si vous liez dynamiquement libatomic, alors tout le code du même programme appelant __atomic_store_16 utilisera la même implémentation; clang ++ et g ++ s'accordent définitivement sur les noms de fonctions à appeler, et cela suffit. (Mais notez que seuls les objets atomiques sans verrouillage de la mémoire partagée entre différents processus fonctionnent: chaque processus possède sa propre table de hachage de verrous . les objets sont supposés fonctionner (et fonctionnent effectivement) dans la mémoire partagée sur des architectures de CPU normales, même si la région est mappée sur des adresses différentes.)

Les collisions de hachage signifient que deux objets atomiques peuvent partager le même verrou. Ce n’est pas un problème de correction, mais peut-être un problème de performances : au lieu de deux paires de threads se disputant séparément pour deux objets différents, vous pourriez avoir les 4 threads en lice pour accéder à soit objet. Cela est probablement inhabituel et vous souhaitez généralement que vos objets atomiques soient sans verrouillage sur les plates-formes qui vous intéressent. Mais la plupart du temps, vous ne recevez pas vraiment de malchance, et ça va au fond.

Les deadlocks ne sont pas possibles car il n'y a pas de std::atomic fonctions qui tentent de verrouiller deux objets à la fois. Ainsi, le code de la bibliothèque qui prend le verrou n'essaie jamais de prendre un autre verrou tout en maintenant l'un de ces verrous. Une contention/une sérialisation supplémentaire n'est pas un problème de correction, mais simplement des performances.


x86-64 objets de 16 octets avec GCC contre MSVC :

Les compilateurs peuvent utiliser lock cmpxchg16b pour implémenter un chargement/stockage atomique de 16 octets, ainsi que des opérations réelles de lecture-modification-écriture.

C'est mieux que le verrouillage, mais les performances sont médiocres comparées aux objets atomiques de 8 octets (par exemple, des charges pures se font concurrence à d'autres charges). C'est le seul moyen documenté de faire n'importe quoi avec 16 octets de manière atomique1.

Autant que je sache, MSVC n'utilise jamais lock cmpxchg16b pour les objets de 16 octets, et ils sont fondamentalement identiques à un objet de 24 ou 32 octets.

gcc6 et les versions antérieures lock cmpxchg16b lorsque vous compilez avec -mcx16 (malheureusement, cmpxchg16b n’est pas la ligne de base pour x86-64; les processeurs AMD K8 de première génération le manquent.)

gcc7 a décidé de toujours appeler libatomic et de ne jamais signaler les objets de 16 octets sans verrouillage, même si les fonctions libatomic utilisaient toujours lock cmpxchg16b sur les machines où l’instruction est disponible. Voir is_lock_free () a renvoyé la valeur false après la mise à niveau vers MacPorts gcc 7. . Le message de la liste de diffusion gcc expliquant ce changement est ici .

Vous pouvez utiliser un hack d'union pour obtenir un pointeur ABA raisonnablement bon marché + un compteur sur x86-64 avec gcc/clang: Comment puis-je implémenter un compteur ABA avec c ++ 11 CAS? . lock cmpxchg16b pour les mises à jour du pointeur et du compteur, mais simple mov charge uniquement le pointeur. Cela ne fonctionne que si l'objet de 16 octets est réellement sans verrouillage en utilisant lock cmpxchg16b, bien que.


Note 1 : movdqa la charge/mémoire de 16 octets est atomique dans la pratique sur certains (mais pas = toutes) microarchitectures x86, et il n’existe aucun moyen fiable ou documenté de détecter son utilisation. Voir Pourquoi l'affectation d'entiers sur une variable naturellement alignée est-elle atomique sur x86? , et instructions SSE: quelles CPU peuvent exécuter des opérations de mémoire atomique 16B? pour un exemple où K10 Opteron montre une déchirure aux limites 8B uniquement entre sockets avec HyperTransport.

Ainsi, les auteurs de compilateur doivent faire preuve de prudence et ne peuvent pas utiliser movdqa comme ils utilisent SSE2 movq pour un chargement/stockage atomique sur 8 octets en code 32 bits. Il serait intéressant que les fournisseurs de CPU puissent documenter certaines garanties pour certaines microarchitectures ou ajouter des bits de fonctionnalité CPUID pour un chargement/stockage de vecteurs alignés atomiques de 16, 32 et 64 octets (avec SSE, AVX et AVX512). Peut-être quels vendeurs mobo pourraient désactiver dans le firmware sur des machines funky à plusieurs sockets qui utilisent des puces de colle spéciales de cohérence qui ne transfèrent pas des lignes de cache entières.

62
Peter Cordes

À partir de 29.5.9 du standard C++:

Remarque: La représentation d'une spécialisation atomique n'a pas besoin d'avoir la même taille que son type d'argument correspondant. Les spécialisations doivent avoir la même taille chaque fois que possible, afin de réduire les efforts requis pour porter le code existant. - note de fin

Il est préférable que la taille d'un atomique soit identique à la taille de son type d'argument, bien que cela ne soit pas nécessaire. Pour ce faire, évitez les verrous ou stockez-les dans une structure séparée. Comme les autres réponses l'ont déjà expliqué clairement, une table de hachage est utilisée pour contenir tous les verrous. C'est le moyen le plus efficace en termes de mémoire de stocker un nombre quelconque de verrous pour tous les objets atomiques utilisés.

11
Hadi Brais