Synchronisierungs- und Multiprozessorprobleme

Anwendungen können bei der Ausführung auf Multiprozessorsystemen auf Probleme stoßen, weil sie Annahmen treffen, die nur auf Einprozessorsystemen gültig sind.

Threadprioritäten

Nehmen wir ein Programm mit zwei Threads, von denen einer eine höhere Priorität hat als der andere. Auf einem Einprozessorsystem wird der Thread mit der höheren Priorität die Kontrolle nicht an den Thread mit der niedrigeren Priorität abgeben, da der Scheduler Threads mit höherer Priorität bevorzugt. Auf einem Multiprozessorsystem können beide Threads gleichzeitig ausgeführt werden, jeweils auf einem eigenen Prozessor.

Anwendungen sollten den Zugriff auf Datenstrukturen synchronisieren, um Race Conditions zu vermeiden. Code, der davon ausgeht, dass Threads höherer Priorität ohne Beeinträchtigung durch Threads niedrigerer Priorität ablaufen, wird auf Multiprozessorsystemen fehlschlagen.

Speicheranordnung

Wenn ein Prozessor in einen Speicherplatz schreibt, wird der Wert zwischengespeichert, um die Leistung zu verbessern. Ebenso versucht der Prozessor, Leseanforderungen aus dem Cache zu erfüllen, um die Leistung zu verbessern. Außerdem beginnen Prozessoren mit dem Abrufen von Werten aus dem Speicher, bevor sie von der Anwendung angefordert werden. Dies kann im Rahmen der spekulativen Ausführung oder aufgrund von Cachezeilenproblemen auftreten.

CPU-Caches können in Banken partitioniert werden, auf die parallel zugegriffen werden kann. Dies bedeutet, dass Speichervorgänge außerhalb der Reihenfolge abgeschlossen werden können. Um sicherzustellen, dass Speichervorgänge in der Reihenfolge abgeschlossen sind, stellen die meisten Prozessoren Anweisungen für Speicherbarrieren bereit. Eine vollständige Speicherbarriere stellt sicher, dass Speicherlese- und Schreibvorgänge, die vor der Speicherbarriere-Anweisung angezeigt werden, an den Speicher zugesichert werden, bevor Speicherlese- und Schreibvorgänge nach der Speicherbarriere-Anweisung angezeigt werden. Eine Lesespeicherbarriere ordnet nur die Lesevorgänge des Arbeitsspeichers an und eine Speicherbarriere sortiert nur die Speicherschreibvorgänge. Diese Anweisungen stellen außerdem sicher, dass der Compiler Optimierungen deaktiviert, die Speichervorgänge über die Barrieren neu anordnen können.

Prozessoren können Anweisungen für Speicherbarrieren mit der Kauf-, Freigabe- und Zaunsemantik unterstützen. Diese Semantik beschreibt die Reihenfolge, in der Ergebnisse eines Vorgangs verfügbar werden. Beim Abrufen der Semantik stehen die Ergebnisse des Vorgangs vor den Ergebnissen eines Vorgangs zur Verfügung, der nach dem Vorgang im Code angezeigt wird. Beim Freigeben der Semantik stehen die Ergebnisse des Vorgangs nach den Ergebnissen eines Vorgangs zur Verfügung, der vor dem Vorgang im Code angezeigt wird. Zaunsemantik kombiniert Akquirieren und Freigeben der Semantik. Die Ergebnisse eines Vorgangs mit Zaunsemantik sind vor denen eines Vorgangs verfügbar, der nach dem Vorgang im Code und nach den Vorgängen vor dem Vorgang angezeigt wird.

Auf x86- und x64-Prozessoren, die SSE2 unterstützen, sind die Anweisungen mfence (Memory Fence), lfence (Load Fence) und sfence (Store Fence). Auf ARM-Prozessoren sind die Anweisungen dmb und dsb. Weitere Informationen findest du in der Dokumentation zum Prozessor.

Die folgenden Synchronisierungsfunktionen verwenden die entsprechenden Barrieren, um die Speicherordnung sicherzustellen:

  • Funktionen, die kritische Abschnitte eingeben oder verlassen
  • Funktionen, die SRW-Sperren abrufen oder freigeben
  • Start und Abschluss einmalige Initialisierung
  • Funktion EnterSynchronizationBarrier
  • Funktionen, die Synchronisierungsobjekte signalisieren
  • Wait Functions
  • Verriegelte Funktionen (außer Funktionen mit NoFence-Suffix oder systeminterne Funktionen mit _nf-Suffix)

Reparieren einer Racebedingung

Der folgende Code hat eine Race Condition auf einem Multiprozessorsystem, weil der Prozessor, der CacheComputedValue zum ersten Mal ausführt, möglicherweise fValueHasBeenComputed in den Hauptspeicher schreibt, bevor er iValue in den Hauptspeicher schreibt. Folglich liest ein zweiter Prozessor, der zur gleichen Zeit FetchComputedValue ausführt, fValueHasBeenComputed als TRUE, aber der neue Wert iValue befindet sich noch im Cache des ersten Prozessors und wurde noch nicht in den Speicher geschrieben.

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;
}

Diese Race Condition kann durch Verwendung des veränderlichen Schlüsselworts oder der Funktion InterlockedExchange behoben werden, um sicherzustellen, dass der Wert von iValue für alle Prozessoren aktualisiert wird, bevor der Wert von fValueHasBeenComputed auf TRUE gesetzt wird.

Ab Visual Studio 2005 wird beim Kompilieren im /volatile:ms-Modus die Semantik für Lesevorgänge für veränderliche Variablen verwendet und Semantik für Schreibvorgänge für veränderliche Variablen (wenn von der CPU unterstützt) freigegeben. Daher können Sie das Beispiel wie folgt korrigieren:

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;
}

In Visual Studio 2003 sind Verweise von veränderlich zu veränderlich geordnet; der Compiler ordnet den Zugriff auf veränderliche Variablen nicht neu. Diese Vorgänge können jedoch vom Prozessor neu sortiert werden. Daher können Sie das Beispiel wie folgt korrigieren:

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;
}

Kritische Abschnittsobjekte

Interlocked Variable Access

Wait Functions