Notes d'étude C/C++ Concurrence dans le matériel moderne

1. Concurrence dans le matériel moderne

1. Qu'est-ce que la simultanéité ?

function foo() { ... }
function bar() { ... }

function main() {
    t1 = startThread(foo)
    t2 = startThread(bar)
    // 在继续执行 main() 之前等待 t1 和 t2 完成
    waitUntilFinished(t1)
    waitUntilFinished(t2)
}

        Dans cet exemple de programme, la concurrence signifie que foo() et bar() s'exécutent en même temps. Comment le CPU fait-il réellement cela?

2. Comment utiliser la concurrence pour rendre votre programme plus rapide ?

        Les processeurs modernes peuvent exécuter plusieurs flux d'instructions simultanément :

        1. Un seul cœur de processeur peut exécuter plusieurs threads : le multithreading simultané (SMT), qu'Intel appelle l'hyperthreading

        2. Bien sûr, le processeur peut également avoir plusieurs cœurs pouvant fonctionner indépendamment

        Pour obtenir les meilleures performances en programmation, l'écriture de programmes multithreads est essentielle. Pour ce faire, une compréhension de base du comportement du matériel dans un environnement de programmation parallèle est nécessaire.

        La plupart des détails d'implémentation de bas niveau se trouvent dans le manuel du développeur du logiciel d'architecture Intel et le manuel de référence de l'architecture ARM.

3. SMT multithread simultané

        Les processeurs prennent en charge le parallélisme au niveau des instructions en utilisant une exécution dans le désordre

        En utilisant SMT (Simultaneous Multi-Threading), le processeur prend également en charge le parallélisme au niveau des threads

        1. Dans un seul cœur de processeur, exécutez plusieurs threads

        2. De nombreux composants matériels, tels que les unités ALU, SIMD, etc., sont partagés entre les threads

        3. Dupliquez d'autres composants pour chaque thread, tels que les instructions d'extraction et de décodage de l'unité de contrôle, enregistrez les fichiers

4. Le problème du SMT

        Lors de l'utilisation de SMT, plusieurs flux d'instructions partagent certains des cœurs du processeur.

        1. SMT n'améliore pas les performances lorsqu'un flux utilise toutes les unités de calcul individuellement

        2. La même bande passante mémoire

        3. Certaines unités peuvent n'exister qu'une seule fois sur le noyau, donc SMT dégradera également les performances

        4. Cela peut entraîner des problèmes de sécurité lorsque deux threads de processus non liés s'exécutent sur le même cœur ! Semblable aux problèmes de sécurité Spectre et Meltdown.

5. Cohérence du cache

        Différents cœurs peuvent accéder à la même mémoire en même temps, plusieurs cœurs peuvent partager le cache, le cache est inclus 

        Le CPU doit s'assurer que le cache est cohérent avec les accès concurrents ! Communication entre les processeurs à l'aide du protocole de cohérence de cache

6. Protocole MESI

        Les processeurs et les caches lisent et écrivent toujours à la granularité de la ligne de cache (c'est-à-dire 64 octets)

        Le protocole générique de cohérence de cache MESI attribue à chaque ligne de cache l'un des quatre états suivants :

                Modifié : la ligne de cache n'est stockée que dans un seul cache et a été modifiée dans le cache, mais n'a pas été réécrite dans la mémoire principale

                Exclusif : les lignes de cache ne sont stockées que dans un seul cache pour une utilisation exclusive par un processeur

                SharedShared : la ligne de cache est stockée dans au moins un cache, est actuellement utilisée par le processeur pour un accès en lecture seule et n'a pas été modifiée

                Non valide : la ligne de cache n'a pas été chargée ou utilisée exclusivement par un autre cache

(1) MOIS Exemple (1)

 (2) MOIS Exemple (2)

7. Accès à la mémoire et simultanéité

        Considérez l'exemple de programme suivant, où foo() et bar() s'exécuteront simultanément :

globalCounter = 0
function foo() {
    repeat 1000 times:
    globalCounter = globalCounter - 1
}

function bar() {
    repeat 1000 times:
    globalCounter = (globalCounter + 1) * 2
}

        Le code machine de ce programme pourrait ressembler à ceci :

         Quelle est la valeur finale de globalCounter ?

8, ordre de mémoire

        L'exécution dans le désordre et le multitraitement simultané entraînent une exécution inattendue des instructions de chargement et de stockage de la mémoire

        Toutes les instructions exécutées finiront par se terminer

        Cependant, les effets des instructions de la mémoire (c'est-à-dire les lectures et les écritures) peuvent devenir visibles dans un ordre indéterminé

        Le fournisseur de CPU définit comment les lectures et les écritures peuvent être entrelacées ! ordre de la mémoire

        En général : les instructions pertinentes dans un seul thread fonctionnent toujours comme prévu :

store $123, A
load A, %r1

        Si l'emplacement mémoire en A n'est accessible que par ce thread, alors r1 contiendra toujours 123

(1) Ordre de mémoire faible et fort

        Les architectures de CPU ont souvent un ordre de mémoire faible (par exemple ARM) ou un ordre de mémoire fort (par exemple x86)

        Séquence de mémoire faible :

                Les instructions de mémoire et leurs effets peuvent être réordonnés tant que les dépendances sont respectées

                Différents threads verront des écritures dans des ordres différents

        Commande de mémoire forte :

                Dans un thread, seuls les magasins paresseux sont autorisés après les chargements suivants, tout le reste n'est pas réorganisé

                Lorsque deux threads effectuent le stockage au même emplacement, tous les autres threads verront le résultat écrit dans le même ordre

                Tous les autres threads verront les écritures d'un ensemble de threads dans le même ordre

        Pour les deux:

                Les écritures d'autres fils peuvent être réorganisées

                Les accès mémoire simultanés au même emplacement peuvent être réorganisés

(2)Exemple d'ordre de mémoire (1)

Dans cet exemple, initialement la mémoire en A contient la valeur 1 et la mémoire en B contient la valeur 2.

Ordre de mémoire faible :

        Les threads n'ont pas d'instructions dépendantes

        Les instructions de mémoire peuvent être arbitrairement réordonnées

        r1 = 3, r2 = 2, r3 = 4, r4 = 1 sont autorisés

Commande de mémoire forte :

        Les threads 3 et 4 doivent voir les écritures des threads 1 et 2 dans le même ordre

        Exemple où l'ordre de mémoire faible n'est pas autorisé

        r1 = 3, r2 = 2, r3 = 4, r4 = 3 sont autorisés

(3)Exemple d'ordre de mémoire (2) 

Visualisation d'un exemple d'ordre de mémoire faible :

        Le fil 3 voit écrire A avant d'écrire B. (4.) (1.)
        Le fil 4 voit écrire B avant d'écrire A. (8.) (5.)
        En mémoire forte, 5. non Autorisé à se produire avant 8.

9、Barrières de mémoire

        Les processeurs multicœurs ont des instructions spéciales de barrière de mémoire (également appelées barrière de mémoire) qui appliquent des exigences de commande de mémoire plus strictes

        Ceci est particulièrement utile pour les architectures avec un ordre de mémoire faible

        x86 a les instructions de barrière suivantes :

        lfence : les chargements antérieurs ne peuvent pas être réorganisés en dehors de cette instruction, et les chargements et magasins ultérieurs ne peuvent pas être réorganisés avant cette instruction

        sfence : les magasins antérieurs ne peuvent pas être réorganisés après cette directive, les magasins ultérieurs ne peuvent pas être réorganisés avant cette directive

        mfence : les charges ou les magasins ne peuvent pas être réorganisés après ou avant cette instruction

        ARM a des instructions de barrière de mémoire de données qui prennent en charge différents modes :

        dmb ishst : toutes les écritures qui étaient visibles dans ou causées par ce thread avant cette instruction seront visibles par tous les threads avant toute écriture des magasins qui suivent cette instruction

        dmb ish : toutes les écritures visibles dans ou causées par ce thread et les lectures associées précédant cette instruction seront visibles par tous les threads avant toute lecture et écriture qui suivent cette instruction

        Afin de contrôler en plus l'exécution dans le désordre, ARM fournit des instructions de barrière de synchronisation des données : dsb ishst, dsb ish

10、Opérations atomiques

        L'ordre de la mémoire ne se soucie que des charges et des magasins de mémoire

        Il n'y a aucune restriction d'ordre de mémoire sur les magasins simultanés dans le même emplacement de mémoire ! la commande peut être indéfinie

        Pour permettre des modifications simultanées déterministes, la plupart des architectures prennent en charge les opérations atomiques

        Une opération atomique est généralement une séquence : charger des données, modifier des données, stocker des données

        Également connu sous le nom de lecture-modification-écriture (RMW)

        Le processeur garantit que toutes les opérations RMW sont effectuées de manière atomique, c'est-à-dire qu'aucun autre chargement et stockage simultanés n'est autorisé entre les deux.

        Habituellement, une seule instruction arithmétique et binaire est prise en charge

11、Opérations de comparaison et d'échange (1)

         Sur x86, l'instruction RMW peut verrouiller le bus mémoire

        Pour éviter les problèmes de performances, il n'y a que quelques instructions RMW

        Pour faciliter des opérations atomiques plus complexes, des opérations atomiques Compare-And-Swap (CAS) peuvent être utilisées

        ARM ne prend pas en charge le verrouillage du bus mémoire, donc toutes les opérations RMW sont implémentées avec CAS

        Une instruction CAS a trois paramètres : l'emplacement mémoire m, la valeur attendue e et la valeur attendue d

        Conceptuellement, les opérations CAS fonctionnent comme suit :

        Remarque : les opérations CAS peuvent échouer, par exemple en raison de modifications simultanées

12、Opérations de comparaison et d'échange (2)

        Étant donné que les opérations CAS peuvent échouer, elles sont généralement utilisées dans une boucle avec les étapes suivantes :

        1. Charger la valeur de l'emplacement mémoire dans le registre local
        2. Utiliser le registre local pour le calcul en supposant qu'aucun autre thread ne modifiera l'emplacement mémoire
        3. Générer une nouvelle valeur attendue
        pour l'emplacement mémoire 4. Emplacement mémoire CAS avec la valeur dans le registre local comme valeur attendue Action
        5. Si l'opération CAS échoue, recommencez la boucle depuis le début

        Notez que les étapes 2 et 3 peuvent contenir n'importe quel nombre d'instructions et ne sont pas limitées aux instructions RMW !

13、Opérations de comparaison et d'échange (3)

        Une boucle typique utilisant CAS ressemble à ceci :

success = false
while (not success) { (Step 5)
    expected = load(A) (Step 1)
    desired = non_trivial_operation(expected) (Steps 2, 3)
    success = CAS(A, expected, desired) (Step 4)
}

        En utilisant cette approche, des opérations atomiques arbitrairement complexes peuvent être effectuées sur des emplacements de mémoire

        Cependant, la probabilité d'échec augmente, plus le temps est consacré à des opérations non conventionnelles

        De plus, des opérations non conventionnelles peuvent être effectuées plus fréquemment que nécessaire

2. Programmation parallèle

        Les programmes multithread contiennent souvent de nombreuses
                structures de données de ressources partagées Descripteurs du
                système d'exploitation (tels que des descripteurs de fichiers)
                dans des emplacements de mémoire séparés

        Nécessité de contrôler l'accès simultané aux ressources partagées
                Un accès incontrôlé conduit à des conditions de concurrence Les
                conditions de concurrence se terminent souvent par des états de programme incohérents
                D'autres résultats tels que la corruption silencieuse des données sont également possibles        

        La synchronisation peut être mise en œuvre de différentes manières par
                la prise en charge du système d'exploitation, par exemple via des mutex
                Prise en charge matérielle, en particulier via des opérations atomiques

1. Exclusion mutuelle (1)

        Supprimer des éléments de la liste liée en même temps

        L'observation
                C n'est pas réellement supprimée par
                le thread, il est également possible de libérer de la mémoire de nœud après la suppression 

2. Exclusion mutuelle (2)

        Protéger les ressources partagées en n'autorisant l'accès qu'aux sections critiques.
                Un seul thread à la fois peut entrer dans la section critique.
                S'il est utilisé correctement, il est toujours possible de s'assurer que l'état du programme est toujours cohérent et que le
                comportement du programme n'est pas déterministe (mais cohérent). est encore possible

        Il existe plusieurs possibilités pour implémenter l'exclusion mutuelle.Les
                opérations de test et d'ensemble atomiques
                        nécessitent souvent des rotations, ce qui peut être dangereux pour
                le support du système d'exploitation,
                        par exemple. Mutex sous Linux

3. Verrouiller

        Un mutex est obtenu en acquérant un verrou sur l'objet mutex,
                seul un thread peut acquérir le mutex à la fois.
                Tenter d'acquérir un verrou sur un mutex verrouillé bloquera le thread jusqu'à ce que le mutex soit à nouveau disponible
                . Le thread bloqué peut être suspendu par le noyau pour libérer des ressources de calcul

        Plusieurs mutex peuvent être utilisés pour représenter des sections critiques distinctes
                Un seul thread peut entrer dans la même section critique à la fois, mais les threads peuvent entrer dans différentes sections critiques en même temps
                Permet une synchronisation plus fine
                Nécessite une mise en œuvre prudente pour éviter les blocages

4. Serrure partagée

        Les mutex stricts ne sont pas toujours nécessaires
                Les accès simultanés communs en lecture seule à la même ressource partagée n'interfèrent pas les uns avec
                les
                autres accès en lecture

        Les verrous partagés offrent une solution. Un
                thread peut acquérir un verrou exclusif ou un verrou partagé sur un mutex.
                Si le mutex n'est pas exclusivement verrouillé, plusieurs threads peuvent acquérir un verrou partagé sur le mutex en même temps.
                Si le mutex n'est pas verrouillé par tout autre mode de verrouillage (exclusif ou partagé), un thread à la fois peut obtenir un verrou exclusif sur le mutex

5. Problème d'exclusion mutuelle (1)

        impasse

        Plusieurs threads attendent chacun que d'autres threads libèrent les verrous

        Éviter les interblocages
                Si possible, les threads ne doivent pas acquérir plusieurs verrous
                Si cela ne peut pas être évité, les verrous doivent toujours être acquis dans un ordre globalement cohérent

6. Problème d'exclusion mutuelle (2)


        Une forte contention pour les mutex          affamés peut empêcher certains threads de progresser
        Cela peut être partiellement atténué en utilisant un schéma de verrouillage moins restrictif

        Latence élevée
        Si la contention de mutex est intense, certains threads se bloqueront pendant une longue période,
        ce qui peut entraîner une
        dégradation significative des performances du système et peut même être inférieur aux performances d'un seul thread

        Inversion
        de priorité Les threads de haute priorité peuvent être bloqués par des threads de moindre priorité
        , ce qui peut empêcher les threads
        de faible priorité de disposer de ressources informatiques suffisantes pour libérer rapidement les verrous en raison des différences de priorité

7. Synchronisation assistée par matériel

        L'utilisation de mutex est généralement relativement coûteuse
               Chaque mutex nécessite un certain état (16 à 40 octets)
                L'acquisition d'un verrou peut nécessiter un appel système, ce qui peut prendre des milliers de cycles ou plus

        Les mutex sont donc les meilleurs pour le verrouillage à gros grains,
                par exemple. Verrouiller l'intégralité de la structure de données au lieu d'une partie de
                celle-ci est suffisant s'il n'y a que quelques threads qui se disputent le verrou sur
                le mutex s'il y a plus de sections critiques protégées par le mutex, cela coûte plus
                cher que l'appel système (potentiellement) à acquérir le verrou

        Les performances du mutex se dégradent rapidement en cas de conflit élevé.
                En particulier, la latence d'acquisition des verrous augmente considérablement.
                Cela se produit même lorsque nous acquérons uniquement des verrous partagés sur le mutex.
                Nous pouvons tirer parti du support matériel pour une synchronisation plus efficace.

8. Verrouillage optimiste (1)

        En général, l'accès en lecture seule aux ressources est plus courant que l'accès en écriture.
                Par conséquent, nous devons optimiser le cas courant de l'accès en lecture seule.
                En particulier, l'accès parallèle en lecture seule par de nombreux threads devrait être efficace.
                Les verrous partagés ne le sont pas . adapté à cela

        Le verrouillage optimiste peut fournir une synchronisation lecteur/graveur efficace
                Associer une version à une ressource partagée Les
                écritures doivent toujours acquérir une sorte de verrou exclusif
                        Cela garantit qu'un seul auteur à la fois peut accéder à la ressource
                        À la fin de sa section critique, l'auteur incrémente automatiquement la version pour les
                lectures vérifie simplement que la version
                        est au début de sa section critique, la lecture lit atomiquement la version courante
                        à la fin de sa section critique, la lecture vérifie que la version n'a pas changé
                        Sinon, une écriture concurrente se produit et le la section critique est redémarrée

9. Verrouillage optimiste (2)

        Exemple (pseudocode)

writer(optLock) {
    lockExclusive(optLock.mutex) // begin critical section
    // modify the shared resource
    storeAtomic(optLock.version, optLock.version + 1)
    unlockExclusive(optLock.mutex) // end critical section
}

reader(optLock) {
    while(true) {
        current = loadAtomic(optLock.version); // begin critical section
        // read the shared resource
        if (current == loadAtomic(optLock.version)) // validate
            return; // end critical section
    }
}

10. Verrouillage optimiste (3)

        Pourquoi le verrouillage optimiste fonctionne-t-il ?
                Une lecture n'a besoin d'exécuter que deux instructions de chargement atomique,
                ce qui est beaucoup moins cher que l'acquisition d'un verrou partagé
                mais nécessite peu de modifications, sinon la lecture devrait être redémarrée fréquemment

        Les ressources partagées du lecteur
                peuvent être modifiées lorsque les lecteurs y accèdent
                Nous ne pouvons pas supposer que nous lisons à partir d'un état cohérent
                Des opérations de lecture plus complexes peuvent nécessiter une validation intermédiaire supplémentaire

11. Au-delà de l'exclusion mutuelle

        Dans de nombreux cas, une exclusion mutuelle stricte n'est pas nécessaire en premier lieu
                , par exemple. Insertion parallèle dans la liste liée,
                nous ne nous soucions pas de l'ordre d'insertion
                , nous devons simplement nous assurer que toutes les insertions sont reflétées dans l'état final

        Ceci peut être réalisé efficacement en utilisant des opérations atomiques (pseudocode)

threadSafePush(linkedList, element) {
    while (true) {
        head = loadAtomic(linkedList.head)
        element.next = head
        if (CAS(linkedList.head, head, element))
            break;
    }
}

12. Algorithme non bloquant

        Les algorithmes ou les structures de données qui ne reposent pas sur des verrous sont appelés non bloquants,
                par exemple. La fonction threadSafePush ci-dessus
                La synchronisation entre les threads est souvent implémentée à l'aide d'opérations atomiques pour
                permettre une implémentation plus efficace de nombreux algorithmes et structures de données courants

        De tels algorithmes peuvent fournir différents niveaux de progrès garantissant
                la liberté d'attente : il existe une borne supérieure sur le nombre d'étapes nécessaires pour effectuer chaque opération
                        , ce qui est difficile à atteindre en pratique.

        sans verrou : si le programme s'exécute pendant suffisamment de temps, au moins un thread progresse
                Souvent utilisé de manière informelle (et techniquement incorrecte) comme synonyme de non bloquant

13. Problème ABA (1)

        Les structures de données non bloquantes nécessitent une mise en œuvre minutieuse.
                Nous n'avons plus le luxe des sections critiques.
                Les threads peuvent effectuer différentes opérations sur la structure de données en parallèle (telles que des insertions et des suppressions)
                . Une seule opération atomique contenant ces opérations composites peut être arbitrairement entrelacée.
                Cela peut conduire à des anomalies difficiles à déboguer telles que des mises à jour perdues ou des problèmes ABA

        Les problèmes peuvent souvent être évités en veillant à ce que seules des opérations identiques (telles que des insertions) soient effectuées en parallèle

                Par exemple. Insérer des éléments en parallèle dans la première étape et les supprimer en parallèle dans la deuxième étape

14. Problème ABA (2)

        Considérez la pile basée sur une liste liée simple suivante (pseudocode)

threadSafePush(stack, element) {
    while (true) {
        head = loadAtomic(stack.head)
        element.next = head
        if (CAS(stack.head, head, element))
            break;
    }
}

threadSafePop(stack) {
    while (true) {
        head = loadAtomic(stack.head)
        next = head.next
        if (CAS(stack.head, head, next))
            return head
    }
}

15、Problème ABA (3)

        Considérez l'état initial suivant de la pile sur laquelle deux threads effectuent certaines opérations en parallèle

         Notre implémentation permettra d'effectuer l'entrelacement comme suit

 16. Le danger de vrille (1)

        Un "meilleur" mutex peut être implémenté qui nécessite moins d'espace et n'utilise pas
                d'appels système utilisant des opérations atomiques :
                le mutex est représenté par un seul entier atomique
                0 lorsqu'il est déverrouillé et 1 lorsqu'il est verrouillé
                pour verrouiller le mutex , changez-le en 1 uniquement si le la valeur est changée de manière atomique à 0 en utilisant CAS
                CAS se répète tant qu'un autre thread contient le mutex

function lock(mutexAddress) {
    while (CAS(mutexAddress, 0, 1) not sucessful) {
        <noop>
    }
}
function unlock(mutexAddress) {
    atomicStore(mutexAddress, 0)
}

17. Le danger de vrille (2)

        L'utilisation de cette boucle CAS comme mutex, également connue sous le nom de verrou tournant, présente plusieurs inconvénients :
        1. Elle n'a aucune équité, c'est-à-dire qu'il n'y a aucune garantie que le thread finira par acquérir le verrou

        2. Le cycle CAS consomme des cycles CPU (gaspillage d'énergie et de ressources)

        3. Il est facile de provoquer une inversion de priorité

                Le planificateur du système d'exploitation pense que les threads en rotation nécessitent beaucoup de temps CPU

                Les fils tournants ne font en fait aucun travail utile

                Dans le pire des cas, le planificateur prend le temps CPU du thread qui détient le verrou pour le donner au thread qui tourne

        4. La rotation prend plus de temps à tourner, ce qui aggrave la situation
        3. Solution possible :
                rotation pour un nombre fini de fois (par exemple, combien d'itérations)
                Retour à la "vraie" mutuelle si le verrou ne peut pas être acquis.
                En fait, il s'agit de l'implémentation habituelle des verrous d'exclusion mutuelle (tels que les verrous biaisés, les verrous légers, les verrous lourds en Java, les verrous biaisés semblent être annulés dans la dernière version).

        Vous trouverez ci-dessous une implémentation complète d'un verrou tournant de base utilisant les atomes C++11

struct spinlock {
  std::atomic<bool> lock_ = {0};

  void lock() noexcept {
    for (;;) {
      // 乐观地假设锁在第一次尝试时是空闲的
      if (!lock_.exchange(true, std::memory_order_acquire)) {
        return;
      }
      // 等待释放锁而不产生缓存未命中
      while (lock_.load(std::memory_order_relaxed)) {
        // 发出 X86 PAUSE 或 ARM YIELD 指令以减少超线程(hyper-threads)之间的争用
        __builtin_ia32_pause();
      }
    }
  }

  bool try_lock() noexcept {
    // 首先做一个简单的加载来检查锁是否空闲,以防止在有人这样做时不必要的缓存未命中 while(!try_lock())
    return !lock_.load(std::memory_order_relaxed) &&
           !lock_.exchange(true, std::memory_order_acquire);
  }

  void unlock() noexcept {
    lock_.store(false, std::memory_order_release);
  }
};

        Le but d'un verrou tournant est d'empêcher plusieurs threads d'accéder à une structure de données partagée en même temps. Contrairement à un mutex, le thread sera occupé à attendre et à gaspiller des cycles CPU au lieu de céder le CPU à un autre thread. À moins que vous ne soyez sûr de comprendre les conséquences, n'utilisez pas de verrous tournants personnalisés, mais utilisez des variables atomiques fournies dans différentes langues.

Je suppose que tu aimes

Origine blog.csdn.net/bashendixie5/article/details/127187791
conseillé
Classement