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.