Est-ce que «volatile» garantit quoi que ce soit dans le code C portable pour les systèmes multicœurs?

12

Après avoir examiné un tas d' autres questions et leurs réponses , j'ai l'impression qu'il n'y a pas de consensus sur ce que signifie exactement le mot clé "volatile" en C.

Même la norme elle-même ne semble pas assez claire pour que tout le monde s'entende sur ce qu'elle signifie .

Entre autres problèmes:

  1. Il semble offrir différentes garanties en fonction de votre matériel et de votre compilateur.
  2. Cela affecte les optimisations du compilateur mais pas les optimisations matérielles, donc sur un processeur avancé qui fait ses propres optimisations au moment de l'exécution, il n'est même pas clair si le compilateur peut empêcher toute optimisation que vous souhaitez empêcher. (Certains compilateurs génèrent des instructions pour empêcher certaines optimisations matérielles sur certains systèmes, mais cela ne semble en aucune façon standardisé.)

Pour résumer le problème, il apparaît (après avoir lu beaucoup) que "volatile" garantit quelque chose comme: La valeur sera lue / écrite non seulement depuis / vers un registre, mais au moins vers le cache L1 du noyau, dans le même ordre que les lectures / écritures apparaissent dans le code. Mais cela semble inutile, car la lecture / écriture depuis / vers un registre est déjà suffisante dans le même thread, tandis que la coordination avec le cache L1 ne garantit rien de plus concernant la coordination avec les autres threads. Je ne peux pas imaginer quand il pourrait être important de synchroniser uniquement avec le cache L1.

UTILISATION 1
La seule utilisation largement acceptée de volatile semble être pour les systèmes anciens ou intégrés où certains emplacements de mémoire sont mappés matériellement aux fonctions d'E / S, comme un bit en mémoire qui contrôle (directement, dans le matériel) une lumière , ou un peu en mémoire qui vous indique si une touche du clavier est enfoncée ou non (car elle est connectée directement par le matériel à la touche).

Il semble que «utiliser 1» ne se produit pas dans le code portable dont les cibles incluent les systèmes multicœurs.

UTILISATION 2 La
mémoire qui peut être lue ou écrite à tout moment par un gestionnaire d'interruption (qui peut contrôler une lumière ou stocker des informations à partir d'une clé) n'est pas trop différente de "utiliser 1". Mais déjà pour cela, nous avons le problème que, selon le système, le gestionnaire d'interruption peut s'exécuter sur un cœur différent avec son propre cache mémoire , et "volatile" ne garantit pas la cohérence du cache sur tous les systèmes.

Donc, "utiliser 2" semble aller au-delà de ce que "volatile" peut offrir.

UTILISATION 3
La seule autre utilisation incontestée que je vois est d'empêcher une mauvaise optimisation des accès via différentes variables pointant vers la même mémoire que le compilateur ne réalise pas est la même mémoire. Mais cela n'est probablement incontesté que parce que les gens n'en parlent pas - je n'en ai vu qu'une mention. Et je pensais que la norme C reconnaissait déjà que des pointeurs "différents" (comme différents arguments vers une fonction) pouvaient pointer vers le même élément ou des éléments voisins, et j'ai déjà spécifié que le compilateur devait produire du code qui fonctionne même dans de tels cas. Cependant, je n'ai pas pu trouver rapidement ce sujet dans la dernière norme (500 pages!).

Donc, "utiliser 3" n'existe peut-être pas du tout?

D'où ma question:

Est-ce que "volatile" garantit quoi que ce soit dans le code C portable pour les systèmes multicœurs?


EDIT - mise à jour

Après avoir parcouru la dernière norme , il semble que la réponse soit au moins un oui très limité:
1. La norme spécifie à plusieurs reprises un traitement spécial pour le type spécifique "sig_atomic_t volatile". Cependant, la norme indique également que l'utilisation de la fonction de signal dans un programme multithread entraîne un comportement indéfini. Ce cas d'utilisation semble donc limité à la communication entre un programme monothread et son gestionnaire de signaux.
2. La norme spécifie également une signification claire pour "volatile" par rapport à setjmp / longjmp. (Un exemple de code là où c'est important est donné dans d'autres questions et réponses .)

La question la plus précise devient donc:
«volatile» garantit-il quoi que ce soit dans le code C portable pour les systèmes multicœurs, à l'exception de (1) autoriser un programme à thread unique à recevoir des informations de son gestionnaire de signal, ou (2) autoriser setjmp code pour voir les variables modifiées entre setjmp et longjmp?

C'est toujours une question oui / non.

Si "oui", ce serait bien si vous pouviez montrer un exemple de code portable sans bogue qui devient bogué si "volatile" est omis. Si "non", alors je suppose qu'un compilateur est libre d'ignorer "volatile" en dehors de ces deux cas très spécifiques, pour les cibles multicœurs.

Mat
la source
3
Les signaux existent en C portable; qu'en est-il d'une variable globale mise à jour par un gestionnaire de signal? Cela devrait être volatiled'informer le programme qu'il peut changer de manière asynchrone.
Nate Eldredge
2
@NateEldredge Global, bien que volatil seul, n'est pas suffisant. Il doit également être atomique.
Eugene Sh.
@EugeneSh .: Oui, bien sûr. Mais la question qui nous occupe concerne volatileprécisément ce qui, à mon avis, est nécessaire.
Nate Eldredge
" alors que la coordination avec le cache L1 ne garantit rien de plus concernant la coordination avec les autres threads " Où la "coordination avec le cache L1" n'est-elle pas suffisante pour communiquer avec d'autres threads?
curiousguy
1
Peut-être pertinente, proposition C ++ de déprécier volatile , la proposition répond à bon nombre des préoccupations que vous soulevez ici, et peut-être que son résultat aura une influence sur le comité C
MM

Réponses:

1

Pour résumer le problème, il apparaît (après avoir lu beaucoup) que "volatile" garantit quelque chose comme: La valeur sera lue / écrite non seulement depuis / vers un registre, mais au moins vers le cache L1 du noyau, dans le même ordre que les lectures / écritures apparaissent dans le code .

Non, ce n'est absolument pas le cas . Et cela rend volatile presque inutile aux fins du code sûr MT.

Si c'était le cas, alors volatile serait assez bon pour les variables partagées par plusieurs threads, car la commande des événements dans le cache L1 est tout ce que vous devez faire dans un processeur typique (c'est-à-dire multicœur ou multi-processeur sur la carte mère) capable de coopérer d'une manière qui rend possible une implémentation normale du multithreading C / C ++ ou Java avec des coûts attendus typiques (c'est-à-dire pas un coût énorme pour la plupart des opérations mutex atomiques ou non satisfaites).

Mais volatile ne pas fournir toute commande garantie (ou « la visibilité de la mémoire ») dans le cache ni en théorie ni en pratique.

(Remarque: ce qui suit est basé sur une bonne interprétation des documents standard, l'intention de la norme, la pratique historique et une compréhension approfondie des attentes des rédacteurs du compilateur. Cette approche basée sur l'histoire, les pratiques réelles, les attentes et la compréhension des personnes réelles dans le monde réel, qui est beaucoup plus fort et plus fiable que l'analyse des mots d'un document qui n'est pas connu pour être une écriture stellaire et qui a été révisé plusieurs fois.)

En pratique, volatile garantit ptrace-capacité qui est la capacité d'utiliser les informations de débogage pour le programme en cours d'exécution, à n'importe quel niveau d'optimisation , et le fait que les informations de débogage ont un sens pour ces objets volatils:

  • vous pouvez utiliser ptrace(un mécanisme semblable à ptrace) pour définir des points d'arrêt significatifs aux points de séquence après des opérations impliquant des objets volatils: vous pouvez vraiment interrompre exactement ces points (notez que cela ne fonctionne que si vous êtes prêt à définir de nombreux points d'arrêt comme n'importe quel L'instruction C / C ++ peut être compilée à de nombreux points de début et de fin d'assemblage différents, comme dans une boucle massivement déroulée);
  • tandis qu'un thread d'exécution s'arrête, vous pouvez lire la valeur de tous les objets volatils, car ils ont leur représentation canonique (en suivant l'ABI pour leur type respectif); une variable locale non volatile pourrait avoir une représentation atypique, f.ex. une représentation décalée: une variable utilisée pour indexer un tableau peut être multipliée par la taille des objets individuels, pour une indexation plus facile; ou il peut être remplacé par un pointeur sur un élément de tableau (tant que toutes les utilisations de la variable sont converties de manière similaire) (pensez à changer dx en du dans une intégrale);
  • vous pouvez également modifier ces objets (tant que les mappages de mémoire le permettent, car un objet volatil avec une durée de vie statique qui est qualifié const peut se trouver dans une plage de mémoire mappée en lecture seule).

La volatilité garantit en pratique un peu plus que l'interprétation stricte de ptrace: elle garantit également que les variables automatiques volatiles ont une adresse sur la pile, car elles ne sont pas affectées à un registre, une allocation de registre qui rendrait les manipulations de ptrace plus délicates (le compilateur peut sortie des informations de débogage pour expliquer comment les variables sont allouées aux registres, mais la lecture et la modification de l'état du registre sont légèrement plus complexes que l'accès aux adresses mémoire).

Notez que la capacité de débogage complète du programme, qui considère toutes les variables volatiles au moins aux points de séquence, est fournie par le mode "d'optimisation zéro" du compilateur, un mode qui effectue toujours des optimisations triviales comme des simplifications arithmétiques (il n'y a généralement aucune garantie non optimisation à tous les modes). Mais volatile est plus fort que non optimisation: x-xpeut être simplifié pour un entier non volatile xmais pas pour un objet volatil.

Donc, la volatilité signifie qu'il est garanti d'être compilé tel quel, comme la traduction de la source en binaire / assembleur par le compilateur d'un appel système n'est pas une réinterprétation, modifiée ou optimisée de quelque façon par un compilateur. Notez que les appels de bibliothèque peuvent ou non être des appels système. De nombreuses fonctions système officielles sont en fait des fonctions de bibliothèque qui offrent une fine couche d'interposition et s'en remettent généralement au noyau à la fin. (En particulier, getpidn'a pas besoin d'aller au noyau et pourrait bien lire un emplacement mémoire fourni par le système d'exploitation contenant les informations.)

Les interactions volatiles sont des interactions avec le monde extérieur de la machine réelle , qui doit suivre la "machine abstraite". Ce ne sont pas des interactions internes des parties de programme avec d'autres parties de programme. Le compilateur ne peut que raisonner sur ce qu'il sait, à savoir les parties internes du programme.

La génération de code pour un accès volatil doit suivre l'interaction la plus naturelle avec cet emplacement mémoire: cela ne devrait pas être surprenant. Cela signifie que certains accès volatils devraient être atomiques : si la façon naturelle de lire ou d'écrire la représentation d'un longsur l'architecture est atomique, alors il est prévu qu'une lecture ou une écriture d'un volatile longsera atomique, car le compilateur ne devrait pas générer un code stupide et inefficace pour accéder aux objets volatils octet par octet, par exemple .

Vous devriez pouvoir le déterminer en connaissant l'architecture. Vous n'avez rien à savoir sur le compilateur, car volatile signifie que le compilateur doit être transparent .

Mais volatile ne fait que forcer l'émission d'assemblage attendu pour les moins optimisés pour des cas particuliers à faire une opération mémoire: la sémantique volatile signifie la sémantique générale du cas.

Le cas général est ce que fait le compilateur lorsqu'il n'a aucune information sur une construction: f.ex. appeler une fonction virtuelle sur une valeur l via la répartition dynamique est un cas général, faire un appel direct au dépassement après avoir déterminé au moment de la compilation le type de l'objet désigné par l'expression est un cas particulier. Le compilateur a toujours une gestion générale des cas de toutes les constructions, et il suit l'ABI.

Volatile ne fait rien de spécial pour synchroniser les threads ou fournir une "visibilité mémoire": volatile ne fournit que des garanties au niveau abstrait vu de l'intérieur d'un thread en cours d'exécution ou arrêté, c'est-à-dire à l'intérieur d'un coeur de CPU :

  • volatile ne dit rien sur les opérations de mémoire qui atteignent la RAM principale (vous pouvez définir des types de mise en cache de mémoire spécifiques avec des instructions d'assemblage ou des appels système pour obtenir ces garanties);
  • volatile ne fournit aucune garantie quant au moment où les opérations de mémoire seront validées à n'importe quel niveau de cache (pas même L1) .

Seul le deuxième point signifie que volatile n'est pas utile dans la plupart des problèmes de communication entre les threads; le premier point est essentiellement hors de propos dans tout problème de programmation qui n'implique pas de communication avec des composants matériels en dehors des CPU mais toujours sur le bus mémoire.

La propriété volatile fournissant un comportement garanti du point de vue du noyau exécutant le thread signifie que les signaux asynchrones délivrés à ce thread, qui sont exécutés du point de vue de l'ordre d'exécution de ce thread, voir les opérations dans l'ordre du code source .

Sauf si vous prévoyez d'envoyer des signaux à vos threads (une approche extrêmement utile pour la consolidation des informations sur les threads en cours d'exécution sans point d'arrêt convenu précédemment), volatile n'est pas pour vous.

curiousguy
la source
6

Je ne suis pas un expert, mais cppreference.com a ce qui me semble être de très bonnes informationsvolatile . Voici l'essentiel:

Chaque accès (en lecture et en écriture) effectué via une expression lvalue de type volatile-qualifié est considéré comme un effet secondaire observable à des fins d'optimisation et est évalué strictement selon les règles de la machine abstraite (c'est-à-dire que toutes les écritures sont terminées à un certain temps avant le prochain point de séquence). Cela signifie que dans un seul thread d'exécution, un accès volatile ne peut pas être optimisé ou réorganisé par rapport à un autre effet secondaire visible qui est séparé par un point de séquence de l'accès volatile.

Il donne également quelques utilisations:

Utilisations de volatile

1) les objets volatils statiques modélisent les ports d'E / S mappés en mémoire, et les objets volatils statiques modélisent les ports d'entrée mappés en mémoire, comme une horloge en temps réel

2) des objets statiques volatils de type sig_atomic_t sont utilisés pour la communication avec les gestionnaires de signaux.

3) les variables volatiles qui sont locales à une fonction qui contient une invocation de la macro setjmp sont les seules variables locales garanties pour conserver leurs valeurs après les retours de longjmp.

4) De plus, des variables volatiles peuvent être utilisées pour désactiver certaines formes d'optimisation, par exemple pour désactiver l'élimination de la mémoire morte ou le pliage constant pour les microbenchmarks.

Et bien sûr, il mentionne que ce volatilen'est pas utile pour la synchronisation des threads:

Notez que les variables volatiles ne conviennent pas pour la communication entre les threads; ils n'offrent ni atomicité, ni synchronisation, ni ordre de mémoire. Une lecture à partir d'une variable volatile qui est modifiée par un autre thread sans synchronisation ou modification simultanée à partir de deux threads non synchronisés est un comportement indéfini en raison d'une course aux données.

Fred Larson
la source
2
En particulier, (2) et (3) concernent le code portable.
Nate Eldredge
2
@TED ​​Malgré le nom de domaine, le lien est vers des informations sur C, pas sur C ++
David Brown
@NateEldredge Vous pouvez rarement utiliser longjmpen code C ++.
curiousguy
@DavidBrown C et C ++ ont la même définition d'un SE observable et essentiellement les mêmes primitives de thread.
curiousguy
4

Tout d'abord, il y a eu historiquement divers hoquets concernant différentes interprétations de la signification de l' volatileaccès et similaires. Voir cette étude: Les volatiles sont mal compilés et que faire à ce sujet .

Outre les divers problèmes mentionnés dans cette étude, le comportement de volatileest portable, sauf pour un aspect d'entre eux: lorsqu'ils agissent comme des barrières de mémoire . Une barrière de mémoire est un mécanisme qui est là pour empêcher l'exécution simultanée non séquencée de votre code. L'utilisation volatilecomme barrière mémoire n'est certainement pas portable.

Que le langage C garantisse ou non le comportement de la mémoire volatileest apparemment discutable, bien que personnellement, je pense que le langage est clair. Nous avons d'abord la définition formelle des effets secondaires, C17 5.1.2.3:

Accéder à un volatileobjet, modifier un objet, modifier un fichier ou appeler une fonction qui effectue l'une de ces opérations sont tous des effets secondaires , qui sont des changements d'état de l'environnement d'exécution.

La norme définit le terme séquençage comme un moyen de déterminer l'ordre d'évaluation (exécution). La définition est formelle et lourde:

Séquencé avant est une relation asymétrique, transitive, par paire entre les évaluations exécutées par un seul thread, qui induit un ordre partiel parmi ces évaluations. Étant donné deux évaluations A et B, si A est séquencé avant B, alors l'exécution de A doit précéder l'exécution de B. (Inversement, si A est séquencé avant B, alors B est séquencé après A.) Si A n'est pas séquencé avant ou après B, alors A et B ne sont pas séquencés . Les évaluations A et B sont séquencées de façon indéterminée lorsque A est séquencé avant ou après B, mais il n'est pas précisé lequel.13) La présence d'un point de séquence entre l'évaluation des expressions A et B implique que chaque calcul de valeur et effet secondaire associé à A est séquencé avant chaque calcul de valeur et effet secondaire associé à B. (Un résumé des points de séquence est donné en annexe C.)

Le TL; DR de ce qui précède est fondamentalement que dans le cas où nous avons une expression Aqui contient des effets secondaires, elle doit être exécutée avant une autre expression B, au cas où elle Bserait séquencée après A.

Les optimisations du code C sont rendues possibles grâce à cette partie:

Dans la machine abstraite, toutes les expressions sont évaluées comme spécifié par la sémantique. Une implémentation réelle n'a pas besoin d'évaluer une partie d'une expression si elle peut en déduire que sa valeur n'est pas utilisée et qu'aucun effet secondaire nécessaire n'est produit (y compris ceux provoqués par l'appel d'une fonction ou l'accès à un objet volatil).

Cela signifie que le programme peut évaluer (exécuter) des expressions dans l'ordre que la norme impose ailleurs (ordre d'évaluation, etc.). Mais il n'a pas besoin d'évaluer (d'exécuter) une valeur s'il peut en déduire qu'elle n'est pas utilisée. Par exemple, l'opération 0 * xn'a pas besoin d'évaluer xet de simplement remplacer l'expression par 0.

Sauf si l' accès à une variable est un effet secondaire. Ce qui signifie que dans le cas xest volatile, il doit évaluer (exécuter) , 0 * xmême si le résultat sera toujours 0. L' optimisation n'est pas autorisé.

De plus, la norme parle de comportement observable:

Les exigences minimales sur une implémentation conforme sont:

  • Les accès aux objets volatils sont évalués strictement selon les règles de la machine abstraite.
    / - / C'est le comportement observable du programme.

Compte tenu de tout ce qui précède, une implémentation conforme (compilateur + système sous-jacent) peut ne pas exécuter l'accès des volatileobjets dans un ordre non séquencé, au cas où la sémantique de la source C écrite dirait le contraire.

Cela signifie que dans cet exemple

volatile int x;
volatile int y;
z = x;
z = y;

Les deux expressions d'affectation doivent être évaluées et z = x; doivent être évaluées avant z = y;. Une implémentation multiprocesseur qui sous-traite ces deux opérations à deux cœurs de séquences différentes n'est pas conforme!

Le dilemme est que les compilateurs ne peuvent pas faire grand-chose sur des choses comme la mise en cache de pré-extraction et le pipelining d'instructions, etc., en particulier pas lorsqu'ils s'exécutent sur un système d'exploitation. Et donc les compilateurs remettent ce problème aux programmeurs, en leur disant que les barrières mémoire sont désormais la responsabilité du programmeur. Alors que la norme C indique clairement que le problème doit être résolu par le compilateur.

Le compilateur ne se soucie pas nécessairement de résoudre le problème, et donc volatilepour agir comme une barrière de mémoire, il n'est pas portable. C'est devenu un problème de qualité de mise en œuvre.

Lundin
la source
@curiousguy Peu importe.
Lundin
@curiousguy Peu importe, tant qu'il s'agit d'une sorte de type entier avec ou sans qualificatifs.
Lundin
S'il s'agit d'un simple entier non volatil, pourquoi les écritures redondantes zseraient-elles réellement exécutées? (comme z = x; z = y;) La valeur va être effacée dans la prochaine instruction.
curiousguy
@curiousguy Parce que les lectures des variables volatiles doivent être exécutées, peu importe, dans la séquence spécifiée.
Lundin
Alors est-il zvraiment attribué deux fois? Comment savez-vous que "les lectures sont exécutées"?
curiousguy