Partager via


Problèmes de synchronisation et de multiprocesseur

Les applications peuvent rencontrer des problèmes lorsqu’elles s’exécutent sur des systèmes multiprocesseurs en raison d’hypothèses qu’elles rendent valides uniquement sur les systèmes à processeur unique.

Priorités des threads

Considérez un programme avec deux threads, un avec une priorité plus élevée que l’autre. Sur un système à processeur unique, le thread de priorité supérieure n’abandonne pas le contrôle au thread de priorité inférieure, car le planificateur donne la préférence aux threads de priorité supérieure. Sur un système multiprocesseur, les deux threads peuvent s’exécuter simultanément, chacun sur son propre processeur.

Les applications doivent synchroniser l’accès aux structures de données pour éviter les conditions de concurrence. Le code qui suppose que les threads de priorité supérieure s’exécutent sans interférence des threads de priorité inférieure échouent sur les systèmes multiprocesseurs.

Ordre de la mémoire

Lorsqu’un processeur écrit dans un emplacement de mémoire, la valeur est mise en cache pour améliorer les performances. De même, le processeur tente de satisfaire les demandes de lecture du cache pour améliorer les performances. En outre, les processeurs commencent à extraire des valeurs de la mémoire avant qu’elles ne soient demandées par l’application. Cela peut se produire dans le cadre de l’exécution spéculative ou en raison de problèmes de ligne de cache.

Les caches d’UC peuvent être partitionnés en banques accessibles en parallèle. Cela signifie que les opérations de mémoire peuvent être terminées en dehors de l’ordre. Pour vous assurer que les opérations de mémoire sont effectuées dans l’ordre, la plupart des processeurs fournissent des instructions de barrière de mémoire. Une barrière de mémoire complète garantit que les opérations de lecture et d’écriture de mémoire qui apparaissent avant l’instruction de barrière de mémoire sont validées en mémoire avant toute opération de lecture et d’écriture de mémoire qui apparaissent après l’instruction de barrière de mémoire. Une barrière de mémoire en lecture commande uniquement les opérations de lecture de la mémoire et une barrière de mémoire en écriture commande uniquement les opérations d’écriture de mémoire. Ces instructions garantissent également que le compilateur désactive toutes les optimisations susceptibles de réorganiser les opérations de mémoire sur les barrières.

Les processeurs peuvent prendre en charge des instructions pour les barrières de mémoire avec la sémantique d’acquisition, de mise en production et de clôture. Ces sémantiques décrivent l’ordre dans lequel les résultats d’une opération deviennent disponibles. Avec la sémantique d’acquisition, les résultats de l’opération sont disponibles avant les résultats d’une opération qui apparaît après celle-ci dans le code. Avec la sémantique de mise en production, les résultats de l’opération sont disponibles après les résultats d’une opération qui apparaît avant celle-ci dans le code. La sémantique de clôture combine la sémantique d’acquisition et de mise en production. Les résultats d’une opération avec sémantique de clôture sont disponibles avant ceux d’une opération qui apparaît après celle-ci dans le code et après celles d’une opération qui apparaît avant elle.

Sur les processeurs x86 et x64 qui prennent en charge SSE2, les instructions sont mfence (clôture de mémoire), lfence (clôture de charge) et sfence (clôture de magasin). Sur les processeurs ARM, les instrutions sont dmb et dsb. Pour plus d’informations, consultez la documentation du processeur.

Les fonctions de synchronisation suivantes utilisent les barrières appropriées pour garantir l’ordre de la mémoire :

  • Fonctions qui entrent ou quittent des sections critiques
  • Fonctions qui acquièrent ou libèrent des verrous SRW
  • Début et achèvement de l’initialisation à usage unique
  • Fonction de EnterSynchronizationBarrier
  • Fonctions qui signalent les objets de synchronisation
  • Fonctions d’attente
  • Fonctions interblocées (à l’exception des fonctions avec suffixe noFence, ou intrinsèques avec _nf suffixe)

Résolution d’une condition de concurrence

Le code suivant a une condition de concurrence sur un système multiprocesseur, car le processeur qui s’exécute CacheComputedValue la première fois peut écrire fValueHasBeenComputed dans la mémoire principale avant d’écrire iValue dans la mémoire principale. Par conséquent, un deuxième processeur exécutant FetchComputedValue en même temps lit fValueHasBeenComputed comme TRUE, mais la nouvelle valeur de iValue est toujours dans le cache du premier processeur et n’a pas été écrite en mémoire.

int iValue;
BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (!fValueHasBeenComputed) 
  {
    iValue = ComputeValue();
    fValueHasBeenComputed = TRUE;
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (fValueHasBeenComputed) 
  {
    *piResult = iValue;
    return TRUE;
  } 

  else return FALSE;
}

Cette condition de concurrence ci-dessus peut être réparée à l’aide du mot clé volatile ou de la fonction InterlockedExchange pour vous assurer que la valeur de iValue est mise à jour pour tous les processeurs avant que la valeur de fValueHasBeenComputed soit définie sur TRUE.

À compter de Visual Studio 2005, s’il est compilé en mode /volatile :ms, le compilateur utilise la sémantique d’acquisition pour les opérations de lecture sur variables volatiles et la sémantique de mise en production pour les opérations d’écriture sur variables volatiles (lorsqu’il est pris en charge par l’UC). Par conséquent, vous pouvez corriger l’exemple comme suit :

volatile int iValue;
volatile BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (!fValueHasBeenComputed) 
  {
    iValue = ComputeValue();
    fValueHasBeenComputed = TRUE;
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (fValueHasBeenComputed) 
  {
    *piResult = iValue;
    return TRUE;
  } 

  else return FALSE;
}

Avec Visual Studio 2003, volatile pour références de volatiles sont classées ; le compilateur ne récommande pas accès aux variables volatiles. Toutefois, ces opérations peuvent être réinditées par le processeur. Par conséquent, vous pouvez corriger l’exemple comme suit :

int iValue;
BOOL fValueHasBeenComputed = FALSE;
extern int ComputeValue();

void CacheComputedValue()
{
  if (InterlockedCompareExchange((LONG*)&fValueHasBeenComputed, 
          FALSE, FALSE)==FALSE) 
  {
    InterlockedExchange ((LONG*)&iValue, (LONG)ComputeValue());
    InterlockedExchange ((LONG*)&fValueHasBeenComputed, TRUE);
  }
}
 
BOOL FetchComputedValue(int *piResult)
{
  if (InterlockedCompareExchange((LONG*)&fValueHasBeenComputed, 
          TRUE, TRUE)==TRUE) 
  {
    InterlockedExchange((LONG*)piResult, (LONG)iValue);
    return TRUE;
  } 

  else return FALSE;
}

objets de section critique

d’accès aux variables verrouillées

Fonctions d’attente