web-dev-qa-db-fra.com

Pourquoi la mutation de code d'une variable partagée sur plusieurs threads ne semble-t-elle PAS souffrir d'une situation de concurrence critique?

J'utilise Cygwin GCC et lance ce code:

#include <iostream>
#include <thread>
#include <vector>
using namespace std;

unsigned u = 0;

void foo()
{
    u++;
}

int main()
{
    vector<thread> threads;
    for(int i = 0; i < 1000; i++) {
        threads.Push_back (thread (foo));
    }
    for (auto& t : threads) t.join();

    cout << u << endl;
    return 0;
}

Compilé avec la ligne: g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o.

Il imprime 1000, ce qui est correct. Cependant, je m'attendais à un nombre inférieur en raison de threads écrasant une valeur précédemment incrémentée. Pourquoi ce code ne souffre-t-il pas d'un accès mutuel?

Ma machine de test a 4 cœurs et je ne mets aucune restriction sur le programme que je connaisse.

Le problème persiste lors du remplacement du contenu de la foo partagée par quelque chose de plus complexe, par exemple.

if (u % 3 == 0) {
    u += 4;
} else {
    u -= 1;
}
107
mafu

foo() est si court que chaque thread se termine probablement avant même que le suivant ne soit créé. Si vous ajoutez une période de sommeil aléatoire dans foo() avant le u++, vous pouvez commencer à voir ce que vous attendez.

268
Rob K

Il est important de comprendre qu'une condition de concurrence critique ne garantit pas que le code fonctionnera de manière incorrecte, mais simplement qu'il peut tout faire, car il s'agit d'un comportement non défini. Y compris courir comme prévu.

En particulier sur les machines X86 et AMD64, les conditions de concurrence dans certains cas causent rarement des problèmes, car de nombreuses instructions sont atomiques et que les garanties de cohérence sont très élevées. Ces garanties sont quelque peu réduites sur les systèmes multiprocesseurs où le préfixe de verrouillage est nécessaire pour que de nombreuses instructions soient atomiques.

Si sur votre machine, l’incrément est une opération atomique, cela fonctionnera probablement correctement même si, selon le standard de langue, il s’agit d’un comportement non défini.

Plus précisément, je pense que dans ce cas, le code peut être compilé en une instruction atomique Fetch and Add (ADD ou XADD in X86 Assembly) qui est effectivement atomique dans les systèmes à un seul processeur, mais dans les systèmes à plusieurs processeurs, cela ne correspond pas. garanti d'être atomique et un verrou serait nécessaire pour le faire. Si vous utilisez un système multiprocesseur, il y aura une fenêtre où les threads pourraient interférer et produire des résultats incorrects.

Plus précisément, j'ai compilé votre code dans Assembly à l'aide de https://godbolt.org/ et foo() est compilé en:

foo():
        add     DWORD PTR u[rip], 1
        ret

Cela signifie qu'il exécute uniquement une instruction add qui, pour un processeur unique, sera atomique (bien que, comme mentionné ci-dessus, ce ne soit pas le cas pour un système multiprocesseur).

59
Vality

Je pense que ce n'est pas tellement la chose si vous mettez un sommeil avant ou après le u++. C'est plutôt cette opération u++ se traduit par un code - comparé à la surcharge des threads en train d'appeler foo - exécuté très rapidement, de sorte qu'il est peu probable qu'il soit intercepté. Cependant, si vous "prolongez" l'opération u++, alors la condition de concurrence deviendra beaucoup plus probable:

void foo()
{
    unsigned i = u;
    for (int s=0;s<10000;s++);
    u = i+1;
}

résultat: 694


BTW: j'ai aussi essayé

if (u % 2) {
    u += 2;
} else {
    u -= 1;
}

et cela m’a donné la plupart du temps 1997, mais parfois 1995.

20
Stephan Lechner

Il souffre d'une situation de compétition. Placez usleep(1000); avant u++; dans foo et je vois une sortie différente (<1000) à chaque fois.

7
juf
  1. La réponse probable à la raison pour laquelle la condition de concurrence ne s’est pas manifestée pour vous, bien que existe, est que foo() est si rapide, comparée au temps nécessaire pour démarrer un thread , que chaque thread finisse avant que le suivant puisse même commencer. Mais...

  2. Même avec votre version d'origine, le résultat varie en fonction du système: je l'ai essayé à votre façon sur un Macbook (quad-core) et, en dix essais, j'ai obtenu 1000 trois fois, 999 six fois et 998 fois. La course est donc assez rare, mais clairement présente.

  3. Vous avez compilé avec '-g', qui permet de faire disparaître les bugs. J'ai recompilé votre code, toujours inchangé mais sans le '-g', et la course est devenue beaucoup plus prononcée: j'ai reçu 1000 fois, 999 trois fois, 998 deux fois, 997 deux fois, 996 fois et 992 fois.

  4. Ré. la suggestion d’ajouter un sommeil - cela aide, mais (a) un temps de sommeil fixe laisse les fils faussés par l’heure de début (sous réserve de la résolution de la minuterie), et (b) un sommeil aléatoire les éparpille lorsque nous voulons rapprochez-les. Au lieu de cela, je les coderais pour attendre un signal de départ afin de pouvoir tous les créer avant de les laisser travailler. Avec cette version (avec ou sans '-g'), J'obtiens des résultats partout, aussi bas que 974 et pas plus haut que 998:

    #include <iostream>
    #include <thread>
    #include <vector>
    using namespace std;
    
    unsigned u = 0;
    bool start = false;
    
    void foo()
    {
        while (!start) {
            std::this_thread::yield();
        }
        u++;
    }
    
    int main()
    {
        vector<thread> threads;
        for(int i = 0; i < 1000; i++) {
            threads.Push_back (thread (foo));
        }
        start = true;
        for (auto& t : threads) t.join();
    
        cout << u << endl;
        return 0;
    }
    
6
dgould