Recherche…


Remarques

Différents threads essayant d'accéder au même emplacement de mémoire participent à une course de données si au moins une des opérations est une modification (également appelée opération de stockage ). Ces courses de données provoquent un comportement indéfini . Pour les éviter, il est nécessaire d'empêcher ces threads d'exécuter simultanément de telles opérations conflictuelles.

Les primitives de synchronisation (mutex, section critique, etc.) peuvent protéger ces accès. Le modèle de mémoire introduit en C ++ 11 définit deux nouvelles méthodes portables pour synchroniser l'accès à la mémoire dans un environnement multithread: les opérations atomiques et les clôtures.

Opérations atomiques

Il est maintenant possible de lire et d’écrire dans un emplacement de mémoire donné en utilisant les opérations de chargement atomique et de stockage atomique . Pour plus de commodité, elles sont regroupées dans la classe de modèle std::atomic<t> . Cette classe enveloppe une valeur de type t mais cette fois-ci, les charges et les stockages dans l'objet sont atomiques.

Le modèle n'est pas disponible pour tous les types. Les types disponibles sont spécifiques à l'implémentation, mais ils incluent généralement la plupart des types intégraux (ou la totalité) ainsi que des types de pointeurs. Donc, std::atomic<unsigned> et std::atomic<std::vector<foo> *> devraient être disponibles, alors que std::atomic<std::pair<bool,char>> ne le sera probablement pas.

Les opérations atomiques ont les propriétés suivantes:

  • Toutes les opérations atomiques peuvent être effectuées simultanément à partir de plusieurs threads sans provoquer un comportement indéfini.
  • Une charge atomique verra soit la valeur initiale avec laquelle l'objet atomique a été construit, soit la valeur écrite via une opération de stockage atomique .
  • Les magasins atomiques du même objet atomique sont ordonnés de la même manière dans tous les threads. Si un thread a déjà vu la valeur d'une opération de stockage atomique , les opérations de chargement atomique suivantes verront soit la même valeur, soit la valeur stockée par une opération de stockage atomique ultérieure.
  • Les opérations de lecture-modification-écriture atomiques permettent à la charge atomique et à la mémoire atomique de se produire sans autre entrepôt atomique . Par exemple, on peut incrémenter de manière atomique un compteur à partir de plusieurs threads, et aucun incrément ne sera perdu indépendamment du conflit entre les threads.
  • Les opérations atomiques reçoivent un paramètre optionnel std::memory_order qui définit les propriétés supplémentaires de l'opération par rapport aux autres emplacements de mémoire.
std :: memory_order Sens
std::memory_order_relaxed pas de restrictions supplémentaires
std::memory_order_releasestd::memory_order_acquire si load-acquire voit la valeur stockée par store-release alors les magasins sont séquencés avant que la store-release se produise avant que les charges ne soient séquencées après l' acquisition du chargement
std::memory_order_consume comme memory_order_acquire mais seulement pour les charges dépendantes
std::memory_order_acq_rel combine load-acquire et store-release
std::memory_order_seq_cst cohérence séquentielle

Ces balises d'ordre mémoire permettent trois disciplines différentes de la mémoire: la cohérence séquentielle , relaxée et la libération-acquisition avec sa version-consume frère.

Consistance séquentielle

Si aucun ordre de mémoire n'est spécifié pour une opération atomique, l'ordre par défaut est la cohérence séquentielle . Ce mode peut également être explicitement sélectionné en balisant l'opération avec std::memory_order_seq_cst .

Avec cet ordre, aucune opération de mémoire ne peut traverser l'opération atomique. Toutes les opérations de mémoire séquencées avant l'opération atomique se produisent avant l'opération atomique et l'opération atomique se produit avant toutes les opérations de mémoire qui sont séquencées après celle-ci. Ce mode est probablement le plus facile à raisonner, mais il entraîne également la plus grande pénalité en matière de performance. Il empêche également toutes les optimisations du compilateur qui pourraient autrement tenter de réorganiser les opérations après l'opération atomique.

Commande détendue

Le contraire de la cohérence séquentielle est le classement de la mémoire détendue . Il est sélectionné avec la balise std::memory_order_relaxed . Une opération atomique détendue n'imposera aucune restriction aux autres opérations de mémoire. Le seul effet qui reste, c'est que l'opération est elle-même encore atomique.

Validation des commandes

Une opération de stockage atomique peut être balisée avec std::memory_order_release et une opération de chargement atomique peut être balisée avec std::memory_order_acquire . La première opération est appelée (atomique) release-release tandis que la seconde est appelée (atomic) load -acquise .

Lorsque load-ACED voit la valeur écrite par une libération de magasin, il se produit ce qui suit: toutes les opérations de magasin séquencées avant la libération de magasin deviennent visibles pour ( se produire avant ) les opérations de chargement après l' acquisition de charge .

Les opérations de lecture-modification-écriture atomiques peuvent également recevoir la balise cumulative std::memory_order_acq_rel . Cela rend la partie de la charge atomique de l'opération une charge atomique acquisition tandis que la partie du magasin atomique devient magasin à libération atomique.

Le compilateur n'est pas autorisé à déplacer les opérations de stockage après une opération de libération en mémoire atomique . Il est également interdit de déplacer des opérations de chargement avant l’acquisition atomique (ou la charge-consommation ).

Notez également qu'il n'y a pas de libération de charge atomique ou d' acquisition de mémoire atomique . Tenter de créer de telles opérations en fait des opérations détendues .

Commande de sortie-consommation

Cette combinaison est similaire à release-acquisition , mais cette fois la charge atomique est balisée avec std::memory_order_consume et devient une opération de chargement-consommation (atomique) . Ce mode est identique à celui de release-acquisition avec la seule différence que parmi les opérations de chargement séquencées après le load-consume, seules celles -ci sont ordonnées en fonction de la valeur chargée par load-consume .

Clôtures

Les clôtures permettent également de classer les opérations de mémoire entre les threads. Une clôture est soit une clôture de libération ou acquérir une clôture.

Si une clôture de relâchement se produit avant une clôture d'acquisition, alors les magasins séquencés avant la clôture de libération sont visibles des charges séquencées après la clôture d'acquisition. Pour garantir que la clôture de validation se produit avant que la barrière d'acquisition ne soit utilisée, il est possible d'utiliser d'autres primitives de synchronisation, y compris des opérations atomiques assouplies.

Besoin d'un modèle de mémoire

int x, y;
bool ready = false;

void init()
{
  x = 2;
  y = 3;
  ready = true;
}
void use()
{
  if (ready)
    std::cout << x + y;
}

Un thread appelle la fonction init() tandis qu'un autre thread (ou gestionnaire de signal) appelle la fonction use() . On pourrait s'attendre à ce que la fonction use() imprime 5 ou ne fasse rien. Cela peut ne pas toujours être le cas pour plusieurs raisons:

  • Le processeur peut réorganiser les écritures qui se produisent dans init() afin que le code qui s'exécute réellement puisse ressembler à ceci:

    void init()
    {
      ready = true;
      x = 2;
      y = 3;
    }
    
  • Le CPU peut réorganiser les lectures qui se produisent dans use() pour que le code réellement exécuté devienne:

    void use()
    {
      int local_x = x;
      int local_y = y;
      if (ready)
        std::cout << local_x + local_y;
    }
    
  • Un compilateur C ++ optimisé peut décider de réorganiser le programme de la même manière.

Un tel réordonnancement ne peut pas modifier le comportement d'un programme s'exécutant dans un seul thread car un thread ne peut pas entrelacer les appels à init() et use() . D'un autre côté, dans un paramètre multithread, un thread peut voir une partie des écritures effectuées par l'autre thread où il peut arriver use() voit ready==true et que garbage in x ou y ou les deux.

Le modèle de mémoire C ++ permet au programmeur de spécifier quelles opérations de réordonnancement sont autorisées et lesquelles ne le sont pas, de sorte qu’un programme multithread puisse également se comporter comme prévu. L'exemple ci-dessus peut être réécrit de manière sécurisée comme ceci:

int x, y;
std::atomic<bool> ready{false};

void init()
{
  x = 2;
  y = 3;
  ready.store(true, std::memory_order_release);
}
void use()
{
  if (ready.load(std::memory_order_acquire))
    std::cout << x + y;
}

Ici, init() effectue une opération de libération de mémoire atomique . Cela non seulement stocke la valeur true dans ready , mais indique également au compilateur qu'il ne peut pas déplacer cette opération avant les opérations d'écriture qui sont séquencées avant elle.

La fonction use() effectue une opération d' acquisition de charge atomique . Il lit la valeur actuelle de ready et interdit également au compilateur de placer les opérations de lecture qui sont séquencées après que cela se soit produit avant que la charge atomique ne soit acquise .

Ces opérations atomiques obligent également le compilateur à mettre en place les instructions matérielles nécessaires pour informer le CPU de l’absence de réorganisations indésirables.

Étant donné que la version de mémoire atomique se trouve dans le même emplacement mémoire que le chargement-acquisition atomique , le modèle de mémoire stipule que si l’opération load-tâche voit la valeur écrite par l’opération store-release , toutes les écritures init() ' s thread avant cette release-store sera visible pour les charges que le thread use() exécute après son load-acquisition . C'est-à-dire que si use() ready==true , alors il est garanti que x==2 et y==3 .

Notez que le compilateur et le processeur sont toujours autorisés à écrire dans y avant d'écrire dans x , et de même les lectures de ces variables dans use() peuvent se produire dans n'importe quel ordre.

Exemple de clôture

L'exemple ci-dessus peut également être implémenté avec des clôtures et des opérations atomiques assouplies:

int x, y;
std::atomic<bool> ready{false};

void init()
{
  x = 2;
  y = 3;
  atomic_thread_fence(std::memory_order_release);
  ready.store(true, std::memory_order_relaxed);
}
void use()
{
  if (ready.load(std::memory_order_relaxed))
  {
    atomic_thread_fence(std::memory_order_acquire);
    std::cout << x + y;
  }
}

Si l'opération de charge atomique voit la valeur écrite par le magasin atomique alors le magasin se produit avant la charge, et ainsi faire les clôtures: la clôture de libération se produit avant la clôture acquire faire les écritures à x et y qui précèdent la clôture de libération pour devenir visible à l'instruction std::cout qui suit la barrière d'acquisition.

Une clôture peut être utile si elle permet de réduire le nombre total d’acquisitions, de versions ou d’autres opérations de synchronisation. Par exemple:

void block_and_use()
{
  while (!ready.load(std::memory_order_relaxed))
    ;
  atomic_thread_fence(std::memory_order_acquire);
  std::cout << x + y;
}

La fonction block_and_use() tourne jusqu'à ce que l'indicateur ready soit défini à l'aide d'une charge atomique relâchée. Ensuite, une seule barrière d'acquisition est utilisée pour fournir la commande de mémoire nécessaire.



Modified text is an extract of the original Stack Overflow Documentation
Sous licence CC BY-SA 3.0
Non affilié à Stack Overflow