0
votes

Différence entre Interlocked, InterlockedAcquire et InterlockedRelease si la réorganisation d'un seul thread est impossible

Selon toute vraisemblance, une implémentation sans verrouillage est déjà exagérée pour les besoins de mon application, mais je voulais de toute façon examiner les barrières de mémoire et le sans verrouillage au cas où je devrais utiliser ces concepts à l'avenir.

D'après ce que je peux dire:

  1. une fonction "InterlockedAcquire" effectue une opération atomique tout en empêchant le compilateur de déplacer des instructions de code après l'InterlockedAcquire avant l'InterlockedAcquire.

  2. une fonction "InterlockedRelease" effectue une opération atomique tout en empêchant le compilateur de déplacer des instructions de code avant l'InterlockedRelease vers après l'InterlockedRelease.

  3. une fonction vanille "Interlocked" effectue une opération atomique tout en empêchant le compilateur de déplacer les instructions de code dans les deux sens à travers l'appel Interlocked.

Ma question est la suivante: si une fonction est structurée de telle sorte que le compilateur ne puisse de toute façon réorganiser aucun code car cela affecterait le comportement à un seul thread, y a-t-il une différence entre l'une des variantes d'une fonction interlocked, ou toutes effectivement le même? La seule différence entre eux est-elle comment ils interagissent avec la réorganisation du code?

Pour un exemple plus concret, voici mon application actuelle - la fonction Produire () dans le cadre de ce qui sera finalement une file d'attente de consommateurs multiples et unique construite à l'aide d'un tampon circulaire:

template <typename T>
class Queue {
    private:
        long headIndex;
        long tailIndex;
        T* array[MAXQUEUESIZE];
    public:
        Queue() {
            headIndex = 0;
            tailIndex = 0;
            memset(array, 0, MAXQUEUESIZE*sizeof(void*);
        }
        ~Queue() {
        }

        bool produce(T value) {
            //1) prevents concurrent calls to produce() from causing corruption:
            long indexRetVal;
            long reservedIndex;
            do {
                reservedIndex = tailIndex;
                indexRetVal = InterlockedCompareExchange64(&tailIndex, (reservedIndex + 1) % MAXQUEUESIZE, reservedIndex);
            } while (indexRetVal != reservedIndex);

            //2) allocates the node.
            T* newValPtr = (T*) malloc(sizeof(T));
            if (newValPtr == null) {
                OutputDebugString("Queue: malloc returned null");
                return false;
            }
            *newValPtr = value;

            //3) prevents a concurrent call to consume from causing corruption by atomically replacing the old pointer:
            T* valPtrRetVal = InterlockedCompareExchangePointer(array + reservedIndex, newValPtr, null);
            //if the previous value wasn't null, then our circular buffer overflowed:
            if (valPtrRetVal != null) {
                OutputDebugString("Queue: circular buffer overflowed");
                free(newValPtr); //as pointed out by RbMm
                return false;
            }

            //otherwise, everything worked fine
            return true;
        }
};

Si je comprends bien, 3) se produira après 1) et 2) indépendamment de ce que je fais de toute façon, mais je devrais changer 1) en InterlockedRelease parce que je me fiche de savoir si cela se produit avant ou après 2) et je devrais laisser le le compilateur décide.


13 commentaires

vous utilisez InterlockedCompareExchange64 sur 32 bits long tailIndex et compilateur rien à dire ici? si malloc fail - reservedIndex sera divulgué? si à l'étape 3 tampon circulaire débordé - pas besoin de newValPtr libre avant le retour? en 1, vous n'avez besoin d'aucune clôture. au magasin 3 newValPtr avec clôture de dégagement


Pourriez-vous expliquer pourquoi newValPtr doit être stocké avec une clôture de libération?


pour toutes les données stockées dans l'emplacement auquel le point newValPtr sera garanti visible par le thread qui lit newValPtr - read doit être avec acquérir sémantique et stocker avec libération.


Désolé, je ne comprends toujours pas donc je vais essayer une autre question: la sémantique d'acquisition et de publication affecte-t-elle le comportement de toute façon en plus d'empêcher certaines formes de réorganisation du code par le compilateur?


ici, aucun code ne sera réorganisé par le compilateur de toute façon. même si vous n'utiliserez pas interlocked, fence, etc., vous écrivez d'abord des données dans l'emplacement de newValPtr (1), puis écrivez la valeur de newValPtr dans la file d'attente (2), un autre thread lit le pointeur stocké (3), puis lisez données par ce pointeur (4). car at (4) sera visible toutes les données écrites en (1) - besoin de faire (2) avec la libération et (3) avec l'acquisition


intéressant - InterlockedCompareExchange64(&tailIndex..) est compilé? si prendre en compte que tailIndex est long et InterlockedCompareExchange64 attendez __int64* ?


vous devez également d'abord stocker newValPtr , puis déplacer tailIndex vers l'avant. l'ordre doit donc être le suivant - (2) - (3) - (1)


Pour une raison quelconque, mon IDE ne se plaint pas - je pense que c'est parce que j'ai tout mis dans un fichier .h parce que j'ai entendu dire que j'étais censé le faire en utilisant "template <typename T>". Je suis presque sûr que vous avez raison et que cela ne se compilera pas


Ok, je pense que je comprends le point concernant newValPtr - je dois m'assurer que les données écrites à l'emplacement spécifié par newValPtr sont présentes avant que l'emplacement lui-même ne soit rendu visible aux autres threads, ce que je ne fais pas actuellement. Cela n'a rien à voir avec la réorganisation du code, qui répond à ma question initiale. Merci. Votre autre point sur le fait de faire avancer le tailIndex en dernier, cependant - il peut y avoir plusieurs producteurs en cours d'exécution, alors n'ai-je pas besoin de réserver une place dans le messageQueue avant de mettre un message à l'intérieur?


si stocker via InterlockedCompareExchangePointer uniquement à l'emplacement 0, vous n'avez pas besoin de le réserver avant. et peut déplacer tailIndex après cela - premier magasin, que déplacer l'index, bien sûr si la valeur du consommateur pop 0 est ok - possible et dans votre commande. seulement de toute façon besoin de (2) mis à la première place, et de vérifier la file d'attente pleine à l'étape (1), lorsque vous réservez l'index mais pas dans (3). également dans ce cas, dans (3), vous n'avez pas besoin de compareexchange mais uniquement d'un magasin verrouillé avec version (car vous avez un index alloué unique)


Ça a du sens. Je comprends maintenant pourquoi il est possible de stocker newValPtr en premier et de déplacer le tailIndex en dernier, mais je ne comprends toujours pas pourquoi il est préférable de le faire dans cet ordre. La file d'attente est en fait un tampon circulaire, donc je ne peux pas vérifier le débordement en comparant simplement tailIndex à MAXQUEUESIZE. En réservant des emplacements, je peux utiliser array [reservedSlot]! = Null pour tester si le tampon est plein en 3), alors que si je stocke sans réserver, j'aurais besoin d'utiliser le tableau [tailIndex]! = Null check à l'étape 1) et devrait tester le dépassement de la mémoire tampon d'une autre manière. Merci encore pour votre temps, je l'apprécie.


Les lignes 68 et 90 sont-elles censées être «%» au lieu de «&»? Je ne pense pas que le conditionnel à la ligne 60 fonctionne correctement. _tail ne doit jamais dépasser la taille - 1, et comme head est toujours positif, le seul moyen pour que 'tail> = head + size-1' renvoie true est si head == 0 et si tail == size-1, ce qui n'est pas le seul cas où la file d'attente est pleine, non?


En tout cas, je n'ai pas l'intention de vous faire coder pour moi. Vous avez déjà répondu à la question initiale et expliqué ce qui n'allait pas pour moi, c'est donc tout ce que je peux vraiment demander.


3 Réponses :


0
votes

Il existe un document sur le msdn Expliqué la différence: Acquérir et publier la sémantique .

Pour l'échantillon:

 a++;
 b++;
 c++;
 d++;
  • Si nous utilisons la sémantique d'acquisition pour incrémenter a , les autres processeurs verront toujours l'incrémentation de a avant les incréments de b et c ;
  • Si nous utilisons la sémantique de release pour incrémenter c , les autres processeurs verront toujours les incréments de a et b avant l'incrément de c ;
  • les routines InterlockedXxx fonctionnent, ont à la fois une sémantique d'acquisition et de libération par défaut.

Plus précis, pour 4 valeurs:

 a++;
 b++;
 c++;
  • Si nous utilisons la sémantique d'acquisition pour incrémenter b , les autres processeurs verront toujours l'incrément de b avant les incréments de c et d ; L'ordre peut être a->b->c,d ou b->a,c,d .
  • Si nous utilisons la sémantique de release pour incrémenter c , les autres processeurs verront toujours les incréments de a et b avant l'incrément de c ; L'ordre peut être a,b->c->d ou a,b,d->c .

Pour citer cette réponse de @antiduh:

Acquérir dit "ne vous souciez des choses qu'après moi". Le communiqué dit "ne vous souciez que des choses devant moi". Combiner les deux est une barrière complète de la mémoire.


2 commentaires

Est-ce la seule différence entre les trois formes d'une fonction imbriquée? Existe-t-il une version préférée dans les situations où les trois entraîneraient un comportement correct?


Nevermind - J'ai reçu de l'aide des autres chaînes de commentaires



0
votes

Les trois versions empêchent le compilateur de déplacer du code à travers l'appel de fonction, mais le compilateur n'est pas le seul endroit où la réorganisation a lieu.

Les processeurs modernes ont une "exécution dans le désordre" et même une "exécution spéculative". L'acquisition et la libération de la sémantique amènent le code à compiler des instructions avec des indicateurs ou des préfixes contrôlant la réorganisation dans le CPU.


3 commentaires

Merci, cela a du sens. Pourtant, pour autant que je sache, il ne semble pas que mon code puisse être réorganisé en toute sécurité d'un point de vue unique. Cela ne devrait-il pas empêcher de toute façon de réorganiser ses déclarations?


@ChristopherInokuchi - qu'est-ce qui peut être réorganisé ici?


@ChristopherInokuchi: Le CPU utilise des techniques telles que le changement de nom de registre et la lecture à partir du cache du processeur interne pour que le thread actuel se comporte correctement même en présence de réorganisation. Quelque chose comme *p = f(); g(p); peut être transformé en un ordre d'exécution réel qui ne peut même pas être exprimé en C.Le stockage de mémoire réel dans *p est susceptible d'avoir lieu quelque part au milieu de la fonction g , et à chaque fois que g lit à partir de *p avant cette écriture, à la place de mémoire, il sera en fait satisfait par le tampon de stockage interne du CPU qui attend d'être écrit.



2
votes

Ma question est la suivante: si une fonction est structurée de telle sorte que le compilateur ne puisse de toute façon réorganiser aucun code car cela affecterait le comportement à un seul thread, y a-t-il une différence entre l'une des variantes d'une fonction interlocked, ou toutes effectivement le même? La seule différence entre eux est-elle comment ils interagissent avec la réorganisation du code?

Vous pouvez confondre les instructions C ++ avec les instructions . Votre question n'est pas spécifique au processeur, vous devez donc prétendre que vous n'avez aucune idée de ce à quoi ressemblent les instructions du processeur.

Considérez ce code:

int c = b;
b = 5;
if (a != 2)
    b = c;

Maintenant, voici un exemple de réorganisation de ce code qui n'affecte pas un seul thread:

if (a == 2)
{
    b = 5;
}

Celui-ci effectue les mêmes opérations mais dans un ordre différent. Il n'a aucun effet sur le code monothread. Mais, bien sûr, si un autre thread accédait à b , il pouvait voir une valeur de 5 partir de ce code même si a n'était jamais 2 .

Ainsi, il pourrait également voir une valeur de 5 partir du code d'origine même si a n'est jamais 2!

Pourquoi, parce que les deux bits de code fonctionnent de la même manière du point de vue d'un seul thread. Et à moins que vous n'utilisiez des opérations avec une sémantique de threading garantie, c'est tout ce que le compilateur, le processeur, les caches et les autres composants de la plate-forme doivent préserver.

Il est donc très probable que votre croyance selon laquelle la réorganisation de l'un des codes affecterait le comportement à un seul thread est probablement incorrecte. Il existe de nombreuses façons de réorganiser et d'optimiser le code qui n'affecte pas le comportement monothread.


3 commentaires

Cela s'appelle "exécution spéculative" plutôt que "exécution dans le désordre", mais c'est une chose réelle pour les processeurs modernes, donc la réponse est tout à fait correcte à part appeler cette transformation "réordonnancement".


@BenVoigt C'est un peu les deux, car l'écriture dans b (dans le cas où il est en fait mis à 5 ) se produit avant le test de a .


«Spéculative» implique que l'action est entreprise (ou au moins préparée) avant que l'on sache si elle doit l'être. Désormais, de vraies implémentations d'exécution spéculative semblent contenir le résultat potentiel dans un registre CPU shadow où il peut être utilisé pour satisfaire les dépendances de flux de données pour plus de spéculation, sans le laisser atteindre la mémoire qu'un autre cœur pourrait voir (c'est pourquoi les attaques de canal latéral comme le fait que la spéculation ait des effets observables sur le cache est un si gros problème). Mais il n'y a certainement rien dans le modèle de mémoire de cohérence séquentielle C ++ qui empêche la spéculation visible.