Un des utilisateurs du compilateur Visual C ++ a donné l'exemple de code suivant et a demandé pourquoi sa boucle avec la condition est exécutée sans fin, bien qu'à un moment donné la condition devrait s'arrêter et le cycle se terminer:
#include <windows.h> int x = 0, y = 1; int* ptr; DWORD CALLBACK ThreadProc(void*) { Sleep(1000); ptr = &y; return 0; } int main(int, char**) { ptr = &x; // starts out pointing to x DWORD id; HANDLE hThread = CreateThread(nullptr, 0, ThreadProc, 0, &id); // , ptr // while (*ptr == 0) { } return 0; }
Pour ceux qui ne connaissent pas les fonctionnalités spécifiques à la plateforme Windows, voici l'équivalent en C ++ pur:
#include <chrono> #include <thread> int x = 0, y = 1; int* ptr = &x; void ThreadProc() { std::this_thread::sleep_for(std::chrono::seconds(1)); ptr = &y; } int main(int, char**) { ptr = &x; // starts out pointing to x std::thread thread(ThreadProc); // , ptr // while (*ptr == 0) { } return 0; }
Ensuite, l'utilisateur a apporté sa compréhension du programme:
La boucle conditionnelle a été transformée en infini par le compilateur. Je vois cela dans le code assembleur généré, qui charge une fois la valeur du pointeur ptr dans le registre (au début de la boucle), puis compare la valeur de ce registre avec zéro à chaque itération. Comme le rechargement de la valeur de ptr ne se reproduit plus, le cycle ne se termine jamais.
Je comprends que déclarer ptr comme «volatile int *» devrait entraîner la suppression des optimisations par le compilateur et lire la valeur de ptr à chaque itération de la boucle, ce qui résoudra le problème. Mais je voudrais quand même savoir pourquoi le compilateur ne peut pas être assez intelligent pour faire de telles choses automatiquement? De toute évidence, la variable globale utilisée dans deux threads différents peut être modifiée, ce qui signifie qu'elle ne peut pas être simplement mise en cache dans le registre. Pourquoi le compilateur ne peut-il pas générer immédiatement le code correct?
Avant de répondre à cette question, commençons par une petite sélection: «volatile int * ptr» ne déclare pas la variable ptr comme un «pointeur pour lequel les optimisations sont interdites». Il s'agit d'un "pointeur normal vers une variable pour laquelle les optimisations sont interdites". Ce que l'auteur de la question ci-dessus avait à l'esprit devait être déclaré «int * volatile ptr».
Revenons maintenant à la question principale. Que se passe-t-il ici?
Même un coup d'œil rapide sur le code nous dira qu'il n'y a pas de variables comme std :: atomic, ni l'utilisation de std :: memory_order (explicite ou implicite). Cela signifie que toute tentative d'accès à ptr ou * ptr à partir de deux flux différents conduit à un comportement non défini. Intuitivement, vous pouvez y penser de cette façon: «Le compilateur optimise chaque thread comme s'il s'exécutait seul dans le programme. Les seuls points où le compilateur DOIT penser à accéder aux données de différents flux utilisent std :: atomic ou std :: memory_order. »
Cela explique pourquoi le programme ne s'est pas comporté comme prévu par le programmeur. À partir du moment où vous autorisez un comportement vague - absolument rien ne peut être garanti.
Mais d'accord, réfléchissons à la deuxième partie de sa question: pourquoi le compilateur n'est-il pas assez intelligent pour reconnaître cette situation et désactiver automatiquement l'optimisation en chargeant la valeur du pointeur dans le registre? Eh bien, le compilateur applique automatiquement tout ce qui est possible et non contraire à la norme d'optimisation. Il serait étrange de lui demander de pouvoir lire les pensées du programmeur et de désactiver certaines optimisations qui ne contredisent pas la norme, ce qui, selon le programmeur, devrait peut-être changer la logique du programme pour le mieux. «Oh, que se passe-t-il si ce cycle s'attend à un changement de la valeur d'une variable globale dans un autre thread, bien qu'il n'ait pas été explicitement annoncé? Je vais le prendre cent fois pour le ralentir pour être prêt à cette situation! " Cela devrait-il en être ainsi? À peine.
Mais supposons que nous ajoutions une règle au compilateur comme "Si l'optimisation a conduit à l'apparition d'une boucle infinie, alors vous devez l'annuler et collecter le code sans optimisation." Ou même comme ceci: "Annulez successivement les optimisations individuelles jusqu'à ce que le résultat soit une boucle non infinie." Outre les surprises incroyables que cela apportera, cela apportera-t-il un avantage?
Oui, dans ce cas théorique, nous n'obtiendrons pas de boucle infinie. Il sera interrompu si un autre flux écrit une valeur non nulle dans * ptr. Il sera également interrompu si un autre thread écrit une valeur différente de zéro dans la variable x. Il n'est pas clair à quel point l'analyse des dépendances devrait être approfondie afin de «détecter» tous les cas susceptibles d'affecter la situation. Étant donné que le compilateur n'exécute pas réellement le programme créé et n'analyse pas son comportement au moment de l'exécution, la seule solution consiste à supposer qu'en général aucun appel à des variables globales, pointeurs et références ne peut être optimisé.
int limit; void do_something() { ... if (value > limit) value = limit;
Ceci est complètement contraire à l'esprit du C ++. La norme de langage dit que si vous modifiez une variable et vous attendez à voir cette modification dans un autre thread, vous devez dire explicitement ceci: utiliser une opération atomique ou organiser l'accès à la mémoire (généralement en utilisant un objet de synchronisation).
Alors s'il vous plaît faites juste cela.