Bereits seit einiger Zeit sind Multi-Core-Prozessoren für
den normalen Konsumenten erschwinglich. In Servern werden bereits seit einigen
Jahren Multiprozessorsysteme eingesetzt. Jedoch kommt mir immer wieder Code
unter, der sich strikt an einen Kern bindet, weil keine Operation asynchron
ausgeführt wird. Ich persönlich vertrete die Meinung, dass allein aus Gründen der User Experience mehrere
Threads eingesetzt werden sollen. Welcher Benutzer sieht es schon gern, wenn er
auf einen Button drückt, und die Anwendung für einige Sekunden zu einer weißen
Fläche wird? In Gesprächen mit Entwicklern höre ich jedoch immer wieder das
Argument, dass Multithread-Programmierung ja so kompliziert sei und man es
lieber nicht verwendet, um keine Fehler zu machen. Denn das Debugging von
Multithreaded-Code ist nicht so trivial wie bei Singlethreaded-Code. Daher
möchte ich in einer unbestimmt langen Reihe einige Aspekte des Multithreading
beleuchten.
lock
Beginnen möchte ich mit dem C#-Keyword
lock. Das lock-Statement ist nur in C# verfügbar. Es wird ähnlich dem using-Statement
verwendet:
lock(obj)
{
//Do something synchronized
}
lock wird also auf ein Objekt aufgerufen. Der Code innerhalb
des Blocks wird nur aufgeführt, wenn das der lock-Aufruf zurückgekehrt ist. Es
ist also sichergestellt, dass der Code innerhalb des Blocks immer nur von einem
Thread ausgeführt wird. Der Blockinhalt wird auch Critical Section genannt.
Wie verwendet man lock?
Die Syntax für die Nutzung von lock ist denkbar einfach.
Allerdings sind einige Einschränkungen bei der Verwendung zu beachten.
Reference-Type
lock kann nur auf Reference-Types aufgerufen werden. Dies
hängt damit zusammen, dass lock eigentlich eine Maskierung für
Monitor.Enter(lockobject);
ist.
Auf welches Objekt soll das lock aufgerufen werden?
Oft sieht man Konstrukte wie
lock(this){ }
Diese verletzen jedoch oft die Design Guidelines für
Multithreadprogrammierung. Und zwar in den folgenden Fällen:
lock(this){ } kann zu einem Problem werden, wenn die Instanz des Objekts public ist, also öffentliche Zugriffe erlaubt.
lock(typeof(MyType)){ } ist ebenfalls problematisch, wenn MyType öffentlich zugänglich ist.
lock("mylock") { } bedeutet, dass alle lock-Statements, die so geschrieben werden,
dasselbe Lock-Objekt benutzen, da es sich um einen String handelt.
lock sollte also immer auf dedizierte private Objekte
aufgerufen werden. Soll eine Critical Section über alle Instanzen eines Objekts
geschützt sein, definiert man das lock-Objekt als static.
private static object lockObj = new object();
private void foo()
{
lock(lockObj)
{
//Critical section
}
}
Was passiert bei einem lock?
Nehmen wir eine Methode einer Windows-Forms-Anwendung. Auf
dem Formular ist ein Button button1 platziert. Klickt man diesen an, soll sich
der Text des Formulars nach „LOCKED“ ändern. Es ergibt sich folgende Funktion:
private void button1_Click(object sender, EventArgs e)
{
lock (this.lockObject)
{
this.Text = "LOCKED";
}
}
Was macht der Compiler aus dieser Funktion? Schauen wir uns die erzeugte Intermediate Language an:
.method private hidebysig instance void button1_Click(object sender, class [mscorlib]System.EventArgs e) cil managed
{
.maxstack 2
.locals init (
[0] object CS$2$0000)
L_0000: nop
L_0001: ldarg.0
L_0002: ldfld object lock1.Form1::lockObject
L_0007: dup
L_0008: stloc.0
L_0009: call void [mscorlib]System.Threading.Monitor::Enter(object)
L_000e: nop
L_000f: nop
L_0010: ldarg.0
L_0011: ldstr "LOCKED"
L_0016: callvirt instance void [System.Windows.Forms]System.Windows.Forms.Control::set_Text(string)
L_001b: nop
L_001c: nop
L_001d: leave.s L_0027
L_001f: ldloc.0
L_0020: call void [mscorlib]System.Threading.Monitor::Exit(object)
L_0025: nop
L_0026: endfinally
L_0027: nop
L_0028: ret
.try L_000f to L_001f finally handler L_001f to L_0027
}
Wie man sieht, wird das lock-Statement zu einem
System.Threading.Monitor.Enter(lockObject) aufgelöst. Der entsprechende Aufruf
der Methode System.Threading.Monitor.Exit(lockObject) wird in einen
finally-Block eingefasst. Es kann also innerhalb der Critical section
passieren, was will, der Monitor wird freigegeben. Dies ist einer der großen
Vorteile des lock-Statements. Der Entwickler muss sich nur sehr wenige Gedanken
um die korrekte Freigabe des Monitors machen, da dies der Compiler für ihn
erledigt.
Allerdings verbirgt sich hier auch einer der Nachteile des
lock-Statements. Der Compiler löst das lock-Statement in ein
Monitor.Enter(lockObject) auf. Das kann unter Umständen dazu führen, dass der
Aufruf nie zurückkehrt, man also in eine Deadlock-Situation gerät. Um dies zu
vermeiden, verwendet man besser Monitor.TryEnter(object, TimeSpan). TryEnter
versucht für die angegebene Zeitspanne ein lock auf das Objekt zu erlangen.
Schlägt dies fehl, kehrt die Methode mit false zurück. Der Nachteil dieses
Aufrufs ist jedoch, dass der Entwickler sich nun selbst um das Fehlerhandling
kümmern muss. Folgender Codeblock kann also statt des lock-Statements verwendet
werden:
if ( !Monitor.TryEnter( lock2, TimeSpan.FromSeconds( 1 ) ) )
{
throw new ApplicationException( "Unable to claim lock on object {0}", lock2.ToString() );
}
try
{
//Critical section
}
finally
{
Monitor.Exit( lock2 );
}
In diesem Fall liegt die
Freigabe des Monitors jedoch ganz auf Seiten des Entwicklers. Außerdem muss er
sich Gedanken darüber machen, wie die Applikation darauf reagiert, wenn der
Monitor nicht in der vorgegebenen Zeitspanne erlangt werden kann. Deadlocks
sind so jedoch ausgeschlossen.
Einige Beispiele
Zuerst schauen wir uns an, wie sich zwei Methoden verhalten,
die von unterschiedlichen Threads aus aufgerufen werden. Eine dieser Methoden
ist durch ein lock verriegelt:
class Program
{
static void Main( string[] args )
{
AsyncTest test = new AsyncTest( );
Thread thread1 = new Thread(new ThreadStart(test.SyncLock1));
Thread thread2 = new Thread( new ThreadStart( test.UnsyncLock1 ) );
thread1.Start( );
thread2.Start( );
Console.ReadLine( );
}
}
public class AsyncTest
{
private object lock1 = new object( );
public void SyncLock1( )
{
lock ( lock1 )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "SyncLock1 {0}|", i );
Thread.Sleep( 20 );
}
}
}
public void UnsyncLock1( )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "UnsyncLock1 {0}|", i );
Thread.Sleep( 20 );
}
}
}
Auf der Konsole wird folgendes ausgegeben:
Man kann also sehen, dass die beiden Threads sich nicht von
dem lock des einen beeindrucken lassen. Warum auch, schließlich wird keiner
durch das lock tangiert.
Ändern wir das Programm etwas ab und fügen eine Methode
hinzu die ebenfalls durch ein lock verriegelt ist:
class Program
{
static void Main( string[] args )
{
AsyncTest test = new AsyncTest( );
Thread thread1 = new Thread(new ThreadStart(test.SyncLock1));
Thread thread2 = new Thread( new ThreadStart(test.SyncLock2 ) );
thread1.Start( );
thread2.Start( );
Console.ReadLine( );
}
}
public class AsyncTest
{
private object lock1 = new object( );
private object lock2 = new object( );
public void SyncLock1( )
{
lock ( lock1 )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "SyncLock1 {0}|", i );
Thread.Sleep( 20 );
}
}
}
public void SyncLock2( )
{
lock ( lock2 )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "SyncLock2 {0}|", i );
Thread.Sleep( 20 );
}
}
}
}
Die Konsolenausgabe gleicht der des ersten Beispiels:
Denn auch hier ist es so, dass beide Methoden unabhängige
Locks verwenden, sodass keine die andere beeinflusst.
Verriegeln wir nun die zwei Methoden mit demselben
lock-Objekt:
class Program
{
static void Main( string[] args )
{
AsyncTest test = new AsyncTest( );
Thread thread1 = new Thread(new ThreadStart(test.SyncLock1));
Thread thread2 = new Thread( new ThreadStart( test.SyncLock3 ) );
thread1.Start( );
thread2.Start( );
Console.ReadLine( );
}
}
public class AsyncTest
{
private object lock1 = new object( );
public void SyncLock1( )
{
lock ( lock1 )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "SyncLock1 {0}|", i );
Thread.Sleep( 20 );
}
}
}
public void SyncLock3( )
{
lock ( lock1 )
{
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "SyncLock3 {0}|", i );
Thread.Sleep( 20 );
}
}
}
}
Die Methoden werden nun nacheinander abgearbeitet:
Was bedeutet dies nun?
Auf ein Objekt kann nur ein lock (intern ja eigentlich ein
Monitor) vergeben werden. Verwenden verschiedene Methoden verschiedene lock-Objekte,
beeinflussen sich diese Methoden nicht. Verwenden sie das gleiche lock-Objekt,
warten alle Threads, die die Critical Section betreten wollen, bis sie Einer
nach dem Anderen das lock auf das Objekt erhalten. Damit einher geht natürlich
die
Notwendigkeit, dass ein Thread, der eine Critical Section betritt, beim Verlassen
dieser das Lock-Objekt wieder freigibt, sodass ein anderer Thread die Critical
Section betreten kann.
Links
Ausblick
Im nächsten Teil werde ich das Synchronisationsobjekt
Monitor beleuchten. Bis dahin freue ich mich über Rückmeldungen zu diesem Teil.