Le programme multithreading est bloqué en mode optimisé mais s'exécute normalement en -O0

68

J'ai écrit un simple programme multithreading comme suit:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Il se comporte normalement en mode débogage dans Visual studio ou -O0en gc c et imprime le résultat après 1quelques secondes. Mais il est bloqué et n'imprime rien en mode Release ou -O1 -O2 -O3.

sz ppeter
la source
Les commentaires ne sont pas pour une discussion approfondie; cette conversation a été déplacée vers le chat .
Samuel Liew

Réponses:

100

Deux threads, accédant à une variable non atomique et non gardée sont UB Cela concerne finished. Vous pouvez faire finisheddu type std::atomic<bool>pour résoudre ce problème.

Ma solution:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Production:

result =1023045342
main thread id=140147660588864

Démo en direct sur coliru


Quelqu'un peut penser: «C'est un bool- probablement un peu. Comment cela peut-il être non atomique? ' (Je l'ai fait quand j'ai commencé avec le multi-threading moi-même.)

Mais notez que le manque de déchirure n'est pas la seule chose qui std::atomicvous donne. Cela rend également l'accès en lecture + écriture simultané à partir de plusieurs threads bien défini, empêchant le compilateur de supposer que la relecture de la variable verra toujours la même valeur.

La fabrication d'un boolnon-atomique non protégé peut entraîner des problèmes supplémentaires:

  • Le compilateur peut décider d'optimiser une variable dans un registre ou même plusieurs accès CSE en un seul et lever une charge d'une boucle.
  • La variable peut être mise en cache pour un cœur de processeur. (Dans la vraie vie, les processeurs ont des caches cohérentes . Ce n'est pas un vrai problème, mais la norme C de est assez lâche pour couvrir les implémentations C ++ hypothétique sur la mémoire partagée non cohérente où atomic<bool>avec memory_order_relaxedmagasin / charge fonctionnerait, mais où volatilene serait pas. L' utilisation volatile pour cela serait UB, même si cela fonctionne en pratique sur de vraies implémentations C ++.)

Pour éviter que cela se produise, le compilateur doit être explicitement averti de ne pas le faire.


Je suis un peu surpris par l'évolution de la discussion concernant la relation potentielle de volatilecette question. Ainsi, je voudrais dépenser mes deux cents:

Scheff
la source
4
J'ai jeté un coup d'œil func()et j'ai pensé "Je pourrais optimiser cela" L'optimiseur ne se soucie pas du tout des threads, et détectera la boucle infinie, et la transformera avec plaisir en un "moment (Vrai)" Si nous regardons Godbolt .org / z / Tl44iN nous pouvons le voir. Si c'est fini, Trueil revient. Sinon, il entre dans un saut inconditionnel de retour à lui-même (une boucle infinie) au label.L5
Baldrickk
2
@val: il n'y a fondamentalement aucune raison d'abuser volatileen C ++ 11 car vous pouvez obtenir un asm identique avec atomic<T>et std::memory_order_relaxed. Cela fonctionne bien sur du matériel réel: les caches sont cohérents, donc une instruction de chargement ne peut pas continuer à lire une valeur périmée une fois qu'un magasin sur un autre noyau s'engage à y mettre en cache. (MESI)
Peter Cordes
5
@PeterCordes Using volatileest toujours UB. Vous ne devriez vraiment jamais supposer que quelque chose est définitivement et clairement UB est sûr juste parce que vous ne pouvez pas penser à une façon dont cela pourrait mal tourner et cela a fonctionné lorsque vous l'avez essayé. Cela a incité les gens à brûler encore et encore.
David Schwartz
2
@Damon Mutexes a publié / acquis la sémantique. Le compilateur n'est pas autorisé à optimiser la lecture si un mutex a été verrouillé auparavant, donc à protéger finishedavec un std::mutexworks (sans volatileou atomic). En fait, vous pouvez remplacer tous les atomes par un schéma "simple" valeur + mutex; cela fonctionnerait toujours et serait plus lent. atomic<T>est autorisé à utiliser un mutex interne; seul atomic_flagest garanti sans verrou.
Erlkoenig
42

La réponse de Scheff décrit comment réparer votre code. J'ai pensé ajouter quelques informations sur ce qui se passe réellement dans ce cas.

J'ai compilé votre code sur godbolt en utilisant le niveau d'optimisation 1 ( -O1). Votre fonction se compile comme suit:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

Alors, que se passe-t-il ici? Tout d'abord, nous avons une comparaison: cmp BYTE PTR finished[rip], 0- cela vérifie si finishedc'est faux ou non.

Si ce n'est pas faux (ou vrai), nous devons quitter la boucle lors de la première exécution. Ceci accompli par jne .L4lequel j umps quand n ot e qual pour étiqueter .L4où la valeur de i( 0) est stockée dans un registre pour une utilisation ultérieure et la fonction retourne.

S'il est faux cependant, nous passons à

.L5:
  jmp .L5

Il s'agit d'un saut inconditionnel, à étiqueter .L5qui se trouve être la commande de saut elle-même.

En d'autres termes, le thread est placé dans une boucle occupée infinie.

Alors pourquoi est-ce arrivé?

En ce qui concerne l'optimiseur, les threads sont en dehors de sa compétence. Il suppose que les autres threads ne lisent ni n'écrivent les variables simultanément (car ce serait l'UB de la course aux données). Vous devez lui dire qu'il ne peut pas optimiser les accès. C'est là que la réponse de Scheff entre en jeu. Je ne prendrai pas la peine de le répéter.

Étant donné que l'optimiseur n'est pas informé que la finishedvariable peut potentiellement changer pendant l'exécution de la fonction, il voit qu'il finishedn'est pas modifié par la fonction elle-même et suppose qu'elle est constante.

Le code optimisé fournit les deux chemins de code qui résulteront de l'entrée de la fonction avec une valeur booléenne constante; soit il exécute la boucle à l'infini, soit la boucle n'est jamais exécutée.

au -O0compilateur (comme prévu) n'optimise pas le corps de la boucle et la comparaison:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

par conséquent, la fonction, lorsqu'elle n'est pas optimisée, fonctionne, le manque d'atomicité ici n'est généralement pas un problème, car le code et le type de données sont simples. Le pire que nous pourrions rencontrer ici est probablement une valeur iqui est inférieure de un à ce qu'elle devrait être.

Un système plus complexe avec des structures de données est beaucoup plus susceptible d'entraîner des données corrompues ou une exécution incorrecte.

Baldrickk
la source
3
C ++ 11 intègre des threads et un modèle de mémoire sensible aux threads dans le langage lui-même. Cela signifie que les compilateurs ne peuvent pas inventer des écritures même sur des non- atomicvariables dans du code qui n'écrit pas ces variables. Par exemple, if (cond) foo=1;ne peut pas être transformé en asm, c'est comme foo = cond ? 1 : foo;parce que load + store (pas un RMW atomique) pourrait marcher sur une écriture d'un autre thread. Les compilateurs évitaient déjà des trucs comme ça parce qu'ils voulaient être utiles pour écrire des programmes multi-threads, mais C ++ 11 a rendu officiel que les compilateurs ne devaient pas casser le code là où 2 threads écrivent a[1]eta[2]
Peter Cordes
2
Mais oui, autre que cette surévaluation sur la façon dont les compilateurs ne sont pas au courant des discussions du tout , votre réponse est correcte. La course aux données UB est ce qui permet de hisser des charges de variables non atomiques, y compris des globales, et les autres optimisations agressives que nous voulons pour le code à thread unique. Programmation MCU - L'optimisation C ++ O2 se casse en boucle sur l'électronique.SE est ma version de cette explication.
Peter Cordes
1
@PeterCordes: Un avantage de Java utilisant un GC est que la mémoire des objets ne sera pas recyclée sans une barrière de mémoire globale intermédiaire entre l'ancien et le nouvel usage, ce qui signifie que tout noyau qui examine un objet verra toujours une certaine valeur qu'il a tenue à un moment donné après la première publication de la référence. Bien que les barrières de mémoire mondiales puissent être très coûteuses si elles sont utilisées fréquemment, elles peuvent réduire considérablement le besoin de barrières de mémoire ailleurs, même lorsqu'elles sont utilisées avec parcimonie.
supercat
1
Oui, je savais que c'était ce que vous essayiez de dire, mais je ne pense pas que votre formulation à 100% signifie cela. Dire l'optimiseur "les ignore complètement". n'est pas tout à fait raison: il est bien connu que le fait d'ignorer le threading lors de l'optimisation peut impliquer des choses comme le chargement de mot / modifier un octet dans le magasin de mots / mots, ce qui en pratique a provoqué des bogues où l'accès d'un thread à un caractère ou à un champ de bits se déroule sur un écrire dans un membre struct adjacent. Voir lwn.net/Articles/478657 pour l'histoire complète, et comment seul le modèle de mémoire C11 / C ++ 11 rend une telle optimisation illégale, pas seulement indésirable dans la pratique.
Peter Cordes
1
Non, c'est bien .. Merci @PeterCordes. J'apprécie l'amélioration.
Baldrickk
5

Par souci d'exhaustivité dans la courbe d'apprentissage; vous devez éviter d'utiliser des variables globales. Vous avez cependant fait du bon travail en le rendant statique, il sera donc local à l'unité de traduction.

Voici un exemple:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

En direct sur wandbox

Oubli
la source
1
Pourrait également déclarer finishedcomme staticdans le bloc fonction. Il sera toujours initialisé une seule fois, et s'il est initialisé à une constante, cela ne nécessite pas de verrouillage.
Davislor
Les accès à finishedpourraient également utiliser des std::memory_order_relaxedcharges et des magasins moins chers ; il n'y a pas de commande requise. d'autres variables dans l'un ou l'autre thread. Je ne suis pas sûr que la suggestion de @ Davislor ait du staticsens, cependant; si vous aviez plusieurs threads de spin-count, vous ne voudriez pas nécessairement les arrêter tous avec le même drapeau. Vous voulez écrire l'initialisation d' finishedune manière qui se compile en une simple initialisation, pas un magasin atomique, cependant. (Comme vous le faites avec la finished = false;syntaxe C ++ 17 de l'initialiseur par défaut. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Mettre l'indicateur dans un objet permet d'en avoir plus d'un, pour différents pools de threads, comme vous le dites. Le design original avait cependant un seul drapeau pour tous les threads.
Davislor