Im zweiten Teil der unbestimmt langen Reihe Multithreading in .NET befasse ich mich mit der
Klasse System.Threading.Monitor.
System.Threading.Monitor
Der Monitor ist das Objekt, was sich bei genauerer Betrachtung hinter einem lock-Statement verbirgt. Die Details dazu können
im ersten Teil nachgelesen werden. Die Verwendung ist der eines lock-Statements sehr ähnlich. Man verwendet den Monitor stets in Verbindung mit einem Objekt, welches verriegelt werden soll. Der daraus resultierende IL-Code unterscheidet sich ebenfalls nicht von dem eines lock-Statements.
private void monitorNotationExample( object lockObject )
{
Monitor.Enter( lockObject );
//Critical Section: Do something
Monitor.Exit( lockObject );
}Nach der Critical Section muss allerdings im Gegensatz zum lock-Statement die Verriegelung explizit wieder freigegeben werden. Dies macht den Monitor etwas fehleranfälliger als das lock-Statement. Er hat allerdings auch einige ganz entscheidende Vorteile.
Vorteile der Klasse Monitor gegenüber lock
Die Klasse Monitor stellt neben der Funktion Enter noch die Funktion TryEnter bereit, um eine exklusive Sperre für eine Critical Section zu bekommen. Die Funktion TryEnter ist doppelt überladen und liefert jeweils einen Booleschen Wert zurück.
Der Aufruf der Funktion Monitor.TryEnter(Object) versucht, eine exklusive Sperre auf das übergebene Objekt zu erlangen. Schlägt dies fehl, weil bereits ein anderer Thread eine Sperre auf dieses Objekt hält, liefert die Funktion ein false zurück. Konnte die Sperre erlangt werden, wird true zurückgegeben.
Die erste Überladung der Funktion mit dem zusätzlichen Parameter vom Typ Int32 dient dazu, einen Timeout anzugeben. Dieser Timeout wird Anzahl der Millisekunden übergeben, die maximal versucht werden soll, eine exklusive Sperre für das Objekt zu erlangen. Wird zu einem beliebigen Zeitpunkt innerhalb der Zeitspanne die Sperre erlangt, gibt der Aufruf true zurück. Wird der Timeout erreicht, kehrt der Aufruf mit false zurück. Dieser Aufruf ermöglicht es also, eine bestimmte Zeit auf eine Sperre zu warten. Der Rückgabewert muss also in jedem Fall ausgewertet werden. Außerdem muss man bei der Verwendung der Funktion Monitor.TryEnter (Object, Int32) darauf achten, dass eine geeignete Strategie vorhanden ist für den Fall, dass keine Sperre registriert werden konnte.
Die zweite Überladung verlangt als zweiten Parameter keinen Int32, der die Anzahl der Millisekunden für den Timeout angibt, sondern eine Zeitspanne vom Typ
System.TimeSpan. Mit dieser zweiten Überladung ist es also möglich, die ersten beiden Funktionsaufrufe nachzubilden. Entweder wird als Zeitspanne wirklich eine Zeitspanne übergeben, was der ersten Überladung entspräche, oder es wird
Timeout.Infinite übergeben, was dem Aufruf der Funktion Monitor.TryEnter(Object) entspräche.
Einige Beispiele
Um die Funktionsweise der einzelnen Methoden der Monitor-Klasse zu verdeutlichen, werde ich einige Beispiele anführen. Beginnen wir mit einfacheren Verwendung des Monitors. Wir nehmen einen Konsolenanwendung und erweitern diese mit der Klasse AsyncTest. In dieser Klasse sind zwei Methoden enthalten, Sync1() und Sync2(). Beide Methoden greifen auf dasselbe lock-Objekt zu. nämlich lock1.
class Program
{
static void Main( string[] args )
{
AsyncTest test = new AsyncTest( );
Thread thread1 = new Thread( new ThreadStart( test.Sync1 ) );
Thread thread2 = new Thread( new ThreadStart( test.Sync2 ) );
thread1.Start( );
thread2.Start( );
Console.ReadLine( );
}
}
public class AsyncTest
{
private object lock1 = new object( );
public void Sync1( )
{
Monitor.Enter(lock1);
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "1 {0}|", i );
Thread.Sleep( 20 );
}
Monitor.Exit( lock1 );
}
public void Sync2( )
{
Monitor.Enter( lock1 );
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "2 {0}|", i );
Thread.Sleep( 20 );
}
Monitor.Exit( lock1 );
}
}
Die Ausgabe der beiden Threads erfolgt sequenziell, wie bereits im ersten Teil erklärt.

Schauen wir uns im nächsten Beispiel die Funktionsweise der Methode TryEnter an. Thread 2 in der Main-Routine führt nun die Methode Sync3 in der Klasse AsyncTest aus. Diese versucht über den Aufruf von Monitor.TryEnter(lock1) eine Sperre auf das Objekt zu bekommen.
class Program
{
static void Main( string[] args )
{
AsyncTest test = new AsyncTest( );
Thread thread1 = new Thread( new ThreadStart( test.Sync1 ) );
Thread thread2 = new Thread( new ThreadStart( test.Sync3 ) );
thread1.Start( );
thread2.Start( );
Console.ReadLine( );
}
}
public class AsyncTest
{
private object lock1 = new object( );
public void Sync1( )
{
Monitor.Enter(lock1);
for ( int i = 1; i <= 20; i++ )
{
Console.Write( "1 {0}|", i );
Thread.Sleep( 20 );
}
Monitor.Exit( lock1 );
}
public void Sync3( )
{
if ( Monitor.TryEnter( lock1 ) )
{
Console.WriteLine( Environment.NewLine + "Lock achieved." );
Monitor.Exit( lock1 );
}
else
{
Console.WriteLine( Environment.NewLine + "Lock cannot be achieved." );
}
}
}
Ergibt:

Die Ausgabe zu diesem Beispiel entspricht der Beschreibung der Methode Monitor.TryEnter(). Es wird versucht, eine Sperre auf das übergebene Objekt zu registrieren. Schlägt dies fehl, kehrt die Methode mit false zurück. Hätte eine Sperre registriert werden können, wäre true zurückgegeben worden.
Betrachten wir das Verhalten der Methode Sync3() nun einmal mit Übergabe eines Timeouts. Dieser soll 100 Millisekunden lang sein. Die neue Methode nennen wir Sync4() und verdrahten sie in der Main-Methode auf den zweiten Thread:
public void Sync4( )
{
if ( Monitor.TryEnter( lock1, 100 ) )
{
Console.WriteLine( Environment.NewLine + "Lock achieved." );
Monitor.Exit( lock1 );
}
else
{
Console.WriteLine( Environment.NewLine + "Lock cannot be achieved." );
}
}

Wie man sehen kann, kehrt die Methode Sync4 etwas später zurück als Sync3 im vorigen Beispiel, kann jedoch immer noch keine Sperre registrieren. Betrachten wir die Funktion Sync1 genauer, wird auch schnell klar, warum. Sie dauert bei 20 Schleifendurchläufen mit jeweils einem Sleep von 20 Millisekunden mindestens 400 Millisekunden.
Setzen wir nun also den Timeout in Sync4 auf 1000 Millisekunden.

Wie man sehen kann, war der Timeout nun so lang, dass Sync1 vollständig abgearbeitet und die Sperre freigegeben werden konnte, bevor der Timeout abgelaufen war. Der zweite Thread konnte seine Sperre erfolgreich registrieren.
Platzierung der lock-Freigabe Monitor.Exit()
Wie man an Hand der Funktionen Sync3 und Sync4 erkennen kann, habe ich die Freigabe der Sperre nicht an das Ende der Funktion gestellt, sondern an das Ende des Zweigs, der bei erfolgreicher Registrierung der Sperre angesprungen wird. Wird nämlich die Funktion Exit(object) auf ein Objekt aufgerufen, für welches der aufrufende Thread keine Sperre registriert hat, bekommt man von der Runtime eine SynchronizationLockException vor den Bug geknallt, die man dann behandeln darf.

Um dies zu vermeiden (und um logischeren Code zu schreiben) sollte man allerdings den von mir gewählten Weg gehen und die Sperre nur dann freigeben, wenn sie auch wirklich registriert wurde. Außerdem sollte man die Freigabe der Sperre in einem finally-Block notieren, um auf alles vorbereitet zu sein. Auch wenn irgendwo innerhalb der Critical Section eine Exception geworfen wird, wird so wenigstens die Sperre freigegeben und die Chance erhöht, dass sich die Anwendung nicht in einem "Deadlock "aufhängt".
Die richtige Notation für den Monitor wäre also:
public void Sync6( )
{
if ( Monitor.TryEnter( lock1, 1000 ) )
{
try
{
//Critical section
}
finally
{
Monitor.Exit( lock1 );
}
}
else
{
//else-Fall behandeln
}
}
Und wem bei der Betrachtung dieser Methode das C# Keyword using in den Kopf kommt, der ist ganz nah bei dem, was sich die .NET-Entwickler gedacht haben. Ändert man den Timeout von 1000 Millisekunden auf Timeout.Infinite und lässt den else-Zweig weg, entspricht diese Methode dem lock-Statement. Womit nun der Unterschied zwischen den drei Methoden Enter, TryEnter und Exit des Monitors sowie dem lock-Statement hinreichend beschrieben sein sollte.
Fassen wir noch einmal zusammen:
- Das lock-Statement wartet, bis es eine Sperre registrieren konnte. Die Freigabe der Sperre erfolg in einem "finally"-Block (die geschweifte Klammer zu des lock-Blocks).
- Enter versucht eine Sperre zu registrieren und kehrt sofort zurück. Entweder ist die Sperre registriert (Rückgabe true) oder eben nicht (Rückgabe false). Auf jeden Fall muss sich der Programmierer Gedanken im das Exception Handling machen.
- TryEnter erweitert Enter um die Möglichkeit, einen bestimmten Zeitraum auf eine Sperre zu warten. Entweder wird die Sperre in diesem Zeitraum registriert (Rückgabe true) oder die Zeit läuft ab (Rückgabe false). Auch hier muss der Programmierer sich um das Exception Handling selbst kümmern.
Im nächsten Teil werde ich die restlichen Funktionen der Klasse Monitor beleuchten: Monitor.Wait(), Monitor.Pulse() und Monitor.PulseAll(). Bis dahin freue ich mich auf Feedback und/oder Anregungen.