Exemple de code IBM, les fonctions non réentrantes ne fonctionnent pas sur mon système

11

J'étudiais la rentrée dans la programmation. Sur ce site d'IBM (vraiment bon). J'ai fondé un code, copié ci-dessous. C'est le premier code qui arrive sur le site Web.

Le code essaie de montrer les problèmes liés à l'accès partagé aux variables dans un développement non linéaire d'un programme texte (asynchronicité) en imprimant deux valeurs qui changent constamment dans un "contexte dangereux".

#include <signal.h>
#include <stdio.h>

struct two_int { int a, b; } data;

void signal_handler(int signum){
   printf ("%d, %d\n", data.a, data.b);
   alarm (1);
}

int main (void){
   static struct two_int zeros = { 0, 0 }, ones = { 1, 1 };

   signal (SIGALRM, signal_handler); 
   data = zeros;
   alarm (1);
   while (1){
       data = zeros;
       data = ones;
   }
}

Les problèmes sont apparus lorsque j'ai essayé d'exécuter le code (ou mieux, n'apparaissait pas). J'utilisais gcc version 6.3.0 20170516 (Debian 6.3.0-18 + deb9u1) dans la configuration par défaut. La sortie erronée ne se produit pas. La fréquence d'obtention de «mauvaises» valeurs de paire est 0!

Que se passe-t-il après tout? Pourquoi n'y a-t-il pas de problème de ré-entrée en utilisant des variables globales statiques?

Daniel Bandeira
la source
1
Assurez-vous que toute optimisation du compilateur est désactivée et réessayez
roaima
Je suppose que ... mais quelles options changerais-je? Je n'ai aucune idée. :-(
Daniel Bandeira
5
Cela ressemble à une question de programmation (débordement de pile). Il ne semble pas bien placé ici. (Désolé, il y avait moins de sous-sites; c'est tellement découpé. Mais c'est comme ça.)
ctrl-alt-delor
1
Le code rentrant le plus simple est immuable.
ctrl-alt-delor
Au premier moment, je pense que la question serait liée à l'environnement gcc et Linux. Évolution, par exemple, de la planification du système d'exploitation (exécution de plus de texte de programme après le signal d'interruption avant d'appeler la routine du gestionnaire), par exemple.
Daniel Bandeira

Réponses:

12

Ce n'est pas vraiment une réintégration ; vous n'exécutez pas une fonction deux fois dans le même thread (ou dans des threads différents). Vous pouvez l'obtenir via la récursivité ou en passant l'adresse de la fonction actuelle comme argument de pointeur de fonction de rappel à une autre fonction. (Et ce ne serait pas dangereux car ce serait synchrone).

Ceci est tout simplement de la course aux données vanille UB (Undefined Behavior) entre un gestionnaire de signal et le thread principal: seul sig_atomic_test garanti sans danger pour cela . D'autres peuvent arriver à fonctionner, comme dans votre cas où un objet de 8 octets peut être chargé ou stocké avec une instruction sur x86-64, et le compilateur arrive à choisir cet asm. (Comme le montre la réponse de @ icarus).

Voir Programmation MCU - L'optimisation C ++ O2 se casse en boucle - un gestionnaire d'interruption sur un microcontrôleur monocœur est fondamentalement la même chose qu'un gestionnaire de signal dans un programme à thread unique. Dans ce cas, le résultat de l'UB est qu'une charge s'est hissée hors d'une boucle.

Votre cas de test de déchirure se produisant réellement en raison de la course aux données UB a probablement été développé / testé en mode 32 bits, ou avec un compilateur plus ancien qui chargeait les membres de la structure séparément.

Dans votre cas, le compilateur peut optimiser les magasins hors de la boucle infinie car aucun programme sans UB n'a pu les observer. datan'est pas _Atomicouvolatile , et il n'y a pas d'autres effets secondaires dans la boucle. Il n'y a donc aucun moyen pour un lecteur de se synchroniser avec cet écrivain. Cela se produit en fait si vous compilez avec l'optimisation activée ( Godbolt montre une boucle vide en bas de main). J'ai également changé la structure en deux long long, et gcc utilise un seul movdqamagasin de 16 octets avant la boucle. (Ce n'est pas garanti atomique, mais c'est en pratique sur presque tous les processeurs, en supposant qu'il est aligné, ou sur Intel ne franchit tout simplement pas une limite de ligne de cache. Pourquoi l'affectation d'entiers sur une variable naturellement alignée atomique sur x86? )

Donc, la compilation avec l'optimisation activée casserait également votre test et vous montrerait la même valeur à chaque fois. C n'est pas un langage d'assemblage portable.

volatile struct two_intforcerait également le compilateur à ne pas les optimiser, mais ne le forcerait pas à charger / stocker la structure entière atomiquement. (Cela ne l' empêcherait pas non plus .) Notez que cela volatilen'évite pas l' UB de course aux données, mais dans la pratique, cela suffit pour la communication entre les threads et comment les gens ont construit des atomes atomiques roulés à la main (avec asm en ligne) avant C11 / C ++ 11, pour les architectures CPU normales. Ils cache cohérent donc volatileest en pratique essentiellement similaire à _Atomicavecmemory_order_relaxed pour pur-charge et pure magasin, si elle est utilisée pour les types assez étroit que le compilateur utilisera une instruction unique afin de ne pas déchirer. Et bien sûrvolatilen'a aucune garantie de la norme ISO C par rapport à l'écriture de code qui se compile dans le même asm using _Atomicet mo_relaxed.


Si vous aviez une fonction qui fonctionnait global_var++;sur un intou long longque vous exécutez à partir de main et de manière asynchrone à partir d'un gestionnaire de signaux, ce serait un moyen d'utiliser la rentrée pour créer une UB de course de données.

Selon la façon dont il a été compilé (vers une destination mémoire inc ou add, ou pour séparer load / inc / store), il serait atomique ou non par rapport aux gestionnaires de signaux dans le même thread. Voir Est-ce que num ++ peut être atomique pour 'int num'? pour en savoir plus sur l'atomicité sur x86 et en C ++. (C11 stdatomic.het l' _Atomicattribut fournissent des fonctionnalités équivalentes au std::atomic<T>modèle de C ++ 11 )

Une interruption ou une autre exception ne peut pas se produire au milieu d'une instruction, donc un ajout de destination de mémoire est atomique wrt. le contexte bascule sur un processeur monocœur. Seul un écrivain DMA (cohérent en cache) peut "monter" un incrément à partir d'un add [mem], 1sans lockpréfixe sur un processeur monocœur. Il n'y a pas d'autres cœurs sur lesquels un autre thread pourrait s'exécuter.

C'est donc similaire au cas des signaux: un gestionnaire de signaux s'exécute au lieu de l'exécution normale du thread qui gère le signal, il ne peut donc pas être géré au milieu d'une instruction.

Peter Cordes
la source
2
J'ai été poussé à accepter la vôtre comme la meilleure réponse, même si la réponse d'Icaru me suffisait. Les concepts clairs que vous nous avez donnés me donnent un ensemble de sujets à étudier toute cette journée (et plus loin). En fait, j'ai à peine ce que vous écrivez dans les deux premiers paragraphes à première vue. Je vous remercie! Si vous publiez des articles sur Internet sur les ordinateurs et la programmation, donnez-nous le lien!
Daniel Bandeira
17

En regardant l' explorateur du compilateur godbolt (après avoir ajouté le manquant #include <unistd.h>), on voit que pour presque tous les compilateurs x86_64, le code généré utilise des mouvements QWORD pour charger le oneset zerosdans une seule instruction.

        mov     rax, QWORD PTR main::ones[rip]
        mov     QWORD PTR data[rip], rax

Le site IBM dit On most machines, it takes several instructions to store a new value in data, and the value is stored one word at a time. ce qui aurait pu être vrai pour les processeurs types en 2005, mais comme le montre le code, ce n'est plus le cas actuellement. Changer la structure pour avoir deux longs plutôt que deux pouces montrerait le problème.

J'ai déjà écrit que c'était "atomique", ce qui était paresseux. Le programme ne fonctionne que sur un seul processeur. Chaque instruction se terminera du point de vue de ce cpu (en supposant qu'il n'y a rien d'autre qui altère la mémoire tel que dma).

Donc, au Cniveau, il n'est pas défini que le compilateur choisira une seule instruction pour écrire la structure, et ainsi la corruption mentionnée dans le document IBM peut se produire. Les compilateurs modernes ciblant les processeurs actuels utilisent une seule instruction. Une seule instruction suffit pour éviter la corruption d'un programme à thread unique.

icare
la source
3
Essayez de changer le type de données de intà long longet compilez en 32 bits. La leçon est que vous ne savez jamais si / quand il se cassera.
ctrl-alt-delor
2
cela signifie, dans ma machine, l'affectation de ces deux valeurs est une opération atomique? (compte tenu de la compilation pour l'architecture x86_64)
Daniel Bandeira
1
long longcompile toujours en une instruction pour x86-64: 16 octets movdqa. Sauf si vous désactivez l'optimisation, comme dans votre lien Godbolt. (Le -O0mode par défaut de GCC est le mode débogage, qui est plein de bruit de stockage / rechargement et généralement pas intéressant à regarder.)
Peter Cordes
J'ai changé le type en "long long" après avoir lu tous les commentaires. Le résultat était intéressant: les résultats attendus ont été atteints et, en mettant en place certains compteurs, il a pu améliorer d'autres conceptions comme la façon dont le taux de données non concordantes est influencé par le reste du code. Merci pour toute aide!
Daniel Bandeira