Dieser Blog erklärt verschiedene locking Mechanismen mit ihren Vor- und Nachteilen und listet bewährte Best Practices für locking auf. Das verwandte Problem der Code Reentrancy wird ebenfalls erklärt und gelöst.
In Teil 2 Die Gefahren von Multithreading wurde erklärt wie Datakorruption entsteht und dass man mit locking den Zugriff auf die Daten schützen kann. Im gleichen Teil wurde ebenfalls die Gefahr von Code Reentrancy erwähnt: ein Problem das Auftritt wenn der UI Thread freigegeben wird während des Wartens auf eine asynchrone Antwort und so die gleiche Methode nochmals aufgerufen werden kann.
Das Problem
Sobald mehrere Threads auf die gleiche Resource zugreifen wird es gefährlich. Aber nur wenn einer der Zugriffe schreibt, also den State ändert.
Wann muss gelockt werden?
Solange alle Threads nur lesen besteht keine Gefahr. Eine Ausnahme ist der Zugriff auf eine Hardware Resource die nur einen Benutzer haben kann. Oft regelt der Treiber diese Zugriffe durch serialisieren, zum Beispiel ein Drucker Spooler. Wenn man einen Low Level Zugriff programmiert, muss man dieser Zugriff selber regeln durch z.B. Serialiserung, Transaktionen oder Verrieglung.
Eine Gefahr auf Datakorruption besteht beim Zugriff in folgenden Fällen:
- Geteiltes Memory: Variablen, Puffer, usw.
- Single User Resourcen, typisch Hardware
- Bearbeitungen die States Teilen
Eine Instruktion in C# besteht fast immer aus mehreren CPU Instruktionen die in der Mitte durch einen anderen Thread unterbrochen werden können. Wenn diese Bearbeitungen nicht Zustandslos sind muss dieser Abschnitt (Critical Section) gelockt werden.
Wann braucht nicht gelockt zu werden?
Methoden die ausschliesslich mit Parametern und lokalen Variablen (werden auf dem Stack angelegt) arbeiten sind reentrant und müssen nicht gelockt werden.
Immutables sind Variablen die nach jeder Änderung eine neue Instanz zurückgeben. Beispiele von Immutables sind ‚string‘ und Event Handlers (die ‚+=‘ und ‚-=‘ Operatoren). Immutables sind Thread Safe.
Viele Datentypen und Collection sind speziell für Multi-Threading Zugriffe entworfen; sie sind Thread Safe und müssen deshalb nicht gelockt werden.
Wie lockt man?
Das ‚lock‘ Statement
Die C# Sprache hat das Statement ‚lock‘ integriert. Verwendung:
public class BestPracticeLock { private readonly object _countLock = new object(); private int _count; public int Count { get { lock (_countLock) { return _count; } } set { bool raiseCountChanged = false; lock (_countLock) { if (_count != value) { _count = value; raiseCountChanged = true; } } if (raiseCountChanged) { OnCountChanged(); } } } public event EventHandler CountChanged; protected virtual void OnCountChanged() { CountChanged?.Invoke(this, EventArgs.Empty); } }
Das Statement ‚lock‘ ist syntactic sugar für folgenden Code:
bool lockWasTaken = false; var temp = obj; try { Monitor.Enter(temp, ref lockWasTaken); { body } } finally { if (lockWasTaken) Monitor.Exit(temp); }
Mit Attribute
Eine Best Practice ist die Critical Section so kurz wie möglich zu halten. Eine andere ist Locks granular einzusetzen, sprich verwandte Daten mit einem eigenen Lock zu schützen. Wenn man lockt mit Attribute können diese Regel nicht eingehalten werden.
[MethodImpl(MethodImplOptions.Synchronized)] public void DoSomething() { // Code }
ReaderWriterLockSlim
Wenn Threads eine Resource oft lesen aber selten schreiben, ist das ‚lock‘ Statement nicht ideal. Der lock bremst nämlich 2 Threads aus die gleichzeitig die Ressource lesen wollen. Beim Lesen werden die Ressourcen ja nicht geändert und so schliessen die lesenden Threads einander unnötig aus.
Ein typisches Beispiel ist eine Konfigurationsdatei die selten geschrieben wird jedoch oft gelesen wird.
Für diesen Fall gibt es den ReaderWriterLockSlim.
Lock beanspruchen und zurückgeben für lese Aktionen: EnterReadLock()/ExitReadLock().
Lock beanspruchen und zurückgeben für schreibende Aktionen: EnterWriteLock()/ExitWriteLock().
Folgendes Beispiel simuliert ein oft frequentierter Lesezugriff auf einer Datei. Die Datei wird durch einen zyklischen Thread gleichzeitig beschrieben. Zum Lesen wird der DispatcherTimer verwendet welcher immer auf dem UI Thread ausgeführt wird. Thread Remarshalling ist also nicht nötig.
public partial class MainWindow : Window { const string DemoFileName = "ReaderWriterLockSlim.txt"; private readonly PeriodicTask _periodicTask; private readonly ReaderWriterLockSlim _readWriteConfigLock = new ReaderWriterLockSlim(); private readonly DispatcherTimer _timer; public MainWindow() { InitializeComponent(); _timer = new DispatcherTimer(); _timer.Interval = TimeSpan.FromSeconds(1); _timer.Tick += Timer_Tick; _timer.Start(); _periodicTask = new PeriodicTask(); _periodicTask.Start(DoWork, null, 100); } protected override void OnClosed(EventArgs e) { _timer.Stop(); _periodicTask.Stop(); base.OnClosed(e); _readWriteConfigLock.Dispose(); // ReaderWriterLockSlim implements IDisposable! } private void DoWork(object state, CancellationToken cancellationToken) { try { _readWriteConfigLock.EnterWriteLock(); File.WriteAllText(DemoFileName, DateTime.Now.ToLongTimeString(), Encoding.UTF8); } catch (Exception ex) { Debug.WriteLine(ex); // Do something to handle problem... } finally { _readWriteConfigLock.ExitWriteLock(); } } private void Timer_Tick(object sender, EventArgs e) { try { _readWriteConfigLock.EnterReadLock(); txtOutput.Text = File.ReadAllText(DemoFileName, Encoding.UTF8); } catch (Exception ex) { Debug.WriteLine(ex); // Do something to handle problem... } finally { _readWriteConfigLock.ExitReadLock(); } } }
Mutex
Mit einem Mutex ist es möglich Prozess Übergreifend Resourcen zu locken. Eine typische Anwendung ist das Verhindern von einem Programmmehrstart. Mit einem Mutex kann man auch z.B. Instanz spezifische Konfigurationsdateien zuweisen wenn ein Programm mehrfach gestartet werden soll.
Beispiel Verhindern Mehrfachstart
/// <summary> /// Class implemented to prevent multiple start: /// 1. Add this file called program.cs /// 2. Add Main() method with content as shown here /// 3. Set start object to this one in properties of project /// </summary> public class Program { /// <summary> /// Custom entry point: set start object to this object. /// </summary> /// <param name="args"></param> [STAThread] public static void Main(string[] args) { string mutexName = Path.GetFileName(Assembly.GetEntryAssembly().GetName().Name); Mutex namedMutex; if (Mutex.TryOpenExisting(mutexName, out namedMutex) == false) { namedMutex = new Mutex(false, mutexName); GC.KeepAlive(namedMutex); App.Main(); } // There is already an instance running of this program else { MessageBox.Show("There is already an instance running of this program.", mutexName, MessageBoxButton.OK, MessageBoxImage.Information, MessageBoxResult.OK, MessageBoxOptions.None); } } }
Man muss bei WPF Projekte das eigene Startobjekt noch einstellen:
Async Locks
Alle Locks, die bis jetzt vorgestellt wurden, blockieren den unterliegenden Thread, bis der Lock freikommt. Damit im Kontext vom async\await den Thread freigegeben wird während des Wartens, gibt es seit .NET 4.5 das WaitAsync() auf dem SemaphoreSlim.
public class AsyncLock { private readonly SemaphoreSlim _counterLock = new SemaphoreSlim(1); private int _counter = 0; public async Task DelayAndIncrementCounterAsync() { try { await _counterLock.WaitAsync(); await Task.Delay(1000); _counter++; } finally { _counterLock.Release(); } } }
Verschachtelung vom gleichen Lock
In komplexere Klassen kommt es vor, dass von einer kritischen Sektion z.B. eine Eigenschaft gelesen werden muss die mit dem gleichen Lock gesichert ist. ‚lock‘ Statements auf die gleiche lock-Variable können beliebig verschachtelt werden (= reentrant).
Aber:
Reentrance
Mit async\await und ConfigureAwait(true) wird der UI Thread wieder freigegeben während des Wartens auf den asynchronen Event und wird die WindowsMessage Queue weiter abgearbeitet. So entsteht die gleiche Gefahr wie mit Application.DoEvents() bei WinForms: Reentrance. Die Methode die am Warten ist kann nochmals aufgerufen werden.
Weil der gleiche Thread den gleichen Code aufruft funktioniert ein lock hier nicht.
Entweder disabled man das Steuerelement welches den Aufruf betätigt oder man benutzt einen boolean Semaphor (z.B. xxxInProgress).
private async void BtnStartAsyncAwaitReentrance_Click(object sender, RoutedEventArgs e) { _btnAsyncAwaitReentrance.IsEnabled = false; try { await LongRunningOperationAsync(); // Further handling of count on the UI Thread... } catch (OperationCanceledException) { // Task canceled ... } catch (Exception ex) { // Task threw an exception MessageBox.Show(ex.ToString()); } finally { _btnAsyncAwaitReentrance.IsEnabled = true; } }
Das Statement ‘volatile’
Das Statement ‚volatile‘ geschrieben vor einer Variable hat folgende Konsequenzen für die Variable:
- Die Variable wird durch den Compiler nicht weg optimiert. Der Compiler kann nicht wissen, ob zum Beispiel unmanaged Code auf die Variable zugreift.
- Die Variable wird nicht gecached (z.B. in einem Prozessor Register). Jeder Lesezugriff hat garantiert den aktueller Wert der Variable.
Microsoft selber macht Werbung dafür das volatile Keyword einzusetzen in Kombination mit Multithreading als „light weight“ lock:
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatile
Es gibt Duzende Blogs im Netz die stark davon abraten und stattdessen die Verwendung vom lock-Statement vorschlagen.
Best Practice: Locking
- Verwende immer als lock-Objekt: private readonly _nameLock = new object(); ‚name‘ dokumentiert die gelockte Variable.
- Werfe nie einen Event oder rufe nie eine Fremdmethode auf in einem Lock.
- Verzichte auf die Verwendung vom Statement volatile, benutze lock stattdessen.
- Gebe das lock-Objekt nie nach draussen.
- Halte die gelockte Sektion so kurz wie möglich.
- Das await Statement in einem Lock ist verboten.
- Bevorzuge immer das lock-Statement, nicht das MethodImplOptions.Synchronized Attribute und ReaderWriterLockSlim oder SemaphoreSlim. Nur wenn es klare Vorteile gibt.
- So granular wie möglich lock-Objekte einsetzen: Daten die zusammengehören haben ihren eigenen lock.
- Verrichte nur leichte Arbeiten in der gelockten Sektion.
Follow up
Nächstes Mal wird als Vorbereitung auf TPL (Task Parallel Library) und asyn\await das Task-Objekt erklärt.
Pingback: Noser Blog C# Concurrency Teil 5: Cross Thread Aufrufe - Noser Blog
Pingback: Noser Blog C# Concurrency Teil 6: Die Task-Klasse - Noser Blog