Qu'est-ce que std :: atomic exactement?

174

Je comprends que std::atomic<>c'est un objet atomique. Mais dans quelle mesure atomique? À ma connaissance, une opération peut être atomique. Qu'entend-on exactement par rendre un objet atomique? Par exemple, s'il y a deux threads exécutant simultanément le code suivant:

a = a + 12;

Alors toute l'opération est-elle (disons add_twelve_to(int)) atomique? Ou des modifications sont-elles apportées à la variable atomique (so operator=())?

curieux
la source
9
Vous devez utiliser quelque chose comme a.fetch_add(12)si vous voulez un RMW atomique.
Kerrek SB
Ouais c'est ce que je ne comprends pas. Qu'entend-on par rendre un objet atomique. S'il y avait une interface, elle aurait simplement pu être rendue atomique avec un mutex ou un moniteur.
2
@AaryamanSagar cela résout un problème d'efficacité. Les mutex et les moniteurs supportent une surcharge de calcul. L'utilisation std::atomicpermet à la bibliothèque standard de décider de ce qui est nécessaire pour atteindre l'atomicité.
Drew Dormann
1
@AaryamanSagar: std::atomic<T>est un type qui permet des opérations atomiques. Cela n'améliore pas votre vie par magie, vous devez toujours savoir ce que vous voulez en faire. C'est pour un cas d'utilisation très spécifique, et les utilisations des opérations atomiques (sur l'objet) sont généralement très subtiles et doivent être pensées d'un point de vue non local. Donc, à moins que vous ne sachiez déjà cela et pourquoi vous voulez des opérations atomiques, le type n'est probablement pas d'une grande utilité pour vous.
Kerrek SB

Réponses:

188

Chaque instanciation et spécialisation complète de std :: atomic <> représente un type sur lequel différents threads peuvent fonctionner simultanément (leurs instances), sans augmenter le comportement indéfini:

Les objets de types atomiques sont les seuls objets C ++ exempts de courses de données; c'est-à-dire que si un thread écrit dans un objet atomique pendant qu'un autre thread lit à partir de celui-ci, le comportement est bien défini.

De plus, les accès aux objets atomiques peuvent établir une synchronisation inter-thread et ordonner des accès mémoire non atomiques comme spécifié par std::memory_order.

std::atomic<>encapsule les opérations qui, à l'époque pré-C ++ 11, devaient être effectuées en utilisant (par exemple) des fonctions imbriquées avec MSVC ou des bultins atomiques dans le cas de GCC.

En outre, std::atomic<>vous donne plus de contrôle en autorisant diverses commandes de mémoire qui spécifient des contraintes de synchronisation et de classement. Si vous souhaitez en savoir plus sur les atomes et le modèle de mémoire C ++ 11, ces liens peuvent être utiles:

Notez que, pour les cas d'utilisation typiques, vous utiliseriez probablement des opérateurs arithmétiques surchargés ou un autre ensemble d'entre eux :

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Étant donné que la syntaxe de l'opérateur ne vous permet pas de spécifier l'ordre de la mémoire, ces opérations seront effectuées avec std::memory_order_seq_cst , car il s'agit de l'ordre par défaut pour toutes les opérations atomiques en C ++ 11. Elle garantit la cohérence séquentielle (ordre global total) entre toutes les opérations atomiques.

Dans certains cas, cependant, cela peut ne pas être requis (et rien n'est gratuit), vous pouvez donc utiliser une forme plus explicite:

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Maintenant, votre exemple:

a = a + 12;

n'évaluera pas à un seul op atomique: cela entraînera a.load()(qui est atomique lui-même), puis une addition entre cette valeur et 12et a.store()(également atomique) du résultat final. Comme je l'ai noté plus tôt,std::memory_order_seq_cst sera utilisé ici.

Cependant, si vous écrivez a += 12, ce sera une opération atomique (comme je l'ai déjà noté) et équivaut à peu près àa.fetch_add(12, std::memory_order_seq_cst) .

Quant à votre commentaire:

Un régulier inta des charges et des magasins atomiques. Quel est l'intérêt de l'envelopper atomic<>?

Votre déclaration n'est vraie que pour les architectures qui offrent une telle garantie d'atomicité pour les magasins et / ou les charges. Il existe des architectures qui ne font pas cela. En outre, il est généralement nécessaire que les opérations soient effectuées sur une adresse alignée mot / dword pour être atomique, std::atomic<>c'est quelque chose qui est garanti atomique sur chaque plate-forme, sans exigences supplémentaires. De plus, cela vous permet d'écrire du code comme celui-ci:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

Notez que la condition d'assertion sera toujours vraie (et donc ne se déclenchera jamais), vous pouvez donc toujours être sûr que les données sont prêtes après la whilesortie de la boucle. C'est parce que:

  • store()à l'indicateur est exécuté après avoir sharedDataété défini (nous supposons que generateData()renvoie toujours quelque chose d'utile, en particulier, ne retourne jamais NULL) et utilise std::memory_order_releaseorder:

memory_order_release

Une opération de stockage avec cet ordre de mémoire exécute l' opération de libération : aucune lecture ou écriture dans le thread actuel ne peut être réorganisée après ce stockage. Toutes les écritures dans le thread actuel sont visibles dans les autres threads qui acquièrent la même variable atomique

  • sharedDataest utilisé après la whilesortie de la boucle, et donc après l' load()indicateur from retournera une valeur non nulle. load()utilise l' std::memory_order_acquireordre:

std::memory_order_acquire

Une opération de chargement avec cet ordre de mémoire effectue l' opération d' acquisition sur l'emplacement mémoire affecté: aucune lecture ou écriture dans le thread actuel ne peut être réorganisée avant ce chargement. Toutes les écritures dans d'autres threads qui libèrent la même variable atomique sont visibles dans le thread actuel .

Cela vous donne un contrôle précis sur la synchronisation et vous permet de spécifier explicitement comment votre code peut / ne peut pas / ne se comportera / ne se comportera pas. Cela ne serait pas possible si la seule garantie était l'atomicité elle-même. Surtout lorsqu'il s'agit de modèles de synchronisation très intéressants comme la commande de consommation de versions .

Mateusz Grzejek
la source
2
Existe-t-il réellement des architectures qui n'ont pas de charges atomiques et de magasins pour des primitives comme ints?
7
Ce n'est pas seulement une question d'atomicité. c'est aussi l'ordre, le comportement dans les systèmes multicœurs, etc. Vous voudrez peut-être lire cet article .
Mateusz Grzejek
4
@AaryamanSagar Si je ne me trompe pas, même sur x86, les lectures et écritures sont atomiques UNIQUEMENT si alignées sur les limites des mots.
v.shashenko
@MateuszGrzejek J'ai pris une référence à un type atomique. Pourriez-vous vérifier si ce qui suit garantirait toujours le fonctionnement atomique sur l'attribution d'objet ideone.com/HpSwqo
xAditya3393
3
@TimMB Oui, normalement, vous auriez (au moins) deux situations, où l'ordre d'exécution peut être modifié: (1) le compilateur peut réorganiser les instructions (autant que le standard le permet) afin de fournir de meilleures performances du code de sortie (sur la base de l'utilisation des registres du processeur, des prédictions, etc.) et (2) Le processeur peut exécuter des instructions dans un ordre différent pour, par exemple, minimiser le nombre de points de synchronisation du cache. Les contraintes de tri fournies pour std::atomic( std::memory_order) servent exactement à limiter les réorganisations autorisées.
Mateusz Grzejek
20

Je comprends que cela std::atomic<>rend un objet atomique.

C'est une question de perspective ... vous ne pouvez pas l'appliquer à des objets arbitraires et faire en sorte que leurs opérations deviennent atomiques, mais les spécialisations fournies pour (la plupart) des types intégraux et des pointeurs peuvent être utilisées.

a = a + 12;

std::atomic<>ne simplifie pas (utilisez des expressions de modèle pour) cela en une seule opération atomique, au lieu de cela, le operator T() const volatile noexceptmembre fait un atomique load()de a, puis douze est ajouté, et operator=(T t) noexceptfait un store(t).

Tony Delroy
la source
C'est ce que je voulais demander. Un int régulier a des charges et des magasins atomiques. Quel est l'intérêt de l'envelopper avec atomic <>
8
@AaryamanSagar Le simple fait de modifier une normale intne garantit pas de manière portative que le changement est visible à partir d'autres threads, et sa lecture ne garantit pas non plus que vous voyez les changements d'autres threads, et certaines choses comme my_int += 3ne sont pas garanties d'être effectuées de manière atomique à moins que vous n'utilisiez std::atomic<>- elles peuvent impliquer une séquence d'extraction, puis d'ajout, puis de stockage, dans laquelle un autre thread essayant de mettre à jour la même valeur peut entrer après l'extraction et avant le magasin, et clobber la mise à jour de votre thread.
Tony Delroy
"Le simple fait de modifier un int normal ne garantit pas que le changement est visible depuis d'autres threads " C'est pire que cela: toute tentative de mesurer cette visibilité aboutirait à UB.
curiousguy
8

std::atomic existe parce que de nombreux ISA ont une prise en charge matérielle directe

Ce que dit la norme C ++ std::atomica été analysé dans d'autres réponses.

Voyons maintenant ce qui se std::atomiccompile pour obtenir un autre type d'aperçu.

Le principal point à retenir de cette expérience est que les processeurs modernes prennent directement en charge les opérations sur les entiers atomiques, par exemple le préfixe LOCK dans x86, et std::atomicexistent essentiellement comme une interface portable pour ces instructions: que signifie l'instruction "lock" dans l'assemblage x86? Dans aarch64, LDADD serait utilisé.

Cette prise en charge permet des alternatives plus rapides à des méthodes plus générales telles que std::mutex, qui peuvent rendre atomiques des sections multi-instructions plus complexes, au prix d'être plus lentes que std::atomicparce std::mutexqu'elles effectuent des futexappels système sous Linux, qui est bien plus lent que les instructions de l'utilisateur émises par std::atomic, voir aussi: std :: mutex crée-t-il une clôture?

Considérons le programme multi-thread suivant qui incrémente une variable globale sur plusieurs threads, avec différents mécanismes de synchronisation en fonction du préprocesseur défini est utilisé.

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub en amont .

Compilez, exécutez et démontez:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Sortie de condition de concurrence "incorrecte" extrêmement probable pour main_fail.out:

expect 400000
global 100000

et la sortie "droite" déterministe des autres:

expect 400000
global 400000

Démontage de main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Démontage de main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Démontage de main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Conclusions:

  • la version non atomique enregistre le global dans un registre et incrémente le registre.

    Par conséquent, à la fin, il est très probable que quatre écritures reviennent à global avec la même valeur «incorrecte» de 100000.

  • std::atomiccompile en lock addq. Le préfixe LOCK permet d' incextraire, de modifier et de mettre à jour la mémoire de manière atomique.

  • notre préfixe LOCK d'assembly en ligne explicite se compile presque de la même manière que std::atomic, sauf que our incest utilisé à la place de add. Je ne sais pas pourquoi GCC a choisi add, étant donné que notre INC a généré un décodage de 1 octet plus petit.

ARMv8 pourrait utiliser LDAXR + STLXR ou LDADD dans les processeurs plus récents: Comment démarrer des threads en C brut?

Testé dans Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
la source