14. Okt 2024
Lesedauer 6 Min.
C#-Locking
Multithreading
Damit parallel laufende Threads nicht zu falschen Ergebnissen führen.

Multithreading ist immer ein spannendes Thema. Der Begriff – häufig auch als Nebenläufigkeit bezeichnet – steht in der Informatik für das gleichzeitige (oder quasigleichzeitige) Abarbeiten mehrerer Threads (Ausführungsstränge) innerhalb eines einzelnen Prozesses oder eines Tasks [1]. Im Gegensatz zum Multitasking, bei dem mehrere unabhängige Programme voneinander abgeschottet quasigleichzeitig ausgeführt werden, sind die Threads eines Anwendungsprogramms nicht voneinander abgeschottet und können somit durch sogenannte Race Conditions Fehler verursachen.Wenn sich mehrere Threads gleichzeitig um eine Ressource streiten, kommt deshalb das Thema Locking mit C# ins Spiel. Doch was steckt eigentlich hinter einem solchen Lock, und was ändert sich mit der neuesten C#-Version 13?
Warum Locking eine Rolle spielt
Unter einem Lock oder Locking (englisch für Sperre oder Sperren) versteht man in der Informatik das Sperren des Zugriffs auf ein Betriebsmittel [2]. Eine solche Sperre ermöglicht den exklusiven Zugriff eines Prozesses auf eine Ressource, es wird garantiert, dass kein anderer Prozess diese Ressource liest oder verändert, solange die Sperre besteht.Das Grundproblem beim Thema Locking soll anhand der Beispielklasse Konto (Bild 1) demonstriert werden. Die Klasse besitzt ein öffentliches Feld namens kontostand vom Typ int. Die Beispielanwendung startet über den ThreadPool zwei Aufrufe derselben Methode Konto.Manipulate, welche den Kontostand kontinuierlich erhöht. In einer idealen Welt würde die Methode dazu führen, dass der Kontostand auf eine Million anwächst.
Die Beispielklasse Konto mit dem öffentlichen Feld Kontostand vom Typ Integer (Bild 1)
Autor
Da die Methode zwei Mal aufgerufen wird, müsste der Kontostand, nachdem beide Aufrufe abgearbeitet sind, auf zwei Millionen ansteigen. So weit die in einer idealen Welt erdachte Theorie.Startet man die Beispielanwendung mehrfach, kommt es bei fast jedem Durchlauf zu einem anderen Ergebnis. Bild 2 zeigt ein Beispiel eines Durchlaufs. Die erwarteten zwei Millionen sind nicht dabei. Im Gegenteil: Die Ergebnisse sehen so aus, als würde die Anwendung bei ihrer Ausführung mit einem Würfel hantieren.

Unerwartete und unterschiedliche Ergebnisse bei jedem Programmdurchlauf (Bild 2)
Autor
Der Grund für dieses Verhalten liegt darin, dass beide Threads ohne jede Synchronisation dasselbe Datenfeld ändern. Das Inkrementieren eines Integer-Wertes (kontostand++) lässt sich (etwas vereinfacht) herunterbrechen auf die folgenden Operationen:
- Der aktuelle Wert wird ausgelesen.
- Der Wert wird erhöht.
- Der neue Wert wird in den Speicherbereich zurückgeschrieben.
Lösungen
Je nach Problemstellung gibt es für solche Szenarien unterschiedliche Lösungsansätze. Und für manche Spezialfälle gibt es sogar sehr einfache Lösungen. Für ein Integer-Feld wie in der Beispielklasse hat Microsoft die Klasse Interlocked geschaffen, welche das Inkrementieren des Integers atomar ausführt. Bereits eine kleine Anpassung des Codes der Klasse führt sofort zur optimalen Lösung. Bild 3 zeigt die zusätzliche Zeile Interlocked.Increment(ref kontostand), die dazu führt, dass das Problem nicht mehr auftritt. In dieser Programmfassung wird der Kontostand korrekt berechnet und erreicht die erwartete Zielsumme von 2 Millionen.
Interlocked behebt das Problem für Integer-Werte und liefert das erwartete Ergebnis (Bild 3)
Autor
Doch so schön diese Lösung auch sein mag, sie garantiert nur die „korrekte“ Abarbeitung. Es ist hier durchaus fraglich, ob eine Parallelisierung mit mehreren Threads für eine schnellere Abarbeitung sorgt, da sich in diesem Spiel zwei Threads um diesen Integer streiten (Thema: Parallelisierung ist nicht in jedem Fall sinnvoll).Außerdem: Für Integer-Felder ist die Klasse Interlocked die perfekte Lösung, allerdings hilft sie nicht weiter, wenn der Entwickler es mit komplexeren Datentypen oder eigenen Klassen zu tun hat.
Der Hammer: lock
Ein universeller „Hammer“, wie ich es gerne umschreibe, ist das Schlüsselwort lock in C#. Es erlaubt unabhängig vom Datentyp, dass der Entwickler einen sogenannten Sperrbereich definiert, in den jeweils nur ein Thread „eintreten“ und darin seinen Code ausführen darf.Die grundsätzliche Verwendung ist hierbei, dass mittels des Schlüsselworts lock ein .NET-Objekt als Schlüsselobjekt ausgewählt und dieses abgesperrt wird. Ein zweiter Thread kann den gesperrten Bereich so lange nicht betreten, bis dieser wieder freigegeben wird. In Bild 4 sehen Sie den entsprechend modifizierten Code der Beispielanwendung. Ein Programmlauf beweist auch hier wieder, dass damit der korrekte Kontostand ermittelt wird.
Lock sperrt den Zugriff auf den Kontostand und führt ebenfalls zum richtigen Endergebnis (Bild 4)
Autor
Aufgrund des Locks können wir Entwickler nun innerhalb des Sperrbereichs mit den Daten arbeiten, wie wir wollen, da garantiert wird, dass der aktuelle Thread der einzige ist, welcher den Bereich betreten darf. Sollte ein weiterer Thread den Sperrbereich betreten wollen, so wird dieser in der Zeile des lock festhängen, bis die Sperre freigegeben wurde.Die Common Language Runtime (CLR) von .NET sorgt dafür, dass ein Lock immer speziell für einen Thread gilt, weshalb auch ein doppeltes Versperren möglich ist (Bild 5). Das ist übrigens auch der Grund dafür, dass sich async und await mit lock nicht gut vertragen, da nach einem await eventuell ein Threadwechsel stattfindet und somit ein anderer Thread das gesperrte Objekt wieder freigibt, das er gar nicht hat. Damit das nicht passiert, quittiert der Compiler den Versuch, lock mit async und await zu kombinieren, ebenfalls mit einer Fehlermeldung.

Ein doppelter Lock (Bild 5)
Autor
Hinter den Kulissen
Spannend ist, was konkret passiert, wenn man den Lock-Befehl benutzt. Der Compiler setzt den Lock (bisher) immer mit der Klasse System.Threading.Monitor um. Diese beherbergt die Magie, um den Sperrbereich korrekt zu realisieren. In einem try/finally-Block wird versucht, diese Sperre über die Monitor-Klasse abzubilden und beim Verlassen wieder freizugeben, siehe Bild 6. Unter der Haube wird dabei im Fall von Windows die Funktion EnterCriticalSection aus dem Win32-API [3] genutzt, wie der Code-Schnipsel in Bild 7 zeigt. Das bedeutet: Eigentlich liefert das Betriebssystem die für einen Lock erforderliche Funktion.
Die Klasse System.Threading.Monitor setzt den Lock um ... (Bild 6)
Autor

... verwendet wird dabei die Funktion EnterCriticalSection aus dem Win32-API (Bild 7)
Autor
C# 13 ändert etwas …
Mit C# 13 ändert sich genau in diesem Zusammenhang nun etwas. Microsoft hat einen neuen Datentyp namens Lock eingeführt. Einerseits soll er die Lesbarkeit verbessern, da gerade die Signatur unseres Feldes object syncobject eine sehr übliche Form ist, aber es purzelt ein einfaches System.Object einfach so herum, was sich 2024 doch etwas „falsch“ anfühlt. Mit dem Datentyp Lock kann einerseits ganz klar ausgedrückt werden, dass es sich hier um ein Lock-Objekt handelt. Der betreffende Code im Beispiel sieht damit dann so aus:
private Lock _syncObject = new Lock();
Andererseits ist es nun nicht mehr zwangsweise so, dass der Compiler immer die Monitor-Klasse verwendet, sondern er kann mit C# in Version 13 auch auf alternative Wege zurückgreifen.Sieht man sich den erzeugten IL-Code an, so erkennt man, dass im Fall von .NET 9 und dem neuen Datentyp Lock die Methode EnterScope verwendet wird und das Objekt am Ende in einem try-finally-Block mit Dispose wieder freigegeben wird (Bild 8). Die neue Lock-Klasse stützt sich dabei also nicht mehr auf die Monitor-Klasse (die das Win32-API nutzt), sondern verschiebt die Sperrlogik in den .NET-Bereich – weg vom Betriebssystem.

Ein Objekt sperren mit C#13 und der neuen Lock-Klasse (Bild 8)
Autor
Fazit
Locking ist ein Thema, welches bei Multithreading immer aufkommt, und das Thema ist nicht nur alt, sondern auch unangenehm komplex. Aus diesem Grund wurde hier noch einmal klargestellt, warum Locking ein wichtiges Thema für Entwickler ist, und vor allem, dass Microsoft hier eine Änderung in C# 13 einführt.Fussnoten
- Multithreading, http://www.dotnetpro.de/SL2411NETirol1
- Lock, http://www.dotnetpro.de/SL2411NETirol2
- Die Funktion EnterCriticalSection aus dem Win32-API, http://www.dotnetpro.de/SL2411NETirol3