1. Einführung
Mutex und lock sind wie ein Barista, der einen Kunden nach dem anderen bedient. Aber was, wenn wir nicht nur eine Kaffeemaschine haben, sondern gleich drei — und gleichzeitig dürfen drei Tassen Kaffee zubereitet werden?
Zum Beispiel hast du ein Café mit drei Kaffeemaschinen. Kunden (Threads) kommen, besetzen eine freie Maschine, machen Kaffee und gehen wieder. Wenn alle drei Maschinen belegt sind, warten die anderen, bis mindestens eine frei wird.
Frage: Wie sorgt man dafür, dass nicht mehr als drei Kunden gleichzeitig an den Maschinen arbeiten und die anderen in der Warteschlange bleiben?
Antwort: einen Semaphore benutzen!
Was ist ein Semaphor?
Ein Semaphor ist ein klassisches Synchronisationswerkzeug. Wenn lock/Mutex "einer rein — die anderen warten" regeln, sagt ein Semaphor: "ich erlaube N gleichzeitig!".
Semaphore wurden 1965 von Edsger Dijkstra vorgeschlagen. Der Name kommt aus der maritimen Signalgebung: wie Fahnen Informationen übermitteln, so signalisiert ein Semaphor im Code den Threads — rein oder warten.
Szenarien für den Einsatz
- Begrenzung der Anzahl von Threads, die gleichzeitig auf eine Ressource zugreifen.
- Limit für gleichzeitige DB-Verbindungen, parallele Anfragen oder schwere Aufgaben.
2. Überblick über die Klassen: Semaphore und SemaphoreSlim
Semaphore
- Schwerer Klasse, nutzt Kernel-Objekte des OS.
- Unterstützt Synchronisation zwischen Threads verschiedener Prozesse.
- Kann einen Namen haben und zwischen Prozessen geteilt werden.
SemaphoreSlim
- Leichtgewichtige Version, funktioniert nur innerhalb eines Prozesses.
- Schneller und ressourcenschonender.
- Praktisch immer vorzuziehen, wenn keine Interprozess-Synchronisation nötig ist.
Analogie: Rucksack (SemaphoreSlim) vs. großer Koffer (Semaphore). Wenn du leicht reist — nimm den Rucksack.
Vergleichstabelle
| Klasse | Interprozess | Leistung | Empfohlen |
|---|---|---|---|
|
Ja | Langsamer | Wenn Synchronisation zwischen Prozessen nötig ist |
|
Nein | Schneller | In 99% der Fälle innerhalb eines Prozesses |
Wichtige Methoden und Eigenschaften des Semaphors
Wichtige Parameter
- InitialCount — die anfängliche Anzahl an Permits.
- MaxCount — das Maximum gleichzeitig ausgegebener Permits.
Schlüsselmethoden
- Wait() oder WaitAsync() — Zugriff anfordern (ein Permit nehmen).
- Release() — Permit freigeben.
Wie das funktioniert
Wenn beim Aufruf von Wait() keine Permits verfügbar sind, blockiert der Thread und wartet, bis jemand Release() aufruft. Nach dem Freigeben fährt einer der Wartenden fort.
3. Erstes praktisches Beispiel
Fügen wir einer Konsolenanwendung einen "Parkplatz" mit 3 Stellplätzen hinzu und starten 10 Threads.
using System;
using System.Threading;
class Program
{
// Semaphor mit 3 Permits (3 Parkplätze)
static SemaphoreSlim parking = new SemaphoreSlim(3);
static void Main()
{
for (int i = 1; i <= 10; i++)
{
int carNumber = i;
new Thread(() =>
{
Console.WriteLine($"Auto #{carNumber} versucht zu parken...");
parking.Wait(); // wartet auf einen freien Platz
Console.WriteLine($"Auto #{carNumber} hat den Parkplatz erreicht!");
Thread.Sleep(2000); // parkt für 2 Sekunden
Console.WriteLine($"Auto #{carNumber} verlässt den Parkplatz.");
parking.Release(); // Platz freigeben
}).Start();
}
}
}
- Es parken gleichzeitig nur drei Autos.
- Die anderen warten auf die Freigabe eines Platzes.
- Die Ausgabe ist vermischt — das ist normal bei Multithreading.
4. Semaphor als Lastbegrenzer
Begrenzen wir die Anzahl gleichzeitig laufender schwerer Tasks (z. B. Downloads) auf 5.
static SemaphoreSlim semaphore = new SemaphoreSlim(5); // maximal 5 gleichzeitige Downloads
static void DownloadFile(int fileId)
{
semaphore.Wait();
try
{
Console.WriteLine($"--> Starte Download von Datei {fileId}");
Thread.Sleep(1000 + fileId * 100); // Download-Simulation
Console.WriteLine($"<-- Datei {fileId} heruntergeladen");
}
finally
{
semaphore.Release();
}
}
static void Main()
{
for (int i = 1; i <= 12; i++)
{
int localId = i;
new Thread(() => DownloadFile(localId)).Start();
}
}
Wichtiger Punkt: Wait() platzierst du vor dem try-Block und Release() in finally. So wird das Permit auch bei einer Exception sicher freigegeben.
5. Wait(int millisecondsTimeout) und asynchrone Methoden
Man kann nur eine begrenzte Zeit warten:
if (semaphore.Wait(500))
{
// Es gelang, das Permit innerhalb einer halben Sekunde zu bekommen!
}
else
{
// Nach 500 ms nicht gewartet — abgebrochen
}
In modernen Anwendungen (z. B. ASP.NET) nutzt du die asynchrone Variante: await semaphore.WaitAsync(). Das blockiert den Thread nicht, während auf das Permit gewartet wird.
Hinweis: Im asynchronen Code verwende unbedingt SemaphoreSlim und sein WaitAsync, sonst kannst du unerwartete Deadlocks bekommen.
6. Beispiele für falsches und richtiges Verhalten
Ein häufiger Fehler — Release() zu vergessen: Permits "leaken" und alles bleibt stehen.
Schlecht
static void SomeWork()
{
semaphore.Wait();
// ... Verarbeitung, Release vergessen!
}
Gut
static void SomeWork()
{
semaphore.Wait();
try
{
// Verarbeitung
}
finally
{
semaphore.Release();
}
}
Asynchrone Variante
static async Task SomeAsyncWork()
{
await semaphore.WaitAsync();
try
{
// asynchrone Verarbeitung
}
finally
{
semaphore.Release();
}
}
7. Interna des Semaphors (erklärt einfach)
Ein Semaphor ist ein Zähler. Wait() verringert ihn um 1. Wenn er > 0 war — darf der Thread passieren; wenn er 0 ist — wartet der Thread. Release() erhöht den Zähler und weckt Wartende.
+-------------------------------+
| Semaphor (Zähler = 3) |
+-------------------------------+
| [ ] [ ] [ ] | <--- Permits
+----+----+----+----------------+
| | |
Thread Thread Thread
8. Nützliche Feinheiten
Unterschiede zu anderen Primitiven
- lock / Monitor / Mutex — lassen nur einen Thread (exklusiver Zugriff).
- Semaphore/SemaphoreSlim — lassen begrenzt N Threads gleichzeitig.
Ein Semaphor ist nicht an einen "Owner" gebunden: ein Permit kann von jedem Thread freigegeben werden. Das ist ein Feature, kein Bug.
Anwendungen im echten Leben
- Limit für parallele Verbindungen zu einem Service oder einer DB.
- Pool: nicht mehr als N Threads pro Ressource.
- Begrenzung gleichzeitig verarbeiteter Web-Requests.
- Limit für Lese-/Schreibzugriffe zum Schutz vor Überlast.
- Begrenzung von Aufrufen externer APIs.
Beispiel eines Fehlers (Release mehr als Wait)
var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // Fehler! Der Zähler wird 3, MaxCount wird überschritten — es wird eine SemaphoreFullException geworfen.
Hier tritt eine SemaphoreFullException auf: der Zähler hat das Maximum überschritten.
Unterschiede zwischen Semaphore und SemaphoreSlim
- SemaphoreSlim — prozessintern, schneller und einfacher (verwende es so gut wie immer).
- Semaphore — benötigt für Interprozess-Synchronisation (selteneres Szenario).
Warum Semaphoren kennen?
Klassische Interviewfrage: "Wie begrenzt man die Anzahl der Threads, die mit einer Ressource arbeiten?" — die richtige Antwort: Semaphore.
- lock — 1 Thread.
- Semaphore/SemaphoreSlim — N Threads.
9. Typische Fehler und Besonderheiten bei der Nutzung von Semaphoren
Fehler Nr.1: Release() wird vergessen. Wenn ein Thread ein Permit nimmt (Wait() oder WaitAsync()) und es nicht freigibt, warten die anderen endlos — die Anwendung "friert" ein.
Fehler Nr.2: Release() wird öfter aufgerufen als Wait(). Es entstehen "zusätzliche" Permits. Bei Semaphore führt das zu einer SemaphoreFullException und kaputter Zugriffslogik.
Fehler Nr.3: verschiedene Synchronisationsmechanismen werden gemischt. An einer Stelle lock, an anderer Stelle Semaphor für dieselbe Ressource — das erhöht das Risiko für Deadlocks.
Fehler Nr.4: Semaphore im asynchronen Code verwenden. Der klassische Semaphore spielt nicht gut mit async/await. Für asynchrone Szenarien nutze SemaphoreSlim und WaitAsync().
Fehler Nr.5: initialCount und maxCount falsch setzen. Bei falscher Wahl der Werte kann die Beschränkung umgangen werden und mehr Threads greifen auf die Ressource zu als geplant.
GO TO FULL VERSION