web-dev-qa-db-fra.com

Pourquoi la technologie volatile n'est-elle pas considérée comme utile dans la programmation multithread C ou C ++?

Comme démontré dans cette réponse J'ai récemment posté, il semble que je sois confus quant à l'utilité (ou son absence) de volatile dans des contextes de programmation multithreads.

D'après ce que j'ai compris: à chaque fois qu'une variable peut être modifiée en dehors du flux de contrôle d'un morceau de code qui y accède, cette variable doit être déclarée comme étant volatile. Les gestionnaires de signaux, les registres d'E/S et les variables modifiées par un autre thread constituent tous de telles situations.

Ainsi, si vous avez un entier global foo et que foo est lu par un thread et défini de manière atomique par un autre thread (probablement à l'aide d'une instruction machine appropriée), le thread de lecture voit cette situation dans De la même manière, il voit une variable modifiée par un gestionnaire de signaux ou modifiée par une condition matérielle externe. Ainsi, foo devrait être déclaré volatile (ou, pour les situations multithread, accessible avec une charge protégée de la mémoire, est probablement une meilleure solution).

Comment et où est-ce que je me trompe?

156
Michael Ekstrand

Le problème avec volatile dans un contexte multithread est qu’il ne fournit pas tous les garanties dont nous avons besoin. Il a quelques propriétés dont nous avons besoin, mais pas toutes, donc nous ne pouvons pas compter sur volatileseul.

Cependant, les primitives que nous devrions utiliser pour les propriétés restantes fournissent également celles que volatile fournit, il est donc inutile.

Pour les accès thread-safe aux données partagées, nous avons besoin de la garantie que:

  • la lecture/écriture se produit réellement (que le compilateur ne se contente pas de stocker la valeur dans un registre et de reporter à plus tard la mise à jour de la mémoire principale)
  • qu'il n'y ait pas de réapprovisionnement. Supposons que nous utilisions une variable volatile comme indicateur pour indiquer si certaines données sont prêtes à être lues ou non. Dans notre code, nous plaçons simplement le drapeau après la préparation des données, donc tout semble bien. Mais que se passe-t-il si les instructions sont réorganisées pour que le drapeau soit défini premier?

volatile garantit le premier point. Cela garantit également qu'il n'y a pas de réorganisation entre différentes lectures/écritures volatiles. Tous les accès à la mémoire volatile auront lieu dans l’ordre dans lequel ils ont été spécifiés. C’est tout ce dont nous avons besoin pour ce à quoi volatile est destiné: manipuler des registres d’E/S ou du matériel mappé en mémoire, mais cela ne nous aide pas dans le code multithread où l’objet volatile est souvent seulement utilisé pour synchroniser l'accès aux données non volatiles. Ces accès peuvent toujours être réorganisés par rapport à ceux volatile.

La solution pour empêcher la réorganisation consiste à utiliser un barrière de mémoire, qui indique à la fois au compilateur et à la CPU que aucun accès mémoire ne peut être réorganisé de ce point. En plaçant ces barrières autour de notre accès variable volatil, nous veillons à ce que même les accès non volatiles ne soient pas réorganisés sur celui-ci, ce qui nous permet d'écrire du code thread-safe.

Cependant, les barrières de mémoire aussi assurent que toutes les lectures/écritures en attente sont exécutées lorsque la barrière est atteinte, de sorte qu'elle nous donne tout ce dont nous avons besoin, rendant volatile inutile. Nous pouvons simplement supprimer le qualificatif volatile.

Depuis C++ 11, les variables atomiques (std::atomic<T>) nous donne toutes les garanties pertinentes.

205
jalf

Vous pouvez également considérer cela depuis le Documentation du noyau Linux .

Les programmeurs C ont souvent pris le mot volatile pour signifier que la variable pouvait être modifiée en dehors du thread d'exécution en cours; en conséquence, ils sont parfois tentés de l'utiliser dans le code du noyau lorsque des structures de données partagées sont utilisées. En d'autres termes, ils sont connus pour traiter les types volatils comme une sorte de variable atomique facile, ce qu'ils ne sont pas. L'utilisation de volatile dans le code du noyau n'est presque jamais correcte; Ce document décrit pourquoi.

Le point clé à comprendre en ce qui concerne volatile est que son objectif est de supprimer l’optimisation, ce qui n’est presque jamais ce que l’on veut réellement faire. Dans le noyau, il faut protéger les structures de données partagées contre les accès simultanés indésirables, ce qui est une tâche très différente. Le processus de protection contre les accès simultanés indésirables évite également presque tous les problèmes liés à l'optimisation de manière plus efficace.

Tout comme les volatiles, les primitives du noyau qui sécurisent l’accès simultané aux données (spinlocks, mutex, barrières de mémoire, etc.) sont conçues pour empêcher toute optimisation non souhaitée. S'ils sont utilisés correctement, il ne sera pas nécessaire d'utiliser volatile également. Si volatile est toujours nécessaire, il y a presque certainement un bug dans le code quelque part. Dans le code du noyau correctement écrit, volatile ne peut que ralentir les choses.

Prenons un bloc typique de code du noyau:

spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);

Si tout le code suit les règles de verrouillage, la valeur de shared_data ne peut pas changer de façon inattendue tant que the_lock est maintenu. Tout autre code susceptible de jouer avec ces données attendra sur le verrou. Les primitives de spinlock agissent comme des barrières de mémoire - elles sont explicitement écrites à cet effet - ce qui signifie que les accès aux données ne seront pas optimisés entre elles. Le compilateur peut donc penser qu'il sait ce que sera shared_data, mais l'appel de spin_lock (), puisqu'il agit comme une barrière de mémoire, l'obligera à oublier tout ce qu'il sait. Il n'y aura pas de problèmes d'optimisation lors de l'accès à ces données.

Si shared_data était déclaré volatile, le verrouillage serait toujours nécessaire. Mais le compilateur ne pourrait pas non plus optimiser l'accès à shared_data dans la section critique, alors que nous savons que personne d'autre ne peut travailler avec elle. Tant que le verrou est maintenu, shared_data n'est pas volatile. Lorsque vous traitez avec des données partagées, un verrouillage correct rend les volatiles inutiles - et potentiellement dangereuses.

La classe de stockage volatile était à l'origine destinée aux registres d'E/S mappés en mémoire. Dans le noyau, les accès aux registres doivent également être protégés par des verrous, mais on ne veut pas non plus que le compilateur "optimise" les accès aux registres dans une section critique. Mais, dans le noyau, les accès mémoire I/O sont toujours effectués via des fonctions d’accesseur; Accéder à la mémoire d'E/S directement via des pointeurs est mal vu et ne fonctionne pas sur toutes les architectures. Ces accesseurs sont écrits pour empêcher toute optimisation non désirée. Une fois encore, la volatilité est inutile.

Une autre situation dans laquelle on peut être tenté d'utiliser volatile est lorsque le processeur est occupé à attendre la valeur d'une variable. La bonne façon d'effectuer une attente bien remplie est la suivante:

while (my_variable != what_i_want)
    cpu_relax();

L'appel cpu_relax () peut réduire la consommation d'énergie du processeur ou céder la place à un processeur double hyperthreaded. il arrive aussi que cela serve de barrière de mémoire, donc encore une fois, volatil est inutile. Bien entendu, l’attente chargée est généralement un acte antisocial.

Il existe encore quelques rares situations où volatile a un sens dans le noyau:

  • Les fonctions d'accès mentionnées ci-dessus peuvent utiliser des architectures volatiles sur lesquelles l'accès direct à la mémoire d'E/S fonctionne. Essentiellement, chaque appel d'accès devient une petite section critique et garantit que l'accès se déroule comme prévu par le programmeur.

  • Le code d'assemblage en ligne qui change de mémoire, mais qui n'a pas d'autres effets secondaires visibles, risque d'être supprimé par GCC. L'ajout du mot clé volatile aux instructions asm empêchera cette suppression.

  • La variable jiffies est particulière en ce sens qu'elle peut avoir une valeur différente à chaque référence, mais elle peut être lue sans verrouillage spécial. Donc, les jiffies peuvent être volatiles, mais l’ajout d’autres variables de ce type est très mal vu. Jiffies est considéré comme un problème "d'héritage stupide" (selon les mots de Linus) à cet égard; le réparer causerait plus de problèmes qu'il n'en valait la peine.

  • Les pointeurs sur les structures de données en mémoire cohérente susceptibles d'être modifiées par les périphériques d'E/S peuvent, parfois, légitimement être volatils. Un tampon en anneau utilisé par une carte réseau, où cette carte modifie les pointeurs pour indiquer les descripteurs qui ont été traités, est un exemple de ce type de situation.

Pour la plupart des codes, aucune des justifications ci-dessus pour volatiles ne s'applique. En conséquence, l'utilisation de volatile sera probablement considérée comme un bug et apportera un contrôle supplémentaire au code. Les développeurs qui sont tentés d’utiliser une technologie volatile devraient prendre du recul et réfléchir à ce qu’ils essaient réellement d’accomplir.

47
user1831086

Je ne pense pas que vous ayez tort - la volatilité est nécessaire pour garantir que le fil A verra la valeur changer, si la valeur est changée autrement que par le fil A. Si je comprends bien, volatil est fondamentalement une façon de dire au compiler "ne mettez pas cette variable en cache dans un registre, mais veillez à toujours la lire/l'écrire à partir de RAM de la mémoire à chaque accès").

La confusion est que la volatilité n'est pas suffisante pour mettre en œuvre un certain nombre de choses. En particulier, les systèmes modernes utilisent plusieurs niveaux de mise en cache, les processeurs multicœurs modernes procèdent à des optimisations fantaisistes au moment de l’exécution, et les compilateurs modernes procèdent à des optimisations fantaisistes à la compilation, qui peuvent tous entraîner divers effets secondaires ordre de l'ordre que vous attendez si vous venez de regarder le code source.

Si volatile est acceptable, aussi longtemps que vous gardez à l'esprit que les changements "observés" dans la variable volatile peuvent ne pas se produire au moment exact où vous pensez qu'ils vont se produire. En particulier, n'essayez pas d'utiliser des variables volatiles pour synchroniser ou ordonner les opérations sur les threads, car cela ne fonctionnera pas de manière fiable.

Personnellement, mon principal (uniquement?) Usage du drapeau volatile est un booléen "pleaseGoAwayNow". Si j'ai un thread de travail qui effectue une boucle continue, je lui demanderai de vérifier le booléen volatil à chaque itération de la boucle et de quitter si le booléen est toujours vrai. Le thread principal peut alors nettoyer le thread de travail en toute sécurité en définissant le booléen sur true, puis en appelant pthread_join () pour attendre que le thread de travail disparaisse.

11
Jeremy Friesner

Votre compréhension est vraiment fausse.

La propriété, que les variables volatiles ont, est "lit et écrit dans cette variable font partie du comportement perceptible du programme". Cela signifie que ce programme fonctionne (avec le matériel approprié):

int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles

Le problème est que ce n'est pas la propriété que nous voulons de thread-safe.

Par exemple, un compteur thread-safe serait simplement (code semblable à linux-kernel, je ne connais pas l’équivalent c ++ 0x):

atomic_t counter;

...
atomic_inc(&counter);

C'est atomique, sans barrière de mémoire. Vous devriez les ajouter si nécessaire. L'ajout de volatile ne serait probablement d'aucune aide, car cela ne relierait pas l'accès au code à proximité (par exemple, l'ajout d'un élément à la liste que le compteur compte). Bien entendu, vous n'avez pas besoin de voir le compteur incrémenté en dehors de votre programme, et des optimisations sont toujours souhaitables, par exemple.

atomic_inc(&counter);
atomic_inc(&counter);

peut encore être optimisé pour

atomically {
  counter+=2;
}

si l'optimiseur est assez intelligent (cela ne change pas la sémantique du code).

6
jpalecek

volatile est utile (bien qu'insuffisant) pour implémenter la construction de base d'un mutex spinlock, mais une fois que vous avez cela (ou quelque chose de supérieur), vous n'avez plus besoin d'un autre volatile.

La méthode typique de la programmation multithread n'est pas de protéger toutes les variables partagées au niveau de la machine, mais plutôt d'introduire des variables de garde qui guident le déroulement du programme. Au lieu de volatile bool my_shared_flag; tu aurais dû

pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;

Non seulement cela encapsule la "partie difficile", mais il est fondamentalement nécessaire: C n'inclut pas opérations atomiques nécessaire pour implémenter un mutex; il ne dispose que de volatile pour apporter des garanties supplémentaires concernant les opérations ordinaires .

Maintenant vous avez quelque chose comme ça:

pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

my_shared_flag n’a pas besoin d’être volatile, bien qu’il soit impossible à connecter, car

  1. Un autre thread y a accès.
  2. Ce qui signifie qu'une référence à cela doit avoir été prise à un moment donné (avec le & opérateur).
    • (Ou une référence a été prise à une structure contenant)
  3. pthread_mutex_lock est une fonction de bibliothèque.
  4. Cela signifie que le compilateur ne peut pas dire si pthread_mutex_lock acquiert en quelque sorte cette référence.
  5. Cela signifie que le compilateur doit supposer que pthread_mutex_lock modifie le drapeau partagé !
  6. Donc, la variable doit être rechargée de la mémoire. volatile, bien que significatif dans ce contexte, est superficiel.
6
Potatoswatter

Pour que vos données soient cohérentes dans un environnement concurrent, vous devez appliquer deux conditions:

1) Atomicité c’est-à-dire que si je lis ou écris des données en mémoire, ces données sont lues/écrites en un seul passage et ne peuvent être interrompues ni contestées en raison, par exemple, d’un changement de contexte.

2) La cohérence, c’est-à-dire que l’ordre des opérations de lecture/écriture doit être vue pour être identique entre plusieurs environnements concurrents - qu’il s’agisse de threads, de machines, etc.

volatile ne correspond à aucune de ces solutions - ni plus particulièrement à la norme c ou c ++ relative à la manière dont la volatilité doit se comporter ne comprend aucune de ces solutions.

C'est encore pire dans la pratique puisque certains compilateurs (tels que le compilateur Itanium d'Intel) tentent d'implémenter un élément de comportement sécurisé d'accès concurrent (en assurant des barrières de mémoire). Cependant, il n'y a pas de cohérence entre les implémentations du compilateur et, de plus, la norme ne l'exige pas. de la mise en œuvre en premier lieu.

Marquer une variable comme volatile signifie simplement que vous forcez le vidage de la valeur vers et depuis la mémoire à chaque fois, ce qui, dans de nombreux cas, ne fait que ralentir votre code, car vous avez pratiquement réduit les performances de votre cache.

c # et Java autant que je sache, en adhérant de manière volatile aux points 1) et 2). Cependant, on ne peut pas en dire autant des compilateurs c/c ++, donc faites-le comme bon vous semble.

Pour une discussion plus approfondie (mais non impartiale) sur le sujet, lisez this

6
zebrabox

Le comp.programming.threads FAQ a ne explication classique par Dave Butenhof:

Q56: Pourquoi n'ai-je pas besoin de déclarer des variables partagées VOLATILE?

Je suis cependant préoccupé par les cas où le compilateur et la bibliothèque de threads répondent à leurs spécifications respectives. Un compilateur C conforme peut allouer globalement une variable partagée (non volatile) à un registre enregistré et restauré à mesure que le CPU passe d'un thread à l'autre. Chaque thread aura sa propre valeur privée pour cette variable partagée, ce qui n'est pas ce que nous souhaitons d'une variable partagée.

Dans un certain sens, cela est vrai si le compilateur en sait assez sur les portées respectives de la variable et des fonctions pthread_cond_wait (ou pthread_mutex_lock). En pratique, la plupart des compilateurs n'essaieront pas de conserver des copies de données globales enregistrées lors d'un appel à une fonction externe, car il est trop difficile de savoir si la routine pourrait avoir accès à l'adresse des données.

Alors oui, il est vrai qu’un compilateur qui se conforme strictement (mais de manière très agressive) à ANSI C pourrait ne pas fonctionner avec plusieurs threads sans volatile. Mais quelqu'un ferait mieux de le réparer. Parce que tout système (c'est-à-dire, de manière pragmatique, une combinaison de noyau, de bibliothèques et de compilateur C) qui ne fournit pas les garanties de cohérence de la mémoire POSIX ne se conforme pas à la norme POSIX. Période. Le système NE PEUT PAS vous obliger à utiliser des variables volatiles partagées pour un comportement correct, car POSIX requiert uniquement que les fonctions de synchronisation POSIX soient nécessaires.

Donc, si votre programme s'interrompt parce que vous n'avez pas utilisé volatile, c'est un BUG. Il ne s’agit peut-être pas d’un bogue dans C, d’un bogue dans la bibliothèque de threads ou d’un bogue dans le noyau. Mais c'est un bogue SYSTEM, et un ou plusieurs de ces composants devront travailler pour le résoudre.

Vous ne voulez pas utiliser volatile, car sur tout système où cela fait une différence, cela sera beaucoup plus coûteux qu'une variable non volatile appropriée. (ANSI C requiert des "points de séquence" pour les variables volatiles à chaque expression, tandis que POSIX ne les requiert que lors d'opérations de synchronisation. Une application à threads gourmands en calculs verra une activité mémoire considérablement plus importante en utilisant des fonctions volatiles. vous ralentit vraiment.)

/ --- [Dave Butenhof] ----------------------- [[email protected]] --- \
| Digital Equipment Corporation 110, broche Brook Road ZKO2-3/Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
----------------- [Une meilleure vie grâce à la concurrence] ---------------- /

M. Butenhof couvre en grande partie le même terrain dans cet article Usenet :

L'utilisation de "volatile" n'est pas suffisante pour assurer une visibilité adéquate de la mémoire ou une synchronisation entre les threads. L'utilisation d'un mutex est suffisante et, sauf en recourant à diverses alternatives de code machine non portables (ou à des implications plus subtiles des règles de la mémoire POSIX qui sont beaucoup plus difficiles à appliquer de manière générale, comme expliqué dans mon précédent post), mutex est NECESSAIRE.

Par conséquent, comme l'explique Bryan, l'utilisation de la technologie volatile n'aboutit qu'à empêcher le compilateur de faire des optimisations utiles et souhaitables, sans fournir aucune aide pour rendre le code "thread-safe". Vous pouvez évidemment déclarer tout ce que vous voulez "volatil" - c’est un attribut de stockage ANSI C légal, après tout. Ne vous attendez pas à ce qu'il résolve les problèmes de synchronisation de threads pour vous.

Tout ce qui est également applicable à C++.

5
Tony Delroy

C’est tout ce que "volatile" est en train de faire: "Hé compilateur, cette variable pourrait changer AT TOUT MOMENT (quelle que soit la mesure de l’horloge)], même si AUCUNE INSTRUCTION DE LOCAL n’est appliquée". NE PAS mettre en cache. cette valeur dans un registre. "

C'est ça. Il indique au compilateur que votre valeur est, bien, volatile - cette valeur peut être modifiée à tout moment par une logique externe (un autre thread, un autre processus, le noyau, etc.). Il existe plus ou moins uniquement pour supprimer les optimisations du compilateur qui mettront en cache une valeur dans un registre qui est intrinsèquement dangereux pour le cache EVER.

Vous rencontrerez peut-être des articles comme "Dr. Dobbs" qui, selon vous, sont une panacée pour la programmation multi-thread. Son approche n'est pas totalement dénuée de mérite, mais elle a le défaut fondamental de rendre les utilisateurs d'un objet responsables de la sécurité de son thread, qui a tendance à avoir les mêmes problèmes que d'autres violations de l'encapsulation.

3
Zack Yezek

Selon mon ancien standard C, "Ce qui constitue un accès à un objet de type qualifié volatil est défini par l'implémentation". Ainsi, les rédacteurs du compilateur C pourraient auraient choisi la signification "volatile" "accès sécurisé dans un environnement multi-processus". Mais ils ne l'ont pas fait.

Au lieu de cela, les opérations requises pour sécuriser un thread de section critique dans un environnement de mémoire partagée multiprocessus multicœurs ont été ajoutées en tant que nouvelles fonctionnalités définies par la mise en œuvre. Et, libérés de l'exigence selon laquelle "volatile" fournirait un accès atomique et un ordre d'accès dans un environnement multi-processus, les rédacteurs du compilateur ont privilégié la réduction de code par rapport à la sémantique historique "volatile".

Cela signifie que des choses comme les sémaphores "volatiles" autour des sections de code critiques, qui ne fonctionnent pas sur un nouveau matériel avec de nouveaux compilateurs, ont peut-être déjà fonctionné avec d'anciens compilateurs sur du vieux matériel.

3
david