# Sunday, June 08, 2008

Multithreading - Teil 1 - das lock-Statement

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.

Sunday, June 08, 2008 4:54:14 PM (W. Europe Daylight Time, UTC+02:00) #    Comments [0] | Trackback
Comments are closed.