1. Warum ein normales lock unzureichend sein kann?
Gedankenexperiment: Wir haben eine gemeinsame Ressource — zum Beispiel eine Liste von Clients, die wir in unserer Multi-Thread-Anwendung irgendwo speichern. Die meiste Zeit lesen wir nur diese Liste — z.B. zeigen die UI Daten an, erstellen Berichte, filtern und sortieren Informationen. Nur gelegentlich fügt jemand einen neuen Client hinzu oder löscht einen alten.
Wenn wir das übliche lock verwenden, muss jeder Thread, der lesen möchte, warten, bis ein anderer Thread mit dem Lesen fertig ist oder, noch schlimmer, mit dem Schreiben. Und wenn etwa 99% der Operationen Leseoperationen sind? Dann bremsen wir alle Leser unnötig, obwohl sie parallel arbeiten könnten.
Hier kommt ein spezieller Lock-Typ ins Spiel — ein Reader-Writer Lock. Die Kernidee: viele Threads können gleichzeitig lesen, aber wenn jemand ändern will, bekommt er exklusiven Zugriff und blockiert die Leser.
2. Theorie: was ist ReaderWriterLockSlim
Kurz zu den Classen
- ReaderWriterLock — älter, langsamer, kann zu Deadlocks führen. Nutze es nur, wenn du .NET Framework 2.0 unterstützen musst.
- ReaderWriterLockSlim — die moderne, schnelle Alternative (Slim: „schlank“, „leichter“). Diese Classe ist für häufige konkurrierende Lese-/Schreiboperationen optimiert und wird empfohlen, wenn viele Threads existieren und Lesen deutlich häufiger ist als Schreiben.
Funktionsprinzip
ReaderWriterLockSlim implementiert drei Lock-Modi:
- Read Lock (Lese-Lock): viele Threads können gleichzeitig lesen.
- Write Lock (Schreib-Lock): nur ein Thread kann Daten ändern, und niemand anderes liest.
- Upgradeable Read Lock (erweiterbares Lese-Lock): nur ein Thread kann diesen Lock gleichzeitig halten; er liest, kann aber bei Bedarf auf ein write lock „aufsteigen”.
Lock-Schema
| Leser-Thread | Schreiber-Thread | Thread mit Upgradeable Read Lock | |
|---|---|---|---|
| Leser | ✅ | ⛔ | ✅/⛔ |
| Schreiber | ⛔ | ⛔ | ⛔ |
| Upgradeable Read | ✅* | ⛔ | ⛔ |
✅ — erlaubt
⛔ — blockiert
— nur wenn das upgradeable read lock nicht auf write hochgestuft wurde
Wann man ReaderWriterLockSlim verwenden (und wann nicht)
Empfohlen:
- Viele gleichzeitige Threads, die hauptsächlich gemeinsame Daten lesen (Nachschlagewerke, Cache, Konfigurationen).
- Schreibvorgänge sind selten (z. B. einmal pro Sekunde/Minute/Stunde).
- Wichtig ist maximale Read-Performance, ohne die Threads zu blockieren.
Nicht empfohlen:
- Lesen/Schreiben sind etwa gleich häufig (besser normales lock).
- Daten ändern sich oft (Gewinn ist gering).
3. Praxis: ReaderWriterLockSlim anwenden
Erweitern wir unsere Beispielanwendung! Früher haben wir ein einfaches Client-Verzeichnis gebaut und Multithread-Zugriff auf eine Collection ausprobiert. Jetzt wird es etwas anspruchsvoller: Angenommen, wir haben eine Liste von Clients, auf die mehrere Threads zugreifen (z. B. ein Service, der alle Clients über eine Aktion benachrichtigt, und ein anderer Thread fügt neue Clients hinzu).
Zuerst beschreiben wir unsere Client-Collection:
// Class Client — ein bekannter Held aus früheren Vorlesungen
public class Client
{
public int Id { get; set; }
public string Name { get; set; }
}
Jetzt erstellen wir eine Wrapper-Classe für thread-sicheren Zugriff mit ReaderWriterLockSlim:
using System;
using System.Collections.Generic;
using System.Threading;
public class ClientDirectory
{
private readonly List<Client> _clients = new List<Client>();
// Slim-lock für Lesen/Schreiben
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// Client hinzufügen (write lock)
public void AddClient(Client client)
{
_lock.EnterWriteLock(); // Nur ein Thread kann einen Client hinzufügen
try
{
_clients.Add(client);
Console.WriteLine($"[Thread {Thread.CurrentThread.ManagedThreadId}] Client hinzugefügt {client.Name}");
}
finally
{
_lock.ExitWriteLock();
}
}
// Kopie der Client-Liste erhalten (read lock)
public List<Client> GetClients()
{
_lock.EnterReadLock(); // Viele Threads können gleichzeitig lesen
try
{
// Wir geben eine Kopie zurück, damit niemand versehentlich das Original ändert
return new List<Client>(_clients);
}
finally
{
_lock.ExitReadLock();
}
}
}
4. Mehrere Leser und gelegentliche Schreiber
Angenommen, es gibt 5 Threads, die periodisch alle Clients lesen („Berichte bauen“ oder einfach nachschauen, wer da ist), und separat einen Thread, der manchmal einen neuen Client hinzufügt.
using System.Threading;
class Program
{
static void Main()
{
var directory = new ClientDirectory();
// Thread zum Hinzufügen von Clients
var writerThread = new Thread(() =>
{
for (int i = 1; i <= 5; i++)
{
directory.AddClient(new Client { Id = i, Name = $"Klient {i}" });
Thread.Sleep(700); // Simulation langer Operationen
}
});
// Mehrere Leser
for (int j = 0; j < 5; j++)
{
int readerId = j + 1;
new Thread(() =>
{
for (int k = 0; k < 10; k++)
{
var clients = directory.GetClients();
Console.WriteLine($"[Leser {readerId}][Thread {Thread.CurrentThread.ManagedThreadId}] Anzahl Clients: {clients.Count}");
Thread.Sleep(200); // Lesen ist häufiger als Schreiben
}
}).Start();
}
writerThread.Start();
writerThread.Join();
// Lesern Zeit geben, fertigzuwerden:
Thread.Sleep(3000);
}
}
In den meisten Fällen können mehrere Threads gleichzeitig die Client-Liste lesen (ohne aufeinander zu warten), aber wenn ein Thread einen Client hinzufügt — müssen die anderen Threads warten, bis der Schreibvorgang abgeschlossen ist. Das erlaubt, viele Anfragen zu bedienen ohne unnötiges Blockieren der Leser.
5. Upgradeable Lock: UpgradeableReadLock
Manchmal liest ein Thread Daten und will nur selten ändern, falls eine Bedingung erfüllt ist. Man könnte meinen: Read lock holen, Zustand prüfen, Read lock freigeben, Write lock holen. Aber in der Zwischenzeit kann ein anderer Thread die Daten geändert haben! Hier hilft die upgradeable Lock.
So benutzt man sie:
public bool AddClientIfNotExists(string name)
{
_lock.EnterUpgradeableReadLock(); // Nur ein Thread kann so einen Lock halten
try
{
bool exists = _clients.Exists(c => c.Name == name);
if (!exists)
{
_lock.EnterWriteLock(); // Auf write hochstufen
try
{
_clients.Add(new Client { Name = name });
return true;
}
finally
{
_lock.ExitWriteLock();
}
}
return false;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
Zuerst liest der Thread und falls nötig, erhöht er die Lock-Stufe, ohne die Liste anderen Threads freizugeben.
6. Nützliche Feinheiten
Vergleich: ReaderWriterLockSlim vs lock
|
|
|
|---|---|---|
| Gleichzeitiges Lesen | Nein | Ja |
| Gleichzeitiges Schreiben | Nein | Nein |
| Unterstützung für upgradeable read | Nein | Ja (EnterUpgradeableReadLock) |
| Geschwindigkeit | Sehr schnell | Schneller bei häufigen Lesezugriffen |
| Speicher | Minimum | Etwas mehr |
| Einfachheit | Einfach | Komplexer, mit Feinheiten |
Wie es in echten Projekten aussieht
In großen Anwendungen, wo es riesige Tabellen oder Caches gibt, die von hunderten Threads gelesen, aber selten geändert werden, hilft ReaderWriterLockSlim, Lesefluss-Stops zu vermeiden. Beispiele:
- Konfigurations-Cache, auf den Microservices zugreifen.
- Nachschlagewerke für finanzielle Berechnungen.
- Interner Cache für Message-Routing.
Ich betone — in den meisten Fällen, besonders wenn du gerade erst mit Multithreading anfängst, ist das normale lock einfacher und sicherer. ReaderWriterLockSlim ist für Fälle gedacht, wo wirklich viele gleichzeitige Lesezugriffe auftreten.
Visualisierung: Zugriffsdiagramm
flowchart TD
A[Leser-Thread 1] --Read--> D[ReaderWriterLockSlim]
B[Leser-Thread 2] --Read--> D
C[Schreiber-Thread] --Write--> D
D --Read erlaubt--> E[Lesen der gemeinsamen Collection]
D --Write blockiert Lesen/Schreiben--> F[Schreiben der gemeinsamen Collection]
Solange nur gelesen wird — der Zugang ist für alle offen; bei Schreibversuch warten alle, bis der Schreibvorgang endet.
7. ReaderWriterLockSlim: Feinheiten und typische Fehler
Lock Recursion (Rekursion): Standardmäßig erlaubt ReaderWriterLockSlim nicht, dass derselbe Thread denselben Lock-Typ mehrfach erwirbt. Das heißt, wenn ein Thread bereits einen read lock hält und versucht, ihn nochmal zu holen — wird eine Ausnahme geworfen. Gleiches gilt für write lock. Allerdings kann ein UpgradeableReadLock innerhalb auf ein write lock hochgestuft werden.
Reihenfolge nicht verwechseln: Erwirb EnterUpgradeableReadLock() → innerhalb davon EnterWriteLock(). Nicht anders herum!
Ausnahmen: Wenn du einen Lock nicht freigibst (z. B. kein ExitWriteLock() aufrufst), warten andere Threads ewig. Verwende deshalb immer try { ... } finally { ... }.
Nicht lange halten: Vermeide lange IO-Operationen innerhalb des Locks. Je länger ein Thread die Sperre hält, desto mehr Verzögerung für die anderen. Versuche, so schnell wie möglich aus Lock-Blöcken herauszukommen.
Abwägen der Komplexität: Nutze ReaderWriterLockSlim nicht für winzige Operationen, wo normales lock einfacher und schneller ist.
GO TO FULL VERSION