1. Einleitung
In den meisten typischen Anwendungen laufen Events schnell und quasi "kostenlos" — die CLR (Common Language Runtime) ist sehr gut für deren Verarbeitung optimiert. Wenn die Anwendung jedoch wächst, es viele Events gibt, die Subscriber-Listen lang werden und die Performance-Anforderungen steigen, stellt man plötzlich fest: selbst so eine "einfache" Konstruktion wie Events kann ein Flaschenhals sein. Besonders sichtbar wird das in Systemen mit vielen Echtzeit-Updates, Benutzeroberflächen (UI) oder beim Verarbeiten Hunderttausender Benachrichtigungen von Sensoren in IoT-Anwendungen.
In dieser Vorlesung zerlegen wir:
- Wie Events und Delegates die Performance beeinflussen.
- Welche Engpässe es gibt.
- Wie man schnellen ereignisgesteuerten Code schreibt und Probleme vermeidet, die die Performance bremsen.
Innere Struktur von Events in .NET
Wie schon erwähnt, ist ein Event eine Hülle um einen Delegate. Ein Delegate ist ein spezielles Objekt, das eine Liste von Methoden (invocation list) enthält, die bei der Invocation aufgerufen werden. Bei jedem Aufruf eines Events durchläuft die CLR diese Liste und ruft synchron alle Methoden auf. (Asynchronität entsteht nur, wenn ihr selbst asynchronen Code hineinpackt.)
Anschauliches Schema:
[Publisher] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
--> [Handler 2]
--> [Handler N]
2. Kosten von Delegates und Events: Zerlegung in Atome
Speicherkosten
- Jeder Delegate ist ein vollwertiges Objekt.
- Jeder Handler (Methoden-Subscriber) erzeugt noch einen Delegate.
- Je mehr Subscriber — desto mehr Objekte, desto höher der Speicherbedarf.
In einfachen Fällen gibt es kaum Leak- oder Overhead-Probleme. Aber wenn es tausende Handler gibt — sollte man nachdenken!
Aufrufkosten
- Ein Event-Aufruf = Iteration über die invocation list.
- Jede Methode wird synchron aufgerufen (eine nach der anderen).
- Wenn ein Handler schwere Arbeit macht oder lange "schläft", bremst das alle anderen.
Beispiel: einfache Implementierung
public class Counter
{
public event EventHandler Counted;
public void Increment()
{
// ... Zähl-Logik ausgelassen
// Subscriber werden synchron aufgerufen!
Counted?.Invoke(this, EventArgs.Empty);
}
}
Wenn wir 1000 Subscriber haben, deren Handler Thread.Sleep(10) aufrufen, dauert der Event-Aufruf schon etwa 10 Sekunden...
3. "Schwere" Subscriber — Feind der Performance
Warum sollten Handler "leicht" sein?
- Events werden synchron aufgerufen, der aufrufende Thread wartet auf das Ende aller Handler.
- Ein langsamer Handler bremst die gesamte Kette.
- Wenn ein Handler mit einer Exception abstürzt — können die übrigen nicht aufgerufen werden (sofern ihr den Aufruf nicht mit try/catch sichert).
Demonstration
class Program
{
static void Main()
{
var publisher = new Counter();
// Schnell
publisher.Counted += (s, e) => Console.WriteLine("First");
// Langsam
publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
// Noch einer
publisher.Counted += (s, e) => Console.WriteLine("Last");
// Zeitmessung
var watch = System.Diagnostics.Stopwatch.StartNew();
publisher.Increment();
watch.Stop();
Console.WriteLine($"Alle Handler wurden in {watch.ElapsedMilliseconds} ms aufgerufen.");
}
}
Probiert das aus — ihr werdet eine deutliche Pause sehen. Der erste Handler ist fast sofort, der zweite verursacht die Verzögerung, und erst danach kommt der dritte.
Praktische Schlussfolgerung
- Packt keine schwere Business-Logik direkt in Event-Handler!
- Führt solche Arbeit besser in einem separaten Thread, Task oder asynchronen Handler aus.
4. Ausnahmen in Handlern: Fallen für die Performance
Wenn einer der Subscriber eine Exception wirft, wird die Event-Verarbeitung abgebrochen — nachfolgende Handler werden eventuell nicht aufgerufen!
publisher.Counted += (s, e) => throw new Exception("Fehler!");
publisher.Counted += (s, e) => Console.WriteLine("Diese Zeile sehen Sie nicht.");
Um das zu vermeiden und nicht wegen eines "schlechten Apfels" alles zu bremsen, verwendet eine manuelle Iteration mit Schutz um jeden Handler.
Fortgeschrittene Variante des Event-Aufrufs
protected virtual void OnCounted()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
try
{
((EventHandler)handler)(this, EventArgs.Empty);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler im Handler: {ex.Message}");
// Logging oder spezielle Fehlerbehandlung
}
}
}
}
Das macht das Event robuster: auch wenn ein Subscriber abstürzt — die anderen laufen weiter.
5. Asynchrone (fire-and-forget) Events
Wenn ein Event langsam sein kann — will man manchmal die Handler in separaten Threads oder Tasks starten, um den Hauptthread nicht zu blockieren.
Variante 1: Jeden Handler in einem eigenen Task starten
protected virtual void OnCountedAsync()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
// Fire-and-forget: wir warten nicht auf Abschluss!
System.Threading.Tasks.Task.Run(() =>
{
((EventHandler)handler)(this, EventArgs.Empty);
});
}
}
}
Aber! Vorsicht mit Parallelismus
- Wenn Subscriber gemeinsame Ressourcen nutzen — drohen race conditions.
- Ausnahmen in fire-and-forget Handlern sind schwer zu fangen.
- Wenn es wichtig ist, auf alle Subscriber zu warten — sammelt die Tasks und verwendet Task.WhenAll.
Für UI (WinForms/WPF) — ruft Handler niemals außerhalb des UI-Threads auf, sonst bekommt ihr eine InvalidOperationException.
Im Allgemeinen brauchen asynchrone Events ein überlegtes Design!
6. Optimierung von Speicherung und Aufruf von Events
"Leere" Events: Speicher sparen
Wenn eine Klasse viele Events hat, von denen die meisten selten benutzt werden (z.B. viele Events in einem UI-Component), gibt es einen Trick: EventHandlerList.
Wie das funktioniert
.NET-Controls (z. B. in WinForms) speichern nicht für jedes Event einen eigenen Delegate, sondern packen alle Events in eine Struktur (EventHandlerList) — nur wenn mindestens ein Handler angemeldet ist, wird dort etwas abgelegt.
Beispiel für manuelles Erstellen von EventHandlerList
using System.ComponentModel; // EventHandlerList lebt hier!
class MyControl
{
private readonly EventHandlerList _events = new EventHandlerList();
private static readonly object EventMyEvent = new object();
public event EventHandler MyEvent
{
add { _events.AddHandler(EventMyEvent, value); }
remove { _events.RemoveHandler(EventMyEvent, value); }
}
protected virtual void OnMyEvent()
{
var handler = (EventHandler)_events[EventMyEvent];
handler?.Invoke(this, EventArgs.Empty);
}
}
Warum das nützlich ist: ihr spart Speicher, weil ihr nicht für hunderte "leerer" Events unnötige Delegates erstellt.
7. Thread-Safety: Vermeidung von Races und Locks
Events in .NET sind an sich NICHT thread-safe! Während ein Subscriber sich an- oder abmeldet, kann ein anderer Thread gleichzeitig das Event auslösen. Das kann dazu führen, dass der Delegate direkt vor dem Aufruf auf null gesetzt wird und eine NullReferenceException ausgelöst wird.
Best Practices
- Verwendet den Operator ?. (Counted?.Invoke(...)) — schützt vor null.
- Für komplexe Fälle — sperrt den Zugriff auf das Event mit lock.
Beispiel
private readonly object _lockObj = new object();
private EventHandler _myEvent;
public event EventHandler MyEvent
{
add { lock (_lockObj) { _myEvent += value; } }
remove { lock (_lockObj) { _myEvent -= value; } }
}
protected virtual void OnMyEvent()
{
EventHandler handler;
lock (_lockObj)
{
handler = _myEvent;
}
handler?.Invoke(this, EventArgs.Empty);
}
Wann braucht man diese Komplexität?
- In multithreaded Anwendungen (z. B. Server, Multithread-Parser etc.).
- Wenn Subscription/Unsubscription aus verschiedenen Threads passieren und das Event aus einem weiteren Thread ausgelöst wird.
8. Accessors add/remove zur Kontrolle und Optimierung
In speziellen Fällen (z. B. wenn ihr alle Subscriptions loggen oder die Anzahl der Subscriber begrenzen wollt) kann man ein Event manuell über Accessors implementieren:
private EventHandler _event;
public event EventHandler MyEvent
{
add
{
if (_event == null || _event.GetInvocationList().Length < 10)
_event += value;
else
Console.WriteLine("Beschränkung: mehr als 10 Subscriber sind nicht erlaubt.");
}
remove { _event -= value; }
}
Das erlaubt:
- Custom-Logik einzuschleusen.
- Events thread-safe zu machen.
- Limits zu prüfen oder Subscriptions/Unsubscriptions zu loggen.
9. Nützliche Feinheiten
Lambda-Ausdrücke, Closures und Performance
Lambda-Ausdrücke sind praktisch für Inline-Subscription:
var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");
Wenn eine Lambda-Expression Variablen einfängt — entsteht ein Closure, was den Speicherverbrauch erhöhen kann. In den meisten UI-Fällen ist das unkritisch, aber in Low-Level-Code sollte man auf Anzahl der Closures und die Lebensdauer der eingefangenen Objekte achten.
Interessanter Fakt:
Wenn man zwei identische Lambdas hintereinander hinzufügt, sind das zwei verschiedene Delegate-Objekte, und die Methode wird zweimal ausgeführt.
Profiling von Events und Delegates
Wenn die Anwendung groß und komplex wird, muss man Events genauso profilieren wie jeden anderen Code.
Wie misst man die Geschwindigkeit eines Events?
- Verwendet Stopwatch, um die Zeit zwischen Event-Auslösung und Ende der Verarbeitung zu messen.
- Benutzt Memory-Profiling-Tools (z. B. dotMemory, die eingebauten Tools von Visual Studio), um Subscriber zu finden, die nicht abgemeldet wurden und im Speicher hängen bleiben.
- Um "Zombie-Subscriber" zu finden, sucht nach langen invocation lists auf langlebigen Objekten.
Tabelle "Optimierungen und Fallen"
| Problem/Szenario | Lösung |
|---|---|
| Viele langlebige (und nutzlose) Events | EventHandlerList verwenden |
| Subscriber bremst alle | Schwere Logik in task/separaten Thread auslagern |
| Thread-Safety | Delegate vor Aufruf kopieren, lock beim Hinzufügen/Entfernen |
| Ausnahmen in Handlern | try/catch um jeden Handler |
| Memory-Leaks durch "Zombie-Subscriber" | Immer abmelden, IDisposable implementieren, profilieren |
Diagramm: "Lebenszyklus eines optimierten Events"
+----------------+ +------------------+ +---------------------+
| Subscriber er | --> | Subscription (+=) | --> | Kam in Invocation |
+----------------+ +------------------+ +---------------------+
| ^
| |
Unsubscription (-=) | Exception |
v |
+----------------+ +--------------------+ +----------------------+
| Subscriber Dispose | --> | Aus Liste entfernt | --> | Kein Zombie mehr |
+----------------+ +--------------------+ +----------------------+
10. Wie man "Event-Management" im Vorstellungsgespräch erklärt
Wenn euch die Frage gestellt wird "Worin sind Events in C# ineffizient?" oder "Wann braucht man Event-Optimierung?", dann wisst ihr:
- Events sind gut für loose coupling, aber ineffizient bei massiver Subscription und schweren Handlern.
- Sie sind standardmäßig nicht thread-safe.
- Sie erfordern manuelles Abmelden (sonst Memory-Leaks).
- Für massive Producer/Subscriber sind EventHandlerList und eigene Accessors add/remove sinnvoll.
- Tiefergehende Kontrolle ist selten nötig — die meisten Fälle deckt das Standard-Pattern ab.
In der nächsten Vorlesung gehen wir zu fortgeschrittenen Szenarien und praktischen Beispielen von event-/delegate-gesteuertem Programmieren über, wo ihr sehen werdet, wie all diese Optimierungen in realen Aufgaben funktionieren.
Häufige Mythen und Antipatterns
- Zu glauben, Events in .NET seien immer schnell — sie sind schnell, bis viele Subscriber oder schwere Handler auftreten.
- Darauf zu hoffen, dass der GC alles automatisch "säubert" — nein, wenn ihr euch nicht abmeldet, lebt das Objekt weiter!
- Events für "weite" Verbindungen zwischen Business-Layern zu nutzen — stattdessen besser explizite Patterns verwenden (z. B. Mediator).
GO TO FULL VERSION