Question

Cette question et ma réponse ci-dessous sont principalement en réponse à une zone de confusion dans une autre question.

A la fin de la réponse, il y a quelques problèmes WRT « volatile » et la synchronisation des threads que je ne suis pas tout à fait confiant sur - Je me réjouis des commentaires et des réponses alternatives. Le point de la question porte essentiellement sur les registres du CPU et la façon dont ils sont utilisés, cependant.

Était-ce utile?

La solution

registres de CPU sont de petites zones de stockage de données sur le silicium de la CPU. Pour la plupart des architectures, ils sont le premier lieu toutes les opérations se produisent (les données se charge dans la mémoire, opérés et repoussés hors).

Quel que soit le fil est en cours d'exécution utilise les registres et possède le pointeur d'instruction (qui dit que l'instruction vient ensuite). Lorsque les swaps OS dans un autre thread, tous l'état du processeur, y compris les registres et le pointeur d'instruction, sont sauvés quelque part, lyophiliser efficacement l'état du fil quand il vient ensuite à la vie.

Beaucoup plus de documentation sur tout cela, bien sûr, dans tous les sens. Wikipédia sur les registres. Wikipédia sur le changement de contexte. pour commencer. Edit: ou lire la réponse de Steve314. :)

Autres conseils

Les registres sont la « mémoire de travail » dans une unité centrale de traitement. Ils sont très rapides, mais une ressource très limitée. En règle générale, une unité centrale de traitement a un petit ensemble fixe de registres nommés, les noms faisant partie de la convention de langage assembleur pour que le code de la machine CPU. Par exemple, 32 bits les processeurs Intel x86 ont quatre registres de données nommés EAX, EBX, ECX et EDX, ainsi qu'un certain nombre d'indexation et d'autres registres plus spécialisés.

Au sens strict, ce n'est pas tout à fait vrai que ces jours - register changement de nom, par exemple, est commun. Certains processeurs ont assez des registres qu'ils les numéroter plutôt que de les nommer, etc. Il reste, cependant, un bon modèle de base pour travailler. Par exemple, inscrivez-vous renommage est utilisé pour préserver l'illusion de ce modèle de base malgré l'exécution hors-commande.

Utilisation des registres en assembleur manuellement par écrit a tendance à avoir un modèle simple d'utilisation du registre. Quelques variables seront conservées uniquement dans des registres pour la durée d'un sous-programme, ou une partie substantielle de celui-ci. D'autres registres sont utilisés dans un modèle d'écriture de lecture-modification. Par exemple ...

mov eax, [var1]
add eax, [var2]
mov [var1], eax

IIRC, qui est valide (mais probablement inefficace) le code assembleur x86. Sur un Motorola 68000, je pourrais écrire ...

move.l [var1], d0
add.l  [var2], d0
move.l d0, [var1]

Cette fois-ci, la source est généralement le paramètre gauche, avec la destination à droite. Le 68000 a eu 8 registres de données (d0..d7) et 8 registres d'adresses (a0..a7), avec a7 IIRC servant également le pointeur de pile.

Sur un 6510 (retour sur le bon vieux Commodore 64) Je pourrais écrire ...

lda    var1
adc    var2
sta    var1

Les registres ici sont pour la plupart implicites dans les instructions - ci-dessus utilisent A (accumulateur) registre

.

S'il vous plaît pardonnez les erreurs stupides dans ces exemples - je ne l'ai pas écrit une quantité importante d'assembleur « réel » (plutôt que virtuel) pendant au moins 15 ans. Le principe est le point, cependant.

Utilisation des registres est spécifique à un fragment de code particulier. Quel registre contient est fondamentalement ce que la dernière instruction laissé en elle. Il est de la responsabilité du programmeur de garder une trace de ce qui est dans chaque registre à chaque point dans le code.

Lors de l'appel d'un sous-programme, que ce soit l'appelant ou l'appelé doivent assumer la responsabilité de veiller à ce qu'il n'y a pas de conflit, ce qui signifie généralement que les registres sont enregistrés sur la pile au début de l'appel, puis relues à la fin. Des problèmes similaires se produisent avec des interruptions. Des choses comme qui est responsable de l'enregistrement des registres (appelant ou appelé) sont généralement une partie de la documentation de chaque sous-programme.

Un compilateur généralement décider comment utiliser les registres d'une manière beaucoup plus sophistiqué qu'un programmeur humain, mais il fonctionne sur les mêmes principes. Le mappage des registres à des variables particulières est dynamique et varie considérablement selon lequel le fragment de code consultez. Enregistrement et restauration des registres est principalement traités conformément aux conventions standard, bien que le compilateur peut improviser « appeler la coutume conventions » dans certaines circonstances.

En règle générale, les variables locales d'une fonction sont imaginées pour vivre dans la pile. Telle est la règle générale avec des variables « auto » en C. Comme « auto » est la valeur par défaut, ceux-ci sont des variables locales normales. Par exemple ...

void myfunc ()
{
  int i;  //  normal (auto) local variable
  //...
  nested_call ();
  //...
}

Dans le code ci-dessus, « i » peut bien se tenir principalement dans un registre. Il peut même être déplacé d'un registre à l'autre et à l'arrière que la fonction progresse. Cependant, lorsque « nested_call » est appelée, la valeur de ce registre sera certainement sur la pile - soit parce que la variable est une variable de la pile (pas un registre), ou parce que le contenu du registre sont enregistrées pour permettre nested_call son propre mémoire de travail .

Dans une application multithreading, les variables locales normales sont locales à un fil particulier. Chaque thread obtient sa propre pile, et en cours d'exécution, l'usage exclusif des registres CPU. Dans un changement de contexte, ces registres sont enregistrés. Si jen registres ou sur la pile, les variables locales ne sont pas partagées entre les threads.

Cette situation de base est conservée dans une application multi-cœurs, même si deux fils ou plus peuvent être actifs en même temps. Chaque noyau a sa propre pile et ses propres registres.

Les données stockées dans la mémoire partagée nécessite plus de soins. Cela inclut des variables globales, les variables statiques dans les classes et fonctions, et des objets de tas alloués. Par exemple ...

void myfunc ()
{
  static int i;  //  static variable
  //...
  nested_call ();
  //...
}

Dans ce cas, la valeur de « i » est conservée entre les appels de fonction. Une région statique de la mémoire principale est réservé pour stocker cette valeur (d'où le nom « statique »). En principe, il n'y a pas besoin d'une action spéciale pour préserver « i » lors de l'appel à « nested_call », et à première vue, la variable peut être accessible depuis tout fil en cours d'exécution sur un noyau (ou même sur une CPU séparée).

Cependant, le compilateur travaille toujours dur pour optimiser la vitesse et la taille de votre code. Lectures répétées et écrit à la mémoire principale sont beaucoup plus lent que le registre des accès. Le compilateur certainement choisir pas pour suivre le modèle simple lecture-modification-écriture décrit ci-dessus, mais au lieu de garder la valeur dans le registre pendant une période relativement longue, ce qui évite de lectures répétées et écrit à la même mémoire.

Cela signifie que les modifications apportées dans un thread ne peuvent pas être vus par un autre thread pendant un certain temps. Deux fils pourraient finir par avoir des idées très différentes sur la valeur de « i » ci-dessus.

Il n'y a pas de solution matérielle magique pour cela. Par exemple, il n'y a pas de mécanisme pour la synchronisation du registre entre les fils. Pour la CPU, la variable et le registre sont complètement entités distinctes - il ne sait pas qu'ils doivent être synchronisés. Il n'y a certainement pas de synchronisation entre les registres dans différents threads ou en cours d'exécution sur différents noyaux -. Il n'y a aucune raison de croire qu'un autre thread utilise le même registre dans le même but à un moment donné

Une solution partielle consiste à marquer une variable "volatile" ...

void myfunc ()
{
  volatile static int i;
  //...
  nested_call ();
  //...
}

Cela indique au compilateur de ne pas optimiser les lectures et écritures à la variable. Le processeur ne dispose pas d'un concept de volatilité. Ce mot-clé indique au compilateur de générer du code différent, en faisant lit immédiatement et écrit à la mémoire comme spécifié par les affectations, au lieu d'éviter les accès à l'aide d'un registre.

est pas une solution de synchronisation de multithreading, cependant - du moins pas en soi. Une solution de multithreading appropriée est d'utiliser une sorte de verrouillage pour gérer l'accès à cette « ressource partagée ». Par exemple ...

void myfunc ()
{
  static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Il y a plus de choses ici que ce qui est immédiatement évident. En principe, plutôt que d'écrire la valeur de « i » retour à sa variable prêt pour l'appel « release_lock_on_i », il pourrait être sauvé sur la pile. En ce qui concerne le compilateur est concerné, ce n'est pas déraisonnable. Il est fait l'accès de la pile de toute façon (économie par exemple l'adresse de retour), permettant ainsi d'économiser le registre sur la pile peut être plus efficace que de l'écrire de nouveau à « i » -. Cache plus convivial que l'accès à un bloc complètement séparé de la mémoire

Malheureusement, cependant, la fonction de verrouillage de libération ne sait pas que la variable n'a pas été réécrites à la mémoire encore, donc ne peut rien faire pour y remédier. Après tout, cette fonction est juste un appel de la bibliothèque (le véritable verrou de libération peut être caché dans un appel plus profondément imbriquées) et que la bibliothèque peut avoir été compilé années avant que votre demande - il ne sait pas comment les appelants utilisent des registres ou la pile. C'est une grande partie des raisons pour lesquelles nous utilisons une pile, et pourquoi les conventions d'appel doivent être normalisées (par exemple qui enregistre les registres). La fonction de verrouillage de libération ne peut pas forcer les appelants à des registres « synchroniser ».

De même, vous pouvez lier à nouveau une vieille application avec une nouvelle bibliothèque - l'appelant ne sait pas ce que « release_lock_on_i » fait ou comment, il est juste un appel de fonction. Cela faitsait pas qu'il a besoin de sauver les registres de retour sur la mémoire d'abord.

Pour résoudre ce problème, nous pouvons ramener le "volatile".

void myfunc ()
{
  volatile static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

Nous pouvons utiliser une variable locale normale temporairement alors que le verrou est actif, pour donner au compilateur la possibilité d'utiliser un registre pour cette brève période. En principe, cependant, un verrou doit être libéré le plus rapidement possible, donc il ne devrait pas être code beaucoup là-dedans. Si nous le faisons, cependant, nous écrivons notre variable temporaire dans « i » avant de libérer le verrou, et la volatilité des « i » assure qu'il est écrit de retour à la mémoire principale.

En principe, cela ne suffit pas. L'écriture à la mémoire principale ne signifie pas que vous avez écrit à la mémoire principale - il y a des couches de cache pour traverser entre les deux, et vos données pourrait s'asseoir dans l'une de ces couches pendant un certain temps. Il y a une question « barrière mémoire » ici, et je ne sais pas beaucoup à ce sujet -. Mais heureusement cette question est de la responsabilité des appels de synchronisation de fil tels que l'acquisition de verrouillage et de déverrouillage des appels ci-dessus

Ce problème de barrière de mémoire ne supprime pas la nécessité pour le mot-clé « volatile », cependant.

Licencié sous: CC-BY-SA avec attribution
Non affilié à StackOverflow
scroll top