Multithreading in C#, Teil 8: Speicherzugriffe

Im achten Teil der Artikelserie zum Thema Multithreading in C# möchte ich Ihnen die Auswirkungen der optimierten Speicherverwaltung des Compilers, der CLR und der CPU auf Ihren Quellcode aufzeigen.

Als Beispielanwendung werden wir einen Adapter für eine Fremdkomponente entwickeln. Adapter kommen in vielen Anwendungen zum Einsatz und eventuell haben Sie selbst schon diverse Adapter programmiert.

In unserer Anwendung möchten wir eine Fremdkomponente einbinden welche die Verbindung zu einem Gerät ABC des Herstellers XYZ aufnimmt. Der Hersteller stellt uns dafür eine dll zur Verfügung welche unter anderem folgende Schnittstellen implementiert.

 

1. Schnittstellen der Fremdkomponente

    1 interface IAbcConnection

    2 {

    3     void Connect(int port);

    4     void Disconnect();

    5 }

    6 

    7 interface IAbcConnectionFactory

    8 {

    9     IAbcConnection CreateConnection();

   10 }

 

Die Verbindung zum Gerät ABC wird mittels einer Klasse erzeugt welche die Schnittstelle IAbcConnection implementiert. Die Connection Klasse selbst wird mittels einer Factory erzeugt welche die Schnittstelle IAbcConnectionFactory implementiert. Wie so oft bei Fremdkomponenten kennen wir nur die Schnittstellen der Klassen, nicht aber die eigentliche Implementierung.

In unserer eigenen Anwendung steuern wir diverse Geräte an. Dabei haben wir ebenfalls eine Schnittstelle definiert welche von allen Geräteklassen verwendet wird. Der nachfolgende Quellcode zeigt diese Schnittstellendefinition.

 

2. Anwendungsspezifische Schnittstellendefinition

    1 interface IDeviceConnection

    2 {

    3     void Initialize(int port);

    4 

    5     void Connect();

    6     void Disconnect();

    7 

    8     ConnectionStatus Status { get; }

    9     int Port { get; }

   10 }

   11 

   12 enum ConnectionStatus

   13 {

   14     Undefined,

   15     Connected,

   16     Disconnected,

   17 }

 

Um das Gerät ABC einbinden zu können bietet es sich an eine Adapterklasse zu schreiben. Diese Adapterklasse implementiert unsere anwendungseigene Schnittstelle und nutzt intern die Fremdkomponente. Der nachfolgende Quellcode zeigt eine mögliche Implementierung dieser Klasse.

 

3. Adapter zur Ansteuerung der Fremdkomponente

    1 class AbcConnection : IDeviceConnection

    2 {

    3     public void Initialize(int port)

    4     {

    5         AbcConnectionFactory factory = new AbcConnectionFactory();

    6 

    7         _connection = factory.CreateConnection();

    8         _port = port;

    9         _status = ConnectionStatus.Disconnected;

   10     }

   11 

   12     public void Connect()

   13     {

   14         if (_status == ConnectionStatus.Disconnected)

   15         {

   16             _connection.Connect(_port);

   17             _status = ConnectionStatus.Connected;

   18         }

   19         else

   20         {

   21             throw new NotSupportedException();

   22         }

   23     }

   24 

   25     public void Disconnect()

   26     {

   27         if (_status == ConnectionStatus.Connected)

   28         {

   29             _connection.Disconnect();

   30             _status = ConnectionStatus.Disconnected;

   31         }

   32         else

   33         {

   34             throw new NotSupportedException();

   35         }

   36     }

   37 

   38     public ConnectionStatus Status

   39     {

   40         get

   41         {

   42             return _status;

   43         }

   44     }

   45 

   46     public int Port

   47     {

   48         get

   49         {

   50             return _port;

   51         }

   52     }

   53 

   54     private int _port;

   55     private IAbcConnection _connection;

   56     private ConnectionStatus _status = ConnectionStatus.Undefined;

   57 }

 

In der Adapterklasse sind folgende drei Variablen definiert:

  • _connection: Fremdkomponente zum Einbinden des Geräts ABC
  • _port: Verwendeter Verbindungsport
  • _status: Status der Verbindung

Die Verbindung muss zuerst initialisiert werden. Dazu wird innerhalb der Funktion Initialize eine Verbindung erzeugt, der Port gespeichert und der Status aktualisiert. Daraufhin lässt sich mittels der Funktionen Connect und Disconnect die Verbindung aufbauen und schliessen. Des Weiteren stehen zwei Properties bereits mit welchen der Port und der aktuelle Status ermittelt werden können. Eine zentrale Rolle bei der Verwaltung der Verbindung nimmt die Statusverwaltung ein. In der Variable _status wird der aktuelle Verbindungszustand gespeichert. Die Funktionen Connect und Disconnect prüfen den Verbindungsstatus und werfen eine Exception wenn sie zu einem nicht unterstützten Zeitpunkt aufgerufen werden. So kann zum Beispiel keine Verbindung zum Gerät ABC hergestellt werden wenn die Klasse nicht zuvor initialisiert wurde oder wenn bereits eine offene Verbindung besteht.

Der Adapter ist somit fertiggestellt und wird in einer Single Thread Anwendung sicher fehlerfrei funktionieren. Die Anwendung ist aber eine Multi Thread Applikation bei der es durchaus vorkommen kann, dass der Adapter aus unterschiedlichen Threads heraus genutzt wird. Somit muss man sich nun die Frage stellen ob der Adapter Multithreadtauglich ist.

Die einzelnen Funktionsaufrufe reagieren abhängig vom Status der Adapterklasse. Dieser Status wird intern verwaltet, zu definierten Zeitpunkten aktualisiert und bei Funktionsaufrufen abgefragt. Auf den ersten Blick scheint der Adapter somit auch in Multithreadanwendungen einsetzbar zu sein.

Und tatsächlich wird die Adapterklasse auch meistens wie erwartet funktionieren. Aber leider nicht immer. Der Compiler, die CLR oder die CPU können Optimierungen durchführen um die Ausführungsgeschwindigkeit einer Anwendung zu erhöhen. Dazu kann die Reihenfolge von Speicherzugriffen geändert werden so lange dabei das Verhalten der Anwendung in einem Single Thread Scenario nicht verändert wird. Was dies genau bedeutet wollen wir uns an einem Beispiel ansehen. Betrachten wir dazu die Funktionen Initialize und Connect nochmals genauer.

 

4. Detailanalyse der Initialize und Connect Funktion des Adapters

    1 public void Initialize(int port)

    2 {

    3     AbcConnectionFactory factory = new AbcConnectionFactory();

    4 

    5     _connection = factory.CreateConnection();

    6     _port = port;

    7     _status = ConnectionStatus.Disconnected;

    8 }

    9 

   10 public void Connect()

   11 {

   12     if (_status == ConnectionStatus.Disconnected)

   13     {

   14         _connection.Connect(_port);

   15         _status = ConnectionStatus.Connected;

   16     }

   17     else

   18     {

   19         throw new NotSupportedException();

   20     }

   21 }

 

Die beiden Funktionen sollen nun in zwei getrennten Threads aufgerufen werden:

  • Thread 1 ruft die Initialize Funktion auf und übergibt dabei die Portnummer 2600
  • Thread 2 ruft die Connect Funktion auf bis diese erfolgreich ist, also bis keine NotSupportedException mehr geworfen wird

Am Ende der Initialize Funktion wird der Verbindungsstatus gesetzt. Dieser wird am Anfang der Connect Funktion geprüft. Das heisst wenn innerhalb der Connect Funktion die Codezeile _connection.Connect(_port); ausgeführt wird dann müsste der Port immer 2600 sein. Dies ist aber leider nicht immer der Fall. Es kann zu Konstellationen kommen bei denen der Port gleich 0 ist. Wie ist dies möglich? Die Antwort liegt in der Speicheroptimierung. Der Compiler, die CLR und die CPU können Lese- und Schreiboperationen umsortieren wenn dadurch das Verhalten einer Single Thread Anwendung nicht geändert wird.

 

a) Umsortierung von Schreiboperationen

Betrachten wir zuerst wie sich das Umsortieren von Schreiboperationen auswirken kann. In der Initialize Funktion werden die folgenden Schreiboperationen ausgeführt:

_connection = factory.CreateConnection();       //write 1

_port = port;                                   //write 2

_status = ConnectionStatus.Disconnected;        //write 3

 

Diese Schreiboperationen können beliebig umgestellt werden. Zum Beispiel kann die Reihenfolge während der Programmausführung folgendermassen sein:

_connection = factory.CreateConnection();       //write 1

_status = ConnectionStatus.Disconnected;        //write 3

_port = port;                                   //write 2

 

Die Umstellung der Schreiboperationen führt dazu, dass der Status vor dem Port geändert wird. In einer Single Thread Anwendung hat dies keine Auswirkung. In unserem Fall haben wir aber eine Multi Thread Anwendung. Der parallele Thread könnte somit die Connect Funktion aufrufen während der Status durch die Initialize Funktion bereits aktualisiert ist aber der Port noch nicht gesetzt wurde. Somit würde beim Connect der Wert „0“ für den Port verwendet werden.

Die Ursache dafür, dass der Port gleich 0 ist kann aber auch in den Leseoperationen liegen. Diese können ebenfalls durch Optimierungen umsortiert werden.

 

b) Umsortierung von Leseoperationen

In der Connect Funktion werden der Status und der Port gelesen. Der folgende Quellcode zeigt einen verkürzten Ausschnitt aus dieser Funktion.

if (_status == ConnectionStatus.Disconnected)    //read 1

{

    _connection.Connect(_port);            //read 2

}

 

Lesevorgänge können beliebig umgestellt werden wenn dabei das Single Thread Verhalten nicht verändert wird. Während der Programmausführung kann es daher vorkommen, dass der Port vor dem Status gelesen wird. Dies würde dem folgenden Quellcode entsprechen:

int x = _port;                     //read 2

if (_status == ConnectionStatus.Disconnected)    //read 1

{

    _connection.Connect(x);           

}

 

Wie Sie sehen kann die Optimierung von Lese- und Schreiboperationen dazu führen das in einer Multithreading Anwendung Fehler bei der Programmausführung auftreten. Die vermeintlich ausreichende Absicherung der Funktionen der Adapter Klasse mittels Prüfung des Verbindungsstatus ist somit nicht praxistauglich. Die Adapter Klasse muss erweitert und fit für Multithreading gemacht werden. Dazu gibt es eine Vielzahl von Möglichkeiten. Nachfolgend werden wir zwei Lösungsvarianten betrachten.

 

5. Threadsichere Adapter Klasse mittels Lock

    1 class AbcConnection : IDeviceConnection

    2 {

    3     public void Initialize(int port)

    4     {

    5         bool lockTaken = false;

    6 

    7         try

    8         {

    9             //lock

   10             Monitor.Enter(_lock, ref lockTaken);

   11 

   12             //init

   13             AbcConnectionFactory factory = new AbcConnectionFactory();

   14 

   15             _connection = factory.CreateConnection();

   16             _port = port;

   17             _status = ConnectionStatus.Disconnected;

   18         }

   19         finally

   20         {

   21             //unlock

   22             if (lockTaken)

   23             {

   24                 Monitor.Exit(_lock);

   25             }

   26         }

   27     }

   28 

   29     public void Connect()

   30     {

   31         bool lockTaken = false;

   32 

   33         try

   34         {

   35             //lock

   36             Monitor.Enter(_lock, ref lockTaken);

   37 

   38             //connect

   39             if (_status == ConnectionStatus.Disconnected)

   40             {

   41                 _connection.Connect(_port);

   42                 _status = ConnectionStatus.Connected;

   43             }

   44             else

   45             {

   46                 throw new NotSupportedException();

   47             }

   48         }

   49         finally

   50         {

   51             //unlock

   52             if (lockTaken)

   53             {

   54                 Monitor.Exit(_lock);

   55             }

   56         }

   57     }

   58 

   59     public void Disconnect()

   60     {

   61         bool lockTaken = false;

   62 

   63         try

   64         {

   65             //lock

   66             Monitor.Enter(_lock, ref lockTaken);

   67 

   68             //disconnect

   69             if (_status == ConnectionStatus.Connected)

   70             {

   71                 _connection.Disconnect();

   72                 _status = ConnectionStatus.Disconnected;

   73             }

   74             else

   75             {

   76                 throw new NotSupportedException();

   77             }

   78         }

   79         finally

   80         {

   81             //unlock

   82             if (lockTaken)

   83             {

   84                 Monitor.Exit(_lock);

   85             }

   86         }

   87     }

   88 

   89     public ConnectionStatus Status

   90     {

   91         get

   92         {

   93             bool lockTaken = false;

   94 

   95             try

   96             {

   97                 //lock

   98                 Monitor.Enter(_lock, ref lockTaken);

   99 

  100                 //return

  101                 return _status;

  102             }

  103             finally

  104             {

  105                 //unlock

  106                 if (lockTaken)

  107                 {

  108                     Monitor.Exit(_lock);

  109                 }

  110             }

  111         }

  112     }

  113 

  114     public int Port

  115     {

  116         get

  117         {

  118             bool lockTaken = false;

  119 

  120             try

  121             {

  122                 //lock

  123                 Monitor.Enter(_lock, ref lockTaken);

  124 

  125                 //return

  126                 return _port;

  127             }

  128             finally

  129             {

  130                 //unlock

  131                 if (lockTaken)

  132                 {

  133                     Monitor.Exit(_lock);

  134                 }

  135             }

  136         }

  137     }

  138 

  139     private int _port;

  140     private IAbcConnection _connection;

  141     private ConnectionStatus _status = ConnectionStatus.Undefined;

  142 

  143     private static object _lock = new object();

  144 }

 

Die Adapter Klasse wurde um ein _lock Objekt erweitert. Innerhalb jeder Funktion wird mittels Monitor.Enter und Monitor.Exit der Funktionsinhalt gegen gleichzeitigen Zugriff aus unterschiedlichen Threads abgesichert. Da in jeder Funktion das gleiche Lock Objekt genutzt wird ist sichergestellt das immer erst eine Funktion komplett beendet werden muss bevor ein anderer Thread eine andere Funktion ausführen kann. Somit wird beispielsweise erst die Initialize Funktion komplett durchlaufen bevor die Connect Funktion durch den anderen Thread ausgeführt werden kann.

Innerhalb der Funktionen dürfen der Compiler, die CLR und die CPU weiterhin Optimierungen der Lese- und Schreiboperationen durchführen. Diese wirken sich aber durch das Sperren mittels der Monitor Funktionen nicht mehr negativ aus.

Nachteil dieser Lösung ist, dass der Quellcode ziemlich aufgebläht wird. Eigentlich war die ursprüngliche Adapter Klasse durch die Abfrage des Verbindungsstatus schon fast threadsicher. Einzig die Zugriffsoptimierung des Compilers führt zu ungewünschten Nebeneffekten. Daher wäre eine Lösung wünschenswert welche diese Nebeneffekte verhindert. Nachfolgend zeige ich Ihnen wie solch eine Lösung mittels des Schlüsselworts volatile erreicht werden kann.

 

6. Das Schlüsselwort volatile

6a) Lese/Schreiboperationen ohne volatile

Das Schlüsselwort volatile kennzeichnet Variablen als flüchtig. Dies  führt unter anderem dazu, dass diese Variablen bei der Optimierung der Lese- und Schreibzugriffe anders behandelt werden.

Betrachten wir dazu folgendes Beispiel:

    1 class VolatileExample

    2 {

    3     void Write()

    4     {

    5         _a = 1;

    6         _b = 2;

    7         _c = 3;

    8     }

    9 

   10     void Read()

   11     {

   12         int a = _a;

   13         int b = _b;

   14         int c = _c;

   15     }

   16 

   17     private int _a;

   18     private int _b;

   19     private int _c;

   20 }

 

Die Schreiboperationen dürfen beliebig umgestellt werden. Folgende Varianten der Write Funktion sind somit möglich:

 

_a = 1;

_b = 2;

_c = 3;

 

 

_a = 1;

_c = 3;

_b = 2;

 

 

_b = 2;

_a = 1;

_c = 3;

 

 

_b = 2;

_c = 3;

_a = 1;

 

 

_c = 3;

_a = 1;

_b = 2;

 

 

_c = 3;

_b = 2;

_a = 1;

 

 

Das Gleiche gilt für die Leseoperationen. Auch diese dürfen beliebig umgestellt werden. Folgende Varianten der Read Funktion sind daher möglich:

 

int a = _a;

int b = _b;

int c = _c;

 

 

int a = _a;

int c = _c;

int b = _b;

 

 

int b = _b;

int a = _a;

int c = _c;

 

 

int b = _b;

int c = _c;

int a = _a;

 

 

int c = _c;

int a = _a;

int b = _b;

 

 

int c = _c;

int b = _b;

int a = _a;

 

 

6b) Lese/Schreiboperationen mit volatile

Im vorangegangen Beispiel haben Sie gesehen das beliebige Umstellungen der Lese- und Schreiboperationen möglich sind. Welche Auswirkungen hat das Schlüsselwort volatile auf diesen Quellcode? Mittels volatile lässt sich eine Variable als flüchtig kennzeichnen. Dies errichtet gleichzeitig eine Art Speicherbarriere um diese Variable.

Bei der Optimierung von Schreiboperationen dürfen keine Schreibvorgänge hinter diese Barriere verschoben werden. Bei der Optimierung von Leseoperationen dürfen keine Lesevorgänge vor diese Barriere verschoben werden.

Betrachten wir dazu unser Beispiel erneut. Diesmal wird die Variable _b als volatile gekennzeichnet.

    1 class VolatileExample

    2 {

    3     void Write()

    4     {

    5         _a = 1;

    6         _b = 2;

    7         _c = 3;

    8     }

    9 

   10     void Read()

   11     {

   12         int a = _a;

   13         int b = _b;

   14         int c = _c;

   15     }

   16 

   17     private int _a;

   18     private volatile int _b;

   19     private int _c;

   20 }

 

Die Schreiboperationen dürfen jetzt nicht mehr beliebig umgestellt werden. Der Schreibvorgang für die Variable _b bildet eine Speicherbarriere.  Vorhergehende Schreibvorgänge dürfen nicht hinter diese Barriere verschoben werden. Folgende Varianten der Write Funktion sind somit möglich:

 

_a = 1;

_b = 2;

_c = 3;

 

 

_a = 1;

_c = 3;

_b = 2;

 

 

_c = 3;

_a = 1;

_b = 2;

 

 

Das Gleiche gilt für die Leseoperationen. Auch diese dürfen nun nicht mehr beliebig umgestellt werden. Lesevorgänge nach der Speicherbarriere durch die Variable _b dürfen nicht vor die Barriere verschoben werden. Folgende Varianten der Read Funktion sind somit möglich:

 

int a = _a;

int b = _b;

int c = _c;

 

 

int b = _b;

int a = _a;

int c = _c;

 

 

int b = _b;

int c = _c;

int a = _a;

 

 

7. Threadsichere Adapter Klasse mittels volatile

Mit diesem Wissen kann die Adapter Klasse relativ leicht umgeschrieben werden um die aufgetretenen Multithreading Effekte zu unterbinden. Wie gezeigt treten in der Adapter Klasse dann Fehler auf wenn Schreibbefehle so umsortiert werden dass sie nach dem Schreiben des Verbindungsstatus durchgeführt werden oder wenn Lesevorgänge vor das Lesen des Verbindungsstatus verschoben werden. Die Variable für den Verbindungsstatus müsste somit eine Speicherzugriffsbarriere aufbauen um diese Effekte zu verhindern. Daher genügt es die Variable als volatile zu kennzeichnen. Die Absicherung mittels der Monitor Funktionen ist dann nicht mehr nötig. Der nachfolgende Quellcode zeigt die finale threadsicher Adapter Klasse. Der Quellcode entspricht der anfänglich erstellen Klasse nur die Definition der Variable _status wurde um das Schlüsselwort volatile ergänzt (siehe Zeile 56).

    1 class AbcConnection : IDeviceConnection

    2 {

    3     public void Initialize(int port)

    4     {

    5         AbcConnectionFactory factory = new AbcConnectionFactory();

    6 

    7         _connection = factory.CreateConnection();

    8         _port = port;

    9         _status = ConnectionStatus.Disconnected;

   10     }

   11 

   12     public void Connect()

   13     {

   14         if (_status == ConnectionStatus.Disconnected)

   15         {

   16             _connection.Connect(_port);

   17             _status = ConnectionStatus.Connected;

   18         }

   19         else

   20         {

   21             throw new NotSupportedException();

   22         }

   23     }

   24 

   25     public void Disconnect()

   26     {

   27         if (_status == ConnectionStatus.Connected)

   28         {

   29             _connection.Disconnect();

   30             _status = ConnectionStatus.Disconnected;

   31         }

   32         else

   33         {

   34             throw new NotSupportedException();

   35         }

   36     }

   37 

   38     public ConnectionStatus Status

   39     {

   40         get

   41         {

   42             return _status;

   43         }

   44     }

   45 

   46     public int Port

   47     {

   48         get

   49         {

   50             return _port;

   51         }

   52     }

   53 

   54     private int _port;

   55     private IAbcConnection _connection;

   56     private volatile ConnectionStatus _status = ConnectionStatus.Undefined;

   57 }

 

Neben den Speicherbarrieren welche implizit durch die Verwendung von volatile gesetzt werden, ist es auch möglich explizite Speicherbarrieren zu nutzen. Diese lassen sich mittels Thread.MemoryBarrier() setzen. Der nachfolgende Quellcode zeigt erneut die Adapter Klasse. In dieser Variante wurde volatile durch Thread.MemoryBarrier() ersetzt.

 

8. Threadsichere Adapter Klasse mittels MemoryBarrier

    1 class AbcConnection : IDeviceConnection

    2 {

    3     public void Initialize(int port)

    4     {

    5         AbcConnectionFactory factory = new AbcConnectionFactory();

    6 

    7         _connection = factory.CreateConnection();

    8         _port = port;

    9 

   10         Thread.MemoryBarrier();

   11         _status = ConnectionStatus.Disconnected;

   12     }

   13 

   14     public void Connect()

   15     {

   16         if (_status == ConnectionStatus.Disconnected)

   17         {

   18             Thread.MemoryBarrier();

   19             _connection.Connect(_port);

   20 

   21             Thread.MemoryBarrier();

   22             _status = ConnectionStatus.Connected;

   23         }

   24         else

   25         {

   26             throw new NotSupportedException();

   27         }

   28     }

   29 

   30     public void Disconnect()

   31     {

   32         if (_status == ConnectionStatus.Connected)

   33         {

   34             Thread.MemoryBarrier();

   35             _connection.Disconnect();

   36 

   37             Thread.MemoryBarrier();

   38             _status = ConnectionStatus.Disconnected;

   39         }

   40         else

   41         {

   42             throw new NotSupportedException();

   43         }

   44     }

   45 

   46     public ConnectionStatus Status

   47     {

   48         get

   49         {

   50             return _status;

   51         }

   52     }

   53 

   54     public int Port

   55     {

   56         get

   57         {

   58             return _port;

   59         }

   60     }

   61 

   62     private int _port;

   63     private IAbcConnection _connection;

   64     private ConnectionStatus _status = ConnectionStatus.Undefined;

   65 }

 

Die expliziten Speicherbarrieren haben den gleichen Effekt wie die Speicherbarrieren bei Verwendung des Schlüsselwort volatile. Auch diese Adapter Klasse ist threadsicher.

 

Fazit

Die Optimierung der Lese-und Schreiboperationen beeinflusst das Laufzeitverhalten von Anwendungen. Dies ist bei einer Single Thread Anwendung nicht relevant, kann aber in einer Multithreading Anwendung zu unerwünschten Seiteneffekten führen. Mit Hilfe des Schlüsselwort volatile und durch die Funktion Thread.MemoryBarrier() lassen sich Speicherbarrieren errichten welche diese Seiteneffekte beheben.

Werbung
Dieser Beitrag wurde unter .NET, C#, Multithreading abgelegt und mit , , , , , , verschlagwortet. Setze ein Lesezeichen auf den Permalink.

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit deinem WordPress.com-Konto. Abmelden /  Ändern )

Facebook-Foto

Du kommentierst mit deinem Facebook-Konto. Abmelden /  Ändern )

Verbinde mit %s