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.
Sobald mehrere Threads auf die gleiche Resource zugreifen wird es gefährlich. Aber nur wenn einer der Zugriffe schreibt, also den State ändert.
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:
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.
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.
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);
}
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
}
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();
}
}
}
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:

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();
}
}
}
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:
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‘ geschrieben vor einer Variable hat folgende Konsequenzen für die 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.
Nächstes Mal wird als Vorbereitung auf TPL (Task Parallel Library) und asyn\await das Task-Objekt erklärt.
[…] ← Vorige Post Nächster Post → […]
[…] ← Vorige Post […]