C#

Das C#-Speichermodell in Theorie und Praxis

Igor Ostrovsky

 

Dies ist der erste Teil einer zweiteiligen Reihe, in der das C#-Speichermodell eingehend besprochen wird. Im ersten Teil werden die Garantien des C#-Speichermodells und die den Garantien zugrunde liegenden Codemuster erläutert. Im zweiten Teil wird ausführlich erklärt, wie die Garantien auf unterschiedlichen Hardwarearchitekturen in Microsoft .NET Framework 4.5 erreicht werden können.

Die Multithreadprogrammierung ist unter anderem so komplex, weil der Compiler und die Hardware subtil die Speicheroperationen eines Programms umwandeln können, sodass nicht nur das Singlethreadverhalten, sondern auch das Multithreadverhalten betroffen ist. Betrachten Sie folgende Methode:

void Init() {
  _data = 42;
  _initialized = true;
}

Wenn „_data“ und „_initialized“ gewöhnliche (also nicht flüchtige) Felder sind, können der Compiler und der Prozessor die Operationen so neu ordnen, dass „Init“ ausgeführt wird, als ob sie folgendermaßen geschrieben wäre:

void Init() {
  _initialized = true;
  _data = 42;
}

Verschiedene Optimierungen in Compilern und Prozessoren können eine derartige Neuordnung herbeiführen. Dies wird in Teil 2 besprochen.

In einem Singlethreadprogramm erhält das Programm durch die Neuordnung von Anweisungen in „Init“ keine andere Bedeutung. Solange „_initialized“ und „_data“ vor der Rückgabe der Methode aktualisiert werden, ist die Reihenfolge der Zuweisungen unwesentlich. In einem Singlethreadprogramm gibt es keinen weiteren Thread, der den Status zwischen den Aktualisierungen beobachten könnte.

In einem Multithreadprogramm kann dagegen die Reihenfolge der Zuweisungen eine Rolle spielen, weil ein anderer Thread möglicherweise während der Ausführung von „Init“ die Felder liest. Folglich kann in der neu geordneten Version von „Init“ ein anderer Thread „_initialized=true“ und „_data=0“ beobachten.

Das C#-Speichermodell stellt einen Satz von Regeln dar, der beschreibt, welche Arten von Neuordnungen von Speicheroperationen zulässig sind und welche nicht. Alle Programme müssen für die in der Spezifikation definierten Garantien geschrieben werden.

Auch wenn der Compiler und der Prozessor Speicheroperationen neu ordnen dürfen, tun sie dies nicht zwangsläufig immer in der Praxis. Viele Programme, die nach dem abstrakten C#-Speichermodell einen Fehler enthalten, werden mit einer bestimmten Hardware und einer bestimmten Version von .NET Framework trotzdem korrekt ausgeführt. Insbesondere ordnen x86- und x64-Prozessoren Operationen nur in gewissen eingeschränkten Szenarios neu. Ebenso führt der CLR-JIT-Compiler nicht alle Transformationen aus, die erlaubt sind.

Beim Schreiben von neuem Code sollten Sie sich in erster Linie das abstrakte C#-Speichermodell ins Gedächtnis rufen. Trotzdem kann es nützlich sein, die tatsächliche Implementierung des Speichermodells auf verschiedenen Architekturen zu verstehen, insbesondere, wenn Sie das Verhalten von vorhandenem Code nachvollziehen möchten.

C#-Speichermodell gemäß ECMA-334

Der Standard ECMA-334 C# Language Specification (bit.ly/MXMCrN) enthält die autorisierte Definition des C#-Speichermodells. Im Folgenden betrachten wir das C#-Speichermodell gemäß der Definition in der Spezifikation.

Neuordnung von Speicheroperationen Laut ECMA-334 wird für den Leser möglicherweise ein veralteter Wert angezeigt, wenn ein Thread in C# eine Speicherstelle liest, in die ein anderer Thread geschrieben hat. Das Problem ist in Abbildung 1 dargestellt.

Abbildung 1: Von der Neuordnung der Speicheroperationen betroffener Code

public class DataInit {
  private int _data = 0;
  private bool _initialized = false;
  void Init() {
    _data = 42;            // Write 1
    _initialized = true;   // Write 2
  }
  void Print() {
    if (_initialized)            // Read 1
      Console.WriteLine(_data);  // Read 2
    else
      Console.WriteLine("Not initialized");
  }
}

Angenommen, „Init“ und „Print“ werden gleichzeitig (in unterschiedlichen Threads) für eine neue Instanz von „DataInit“ aufgerufen. Beim Überprüfen des Codes von „Init“ und „Print“ mag es den Anschein haben, dass „Print“ nur „42“ oder „Not initialized“ ausgeben kann. Tatsächlich kann Print jedoch auch „0“ ausgeben.

Das C#-Speichermodell erlaubt die Neuordnung von Speicheroperationen in einer Methode, solange sich das Verhalten einer Singlethreadausführung nicht ändert. Der Compiler und der Prozessor können die Operationen der Init-Methode beispielsweise wie folgt neu ordnen:

void Init() {
  _initialized = true;   // Write 2
  _data = 42;            // Write 1
}

Diese Neuordnung würde das Verhalten der Init-Methode in einem Singlethreadprogramm nicht verändern. In einem Multithreadprogramm könnte jedoch ein anderer Thread die _initialized- und _data-Felder lesen, nachdem „Init“ nur eines der beiden Felder geändert hat. In diesem Fall könnte eine Neuordnung das Verhalten des Programms ändern. Als Ergebnis könnte die Print-Methode am Ende eine „0“ ausgeben.

Die Neuordnung von „Init“ ist nicht das einzige mögliche Problem in diesem Codebeispiel. Selbst wenn die Init-Schreibvorgänge (Writes) letztlich nicht neu geordnet werden, könnten die Lesevorgänge (Reads) in der Print-Methode umgewandelt werden:

void Print() {
  int d = _data;     // Read 2
  if (_initialized)  // Read 1
    Console.WriteLine(d);
  else
    Console.WriteLine("Not initialized");
}

Diese Transformation hat wie auch die Neuordnung der Schreibvorgänge in einem Singlethreadprogramm keine Auswirkung. Möglicherweise ändert sich jedoch das Verhalten eines Multithreadprogramms. Die Neuordnung von Lesevorgängen kann wie die Neuordnung von Schreibvorgängen zu einer „0“ in der Ausgabe führen.

Im zweiten Teil dieses Artikels erfahren Sie im Zusammenhang mit der ausführlichen Behandlung unterschiedlicher Hardwarearchitekturen, wie und warum diese Transformationen in der Praxis stattfinden.

Flüchtige (volatile) Felder Die C#-Programmiersprache bietet flüchtige Felder, in denen die Art und Weise eingeschränkt wird, wie Speicheroperationen neu geordnet werden können. Nach der ECMA-Spezifikation stellen flüchtige Felder acquire-/release-Semantik bereit (siehebit.ly/NArSlt).

Ein Lesevorgang eines flüchtigen Felds weist acquire-Semantik auf. Das bedeutet, dass es in nachfolgenden Operationen nicht neu geordnet werden kann. Der flüchtige Lesevorgang stellt ein Hindernis dar, das nur in einer Richtung überwunden werden kann: Vorhergehende Operationen können es überwinden, nachfolgende Operationen jedoch nicht. Nehmen Sie folgendes Beispiel:

class AcquireSemanticsExample {
  int _a;
  volatile int _b;
  int _c;
  void Foo() {
    int a = _a; // Read 1
    int b = _b; // Read 2 (volatile)
    int c = _c; // Read 3
    ...
  }
}

„Read 1“ und „Read 3“ sind nicht flüchtig, „Read 2“ ist jedoch flüchtig (volatile). „Read 2“ kann nicht mit „Read 3“, aber mit „Read 1“ neu geordnet werden. Abbildung 2 zeigt gültige Neuordnungen des Foo-Textkörpers.

Abbildung 2: Gültige Neuordnung von Lesevorgängen in AcquireSemanticsExample

int a = _a; // Read 1

int b = _b; // Read 2 (volatile)

int c = _c; // Read 3

int b = _b; // Read 2 (volatile)

int a = _a; // Read 1

int c = _c; // Read 3

int b = _b; // Read 2 (volatile)

int c = _c; // Read 3

int a = _a; // Read 1

Ein Schreibvorgang eines flüchtigen Felds verfügt andererseits über release-Semantik. Folglich kann er nicht mit vorangegangenen Operationen neu geordnet werden. Ein flüchtiger Schreibvorgang bildet ein Hindernis, das nur in einer Richtung überwunden werden kann, wie im folgenden Beispiel gezeigt:

class ReleaseSemanticsExample
{
  int _a;
  volatile int _b;
  int _c;
  void Foo()
  {
    _a = 1; // Write 1
    _b = 1; // Write 2 (volatile)
    _c = 1; // Write 3
    ...
  }
}

„Write 1“ und „Write 3“ sind nicht flüchtig, „Write 2“ ist jedoch flüchtig (volatile). „Write 2“ kann nicht mit „Write 1“, aber mit „Write 3“ neu geordnet werden. Abbildung 3 zeigt gültige Neuordnungen des Foo-Textkörpers.

Abbildung 3: Gültige Neuordnung von Schreibvorgängen in „ReleaseSemanticsExample“

_a = 1; // Write 1

_b = 1; // Write 2 (volatile)

_c = 1; // Write 3

_a = 1; // Write 1

_c = 1; // Write 3

_b = 1; // Write 2 (volatile)

_c = 1; // Write 3

_a = 1; // Write 1

_b = 1; // Write 2 (volatile)

Weiter unten in diesem Artikel werde ich unter „Veröffentlichung über flüchtige Felder“ näher auf die acquire-/release-Semantik eingehen.

Atomarität Sie sollten sich auch dessen bewusst sein, dass in C# Werte nicht unbedingt atomar in den Speicher geschrieben werden. Nehmen Sie folgendes Beispiel:

class AtomicityExample {
  Guid _value;
  void SetValue(Guid value) { _value = value; }
  Guid GetValue() { return _value; }
}

Wenn ein Thread wiederholt „SetValue“ und ein anderer Thread „GetValue“ aufruft, beobachtet der Getterthread möglicherweise einen Wert, der niemals vom Setterthread geschrieben wurde. Wenn beispielsweise der Setterthread nacheinander „SetValue“ mit Guid-Werten (0,0,0,0) und (5,5,5,5) aufruft, könnte „GetValue“ (0,0,0,5) oder (0,0,5,5) oder (5,5,0,0) beobachten, auch wenn keiner dieser Werte jemals mithilfe von „SetValue“ zugewiesen wurde.

Der Grund für dieses „Tearing“ besteht darin, dass die Zuweisung „_value = value“ nicht atomar auf Hardwareebene ausgeführt wird. Auch der Lesevorgang von „_value“ wird nicht atomar ausgeführt.

Die C# ECMA-Spezifikation garantiert, dass die folgenden Typen atomar geschrieben werden: Verweistypen, „bool“, „char“, „byte“, „sbyte“, „short“, „ushort“, „uint“, „int“ und „float“. Werte anderer Typen, einschließlich benutzerdefinierter Werttypen, könnten mithilfe mehrerer atomarer Schreibvorgänge in den Speicher geschrieben werden. Als Ergebnis könnte ein lesender Thread einen zerrissenen Wert beobachten, der aus Teilen unterschiedlicher Werte besteht.

Ein Nachteil besteht darin, dass auch Typen, die normalerweise atomar gelesen und geschrieben werden (wie „int“), nicht atomar gelesen oder geschrieben werden können, wenn der Wert im Speicher nicht korrekt ausgerichtet ist. In C# ist die korrekte Ausrichtung von Werten normalerweise garantiert. Der Benutzer kann die Ausrichtung jedoch mithilfe der StructLayoutAttribute-Klasse überschreiben (bit.ly/Tqa0MZ).

Optimierungen ohne Neuordnung Einige Compileroptimierungen können bestimmte Speicheroperationen einführen oder löschen. Der Compiler kann zum Beispiel wiederholte Lesevorgänge eines Felds durch einen einzigen Lesevorgang ersetzen. Ähnlich verhält es sich im folgenden Fall: Wenn der Code ein Feld liest, den Wert in einer lokalen Variablen speichert und dann die Variable wiederholt liest, könnte stattdessen der Compiler wiederholt das Feld lesen.

Da in der ECMA C#-Spezifikation Optimierungen ohne Neuordnung nicht ausgeschlossen sind, sind sie wahrscheinlich zugelassen. Der JIT-Compiler führt daher diese Optimierungen nicht aus, wie ich in Teil 2 besprechen werde.

Threadkommunikationsmuster

Der Zweck eines Speichermodells besteht darin, Threadkommunikation zu ermöglichen. Wenn ein Thread Werte in den Speicher schreibt und ein anderer Thread aus dem Speicher liest, schreibt das Speichermodell vor, welche Werte für den lesenden Thread sichtbar sind.

Sperren Die einfachste Methode, Daten in Threads freizugeben, liegt im Sperren. Wenn Sie Sperren korrekt verwenden, müssen Sie sich um das Durcheinander des Speichermodells grundsätzlich keine Sorgen machen.

Wenn ein Thread eine Sperre abruft, stellt die CLR sicher, dass für den Thread alle Aktualisierungen sichtbar sind, die von dem Thread durchgeführt wurden, der die Sperre vorher gehalten hat. Fügen wir dem Beispiel am Anfang dieses Artikels eine Sperre hinzu (siehe Abbildung 4).

Abbildung 4: Threadkommunikation mit Sperre

public class Test {
  private int _a = 0;
  private int _b = 0;
  private object _lock = new object();
  void Set() {
    lock (_lock) {
      _a = 1;
      _b = 1;
    }
  }
  void Print() {
    lock (_lock) {
      int b = _b;
      int a = _a;
      Console.WriteLine("{0} {1}", a, b);
    }
  }
}

Das Hinzufügen einer Sperre, die von „Print“ und „Set“ abgerufen wird, bietet eine einfache Lösung. „Set“ und „Print“ werden atomar in Bezug aufeinander ausgeführt. Mit der Sperranweisung wird garantiert, dass die Textkörper von „Print“ und „Set“ zum Ausführen in sequenzieller Reihenfolge angezeigt werden, selbst wenn sie von mehreren Threads aufgerufen werden.

Das Diagramm in Abbildung 5 zeigt eine mögliche sequenzielle Reihenfolge, wenn Thread 1 „Print“ dreimal, Thread 2 „Set“ einmal und Thread 3 „Print“ einmal aufruft.

Sequential Execution with Locking
Abbildung 5: Sequenzielle Ausführung mit Sperre

Wenn ein gesperrter Codeblock ausgeführt wird, sind garantiert alle Schreibvorgänge von Blocks vor dem Block in sequenzieller Reihenfolge der Sperre für ihn sichtbar. Er sind garantiert keine der Schreibvorgänge von Blocks für ihn sichtbar, die ihm in sequenzieller Reihenfolge der Sperre folgen.

Kurzum: Sperren verdecken die Unvorhersehbarkeit und die Komplexitätseigenheiten des Speichermodells: Sie müssen sich um das Neuordnen von Speicheroperationen keine Sorgen machen, wenn Sie Sperren korrekt verwenden. Dabei dürfen jedoch keine Fehler auftreten. Wenn nur „Print“ oder „Set“ die Sperre verwenden (oder „Print“ und „Set“ zwei unterschiedliche Sperren abrufen), kann es zu einer Neuordnung der Speicheroperationen kommen. Hierdurch erhält das Speichermodell seine Komplexität zurück.

Veröffentlichung über Threading-API Das Sperren stellt einen sehr allgemeinen und leistungsstarken Mechanismus zur Statusfreigabe in Threads dar. Die Veröffentlichung über Threading-API ist ein anderes häufig verwendetes Muster der Parallelprogrammierung.

Am einfachsten kann die Veröffentlichung über Threading-API anhand eines Beispiels veranschaulicht werden:

class Test2 {
  static int s_value;
  static void Run() {
    s_value = 42;
    Task t = Task.Factory.StartNew(() => {
      Console.WriteLine(s_value);
    });
    t.Wait();
  }
}

Im vorangestellten Beispiel würden Sie wahrscheinlich erwarten, dass „42“ an den Bildschirm ausgegeben wird. Und Sie hätten recht. In diesem Codebeispiel wird der Druck von „42“ garantiert.

Vielleicht wundern Sie sich, warum ich dies überhaupt erwähne. Tatsächlich gibt es aber mögliche Implementierungen von „StartNew“, nach denen – zumindest theoretisch – der Druck von „0“ anstatt von „42“ zulässig ist. Schließlich kommunizieren zwei Threads über ein nicht flüchtiges Feld. Folglich können Speicheroperationen neu geordnet werden. Das Muster wird im Diagramm in Abbildung 6 angezeigt.

Two Threads Communicating via a Non-Volatile Field
Abbildung 6: Kommunikation von zwei Threads über ein nicht flüchtiges Feld

Bei der Implementierung von „StartNew“ muss sichergestellt werden, dass der Schreibvorgang in „s_value“ auf Thread 1 nicht hinter <start task t> und der Lesevorgang von „s_value“ auf Thread 2 wird nicht vor <task t starting> verschoben wird. Die StartNew-API garantiert dies jedoch nicht.

Alle anderen Threading-APIs in .NET Framework, wie Thread.Start und ThreadPool.QueueUserWorkItem, geben auch eine ähnliche Garantie ab. Nahezu alle Threading-APIs müssen eine Barrieresemantik besitzen, um korrekt zu funktionieren. Dies wird selten dokumentiert, kann aber normalerweise einfach durch Überlegungen dazu abgeleitet werden, welche Garantien für die API nützlich wären.

Veröffentlichung über Typinitialisierung Eine andere sichere Methode der Veröffentlichung eines Werts für mehrere Threads besteht darin, den Wert in einem statischen Initialisierer oder einem statischen Konstruktor in ein statisches Feld zu schreiben. Nehmen Sie folgendes Beispiel:

class Test3
{
  static int s_value = 42;
  static object s_obj = new object();
  static void PrintValue()
  {
    Console.WriteLine(s_value);
    Console.WriteLine(s_obj == null);
  }
}

Wenn „Test3.PrintValue“ gleichzeitig von mehreren Threads aufgerufen wird, werden durch jeden Aufruf von „PrintValue“ garantiert „42“ und „false“ gedruckt? Oder könnte durch einen der Aufrufe auch „0“ oder „true“ gedruckt werden? Wie im vorherigen Fall tritt das erwartete Verhalten ein: Jeder Thread druckt garantiert „42“ und „false“.

Bisher entsprachen alle besprochenen Muster den Erwartungen. Im Folgenden möchte ich Ihnen ein paar Fälle vorstellen, deren Verhalten überraschend sein mag.

Veröffentlichung über flüchtige Felder Viele Parallelprogramme können mithilfe der drei oben besprochenen einfachen Muster erstellt und zusammen mit den Parallelitätsprimitiven in den Namespaces „.NET System.Threading“ und „System.Collections.Concurrent“ verwendet werden.

Das Muster, das ich hier erläutern möchte, ist so wichtig, dass die Semantik des volatile-Schlüsselworts hierfür entworfen wurde. Am einfachsten können Sie sich die Semantik des volatile-Schlüsselworts merken, indem Sie sich das Muster einprägen, anstatt sich die oben erwähnten abstrakten Regeln anzueignen.

Beginnen wir mit dem Beispielcode in Abbildung 7. Die DataInit-Klasse in Abbildung 7 verfügt über zwei Methoden: „Init“ und „Print“. Beide können von mehreren Threads aufgerufen werden. Wenn keine Speicheroperationen neu geordnet werden, kann „Print“ nur „Not initialized“ oder „42“ drucken. Es gibt aber zwei Situationen, in denen „Print“ eine „0“ drucken könnte:

  • „Write 1“ und „Write 2“ wurden neu geordnet.
  • „Read 1“ und „Read 2“ wurden neu geordnet.

Abbildung 7: Verwenden des volatile-Schlüsselworts

public class DataInit {
  private int _data = 0;
  private volatile bool _initialized = false;
  void Init() {
    _data = 42;            // Write 1
    _initialized = true;   // Write 2
  }
  void Print() {
    if (_initialized) {          // Read 1
      Console.WriteLine(_data);  // Read 2
    }
    else {
      Console.WriteLine("Not initialized");
    }
  }
}

Wenn „_initialized“ nicht als „volatile“ gekennzeichnet wäre, wären beide Neuordnungen erlaubt. Wenn „_initialized“ jedoch als „volatile“ gekennzeichnet wäre, wäre keine der beiden Neuordnungen zulässig. Bei Schreibvorgängen folgt ein flüchtiger Schreibvorgang auf einen normalen Schreibvorgang. Ein flüchtiger Schreibvorgang kann nicht mit einer vorangegangenen Speicheroperation neu geordnet werden. Bei Lesevorgängen folgt ein normaler Lesevorgang auf einen flüchtigen Lesevorgang. Ein flüchtiger Lesevorgang kann nicht mit einer nachfolgenden Speicheroperation neu geordnet werden.

Print druckt folglich niemals „0“, auch nicht bei einem gleichzeitigen Aufruf von „Init“ in einer neuen Instanz von DataInit.

Beachten Sie, dass beide Neuordnungen zulässig sind, wenn das _data-Feld als „volatile“, das _initialized-Feld jedoch nicht als „volatile“ gekennzeichnet ist. Folglich können Sie sich mit diesem Beispiel gut die Semantik von „volatile“ einprägen.

Verzögerte Initialisierung Eine häufige Variante der Veröffentlichung über flüchtige Felder ist die verzögerte Initialisierung. Das Beispiel in Abbildung 8 veranschaulicht die verzögerte Initialisierung.

Abbildung 8: Verzögerte Initialisierung

class BoxedInt
{
  public int Value { get; set; }
}
class LazyInit
{
  volatile BoxedInt _box;
  public int LazyGet()
  {
    var b = _box;  // Read 1
    if (b == null)
    {
      lock(this)
      {
        b = new BoxedInt();
        b.Value = 42; // Write 1
        _box = b;     // Write 2
      }
    }
    return b.Value; // Read 2
  }
}

In diesem Beispiel gibt „LazyGet“ immer garantiert „42“ zurück. Wenn das _box-Feld jedoch nicht flüchtig wäre, könnte „LazyGet“ aus zwei Gründen „0“ zurückgeben: Die Lesevorgänge könnten neu geordnet werden, oder die Schreibvorgänge könnten neu geordnet werden.

In der folgenden Klasse wird dieser Punkt verdeutlicht:

class BoxedInt2
{
  public readonly int _value = 42;
  void PrintValue()
  {
    Console.WriteLine(_value);
  }
}

Es ist möglich, zumindest theoretisch, dass „PrintValue“ aufgrund eines speichermodellbasierten Problems „0“ druckt. Im Folgenden sehen Sie ein Verwendungsbeispiel von „BoxedInt“, in dem dies zugelassen ist:

class Tester
{
  BoxedInt2 _box = null;
  public void Set() {
    _box = new BoxedInt2();
  }
  public void Print() {
    var b = _box;
    if (b != null) b.PrintValue();
  }
}

Da die BoxedInt-Instanz falsch veröffentlicht wurde (über ein nicht flüchtiges Feld, „_box“), kann der Thread, der „Print“ aufruft, ein teilweise konstruiertes Objekt beobachten. Auch hier kann das Problem gelöst werden, indem das _box-Feld flüchtig gemacht wird.

Interlock-Vorgänge und Speicherbarrieren Bei Interlock-Vorgängen handelt es sich um atomare Operationen, die gelegentlich verwendet werden können, um Sperren in einem Multithreadprogramm zu reduzieren. Sehen Sie sich diese einfache threadsichere Counter-Klasse an:

class Counter
{
  private int _value = 0;
  private object _lock = new object();
  public int Increment()
  {
    lock (_lock)
    {
      _value++;
      return _value;
    }
  }
}

Sie können mithilfe von „Interlocked.Increment“ das Programm folgendermaßen umschreiben:

class Counter
{
  private int _value = 0;
  public int Increment()
  {
    return Interlocked.Increment(ref _value);
  }
}

Durch die Umschreibung mit „Interlocked.Increment“ sollte die Methode schneller ausgeführt werden, auf jeden Fall auf einigen Architekturen. Neben den Erhöhungsoperationen bietet die Interlocked-Klasse (bit.ly/RksCMF) Methoden für verschiedene atomare Operationen: Hinzufügen eines Wert, bedingungsabhängiges Ersetzen eines Werts, Ersetzen eines Werts, Rückgabe des Originalwerts usw.

Alle Interlocked-Methoden besitzen eine sehr interessante Eigenschaft: Sie können mit anderen Speicheroperationen nicht neu geordnet werden. Keine Speicheroperation, ob vor oder nach einem Interlock-Vorgang, kann einen Interlock-Vorgang übergeben.

Eine Operation, die in engem Zusammenhang mit den Interlocked-Methoden steht, ist „Thread.MemoryBarrier“. Stellen Sie sie sich als eine Art Dummy-Interlock-Vorgang vor. Wie eine Interlocked-Methode kann „Thread.Memory­Barrier“ nicht ohne vorangegangene oder nachfolgende Speicheroperationen neu geordnet werden. Im Gegensatz zu einer Interlocked-Methode gibt es bei „Thread.MemoryBarrier“ keine ungünstigen Nebeneffekte. Es werden einfach nur Speicherneuordnungen beschränkt.

Abrufschleife (Polling Loop) Bei der Abrufschleife handelt es sich um ein Muster, das zwar allgemein nicht empfohlen wird, in der Praxis aber (leider) häufig anzutreffen ist. Abbildung 9 zeigt eine unterbrochene Abrufschleife.

Abbildung 9: Unterbrochene Abrufschleife

class PollingLoopExample
{
  private bool _loop = true;
  public static void Main()
  {
    PollingLoopExample test1 = new PollingLoopExample();
    // Set _loop to false on another thread
    new Thread(() => { test1._loop = false;}).Start();
    // Poll the _loop field until it is set to false
    while (test1._loop) ;
    // The previous loop may never terminate
  }
}

In diesem Beispiel wird im Hauptthread eine Schleife erzeugt, die ein bestimmtes nicht flüchtiges Feld abruft. Ein Helperthread legt das Feld in der Zwischenzeit fest. Für den Hauptthread ist der aktualisierte Wert möglicherweise niemals sichtbar.

Was wäre, wenn das _loop-Feld als „volatile“ gekennzeichnet wäre? Wäre damit das Programm repariert? Bei Experten herrscht Übereinstimmung darüber, dass der Compiler kein flüchtiges Feld anheben darf, das aus einer Schleife gelesen wurde. Umstritten ist dennoch, ob durch die ECMA C#-Spezifikation diese Garantie ausgedrückt wird.

Einerseits wird in der Spezifikation nur konstatiert, dass flüchtige Felder der acquire-/release-Semantik folgen. Dies dürfte nicht ausreichen, um das Anheben eines flüchtigen Felds zu verhindern. Andererseits wird durch den Beispielcode in der Spezifikation ein flüchtiges Feld abgerufen. Hierdurch wird impliziert, dass das flüchtige gelesene Feld aus der Schleife nicht angehoben werden kann.

Auf x86- und x64-Architekturen bleibt „PollingLoopExample.Main“ normalerweise hängen. Der JIT-Compiler liest das test1._loop-Feld nur einmal, speichert den Wert in einem Register und bildet dann eine Warteschleife, bis sich der Registrierungswert ändert, was offensichtlich niemals eintreten wird.

Wenn der Schleifeninhalt jedoch einige Anweisungen enthält, benötigt der JIT-Compiler das Register wahrscheinlich zu einem anderen Zweck. Jede Iteration kann folglich auf ein erneutes Lesen von „test1._loop“ hinauslaufen. Als Ergebnis sehen Sie möglicherweise Schleifen in vorhandenen Programmen, die ein nicht flüchtiges Feld abrufen, aber trotzdem funktionieren.

Parallelitätsprimitive Viel paralleler Code kann von Parallelitätsprimitiven auf hoher Ebene profitieren, die in .NET Framework 4 verfügbar wurden. In Abbildung 10 werden einige der .NET-Parallelitätsprimitiven aufgelistet.

Abbildung 10: Parallelitätsprimitive in .NET Framework 4

Typ Beschreibung
Lazy<> Verzögert initialisierte Werte
LazyInitializer
BlockingCollection<> Threadsichere Auflistungen
ConcurrentBag<>
ConcurrentDictionary<,>
ConcurrentQueue<>
ConcurrentStack<>
AutoResetEvent Primitive zum Koordinieren der Ausführung unterschiedlicher Threads
Barrier
CountdownEvent
ManualResetEventSlim
Überwachen
SemaphoreSlim
ThreadLocal<> Container, die einen getrennten Wert für jeden Thread enthalten

Durch die Verwendung dieser Primitive können Sie häufig Low-Level-Code vermeiden, der intrinsisch vom Speichermodell abhängt (über „volatile“ usw.).

Das nächste Thema

Bisher habe ich das C-Speichermodell wie in der ECMA C#-Spezifikation definiert beschrieben und die wichtigsten Muster der Threadkommunikation besprochen, die das Speichermodell definieren.

Im zweiten Teil dieses Artikels möchte ich erklären, wie das Speichermodell auf verschiedenen Architekturen implementiert wird. Dies ist wichtig, um das Verhalten des Programms in der Praxis zu verstehen.

Bewährte Methoden

  • Von Ihnen geschriebener Code sollte auf den Garantien der ECMA C#-Spezifikation beruhen und nicht auf den in diesem Artikel beschriebenen Implementierungsdetails.
  • Vermeiden Sie die unnötige Verwendung von flüchtigen Feldern. Meistens sind Sperren oder gleichzeitige Auflistungen („System.Collections.Concurrent.*“) für den Austausch von Daten zwischen Threads geeigneter. In einigen Fällen können flüchtige Felder zum Optimieren von parallelem Code verwendet werden. Zur Überprüfung, ob die Vorteile die zusätzliche Komplexität aufwiegen, sollten Sie Leistungsmessungen verwenden.
  • Anstatt das Muster für die verzögerte Initialisierung mithilfe eines flüchtigen Felds selbst zu implementieren, verwenden Sie die System.Lazy<T>- und System.Threading.LazyInitializer-Typen.
  • Vermeiden Sie das Abrufen von Schleifen. Häufig können Sie „BlockingCollection<T>“, „Monitor.Wait/Pulse“, Ereignisse oder asynchrone Programmierung anstatt einer Abrufschleife verwenden.
  • Verwenden Sie nach Möglichkeit immer die standardmäßigen .NET-Parallelitätsprimitiven, anstatt selbst eine gleichwertige Funktion zu implementieren.

Igor Ostrovsky ist leitender Softwareentwickler bei Microsoft. Er hat an Parallel LINQ, Task Parallel Library und anderen Parallelbibliotheken und Primitiven in Microsoft .NET Framework mitgearbeitet. Ostrovsky schreibt unter igoro.com Blogs zu Programmierthemen.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Joe Duffy, Eric Eilebrecht, Joe Hoag, Emad Omara, Grant Richins, Jaroslav Sevcik und Stephen Toub